From 1e0219ba484ee54efd42bdb73c78b834cc0c4aaf Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Mon, 5 Oct 2026 01:01:02 -0500 Subject: [PATCH 1/3] docs: correct the ACL API references MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docs described an API that no longer exists and named the wrong namespaces, so a reader following them would look for a method that is not there and an interface that lives in another package. - `Webware\Acl\AclInterface` -> `Webware\Core\AclInterface` (the contract moved to webware-core; the implementation stays in webware-acl as `Webware\Acl\Acl`). - `Webware\UserManager\UserInterface`, which does not exist, -> `Webware\Core\UserInterface`. - `isAllowedByRouteName()` -> the inherited Laminas `isAllowed()` that `NavigationFilterIterator` actually calls, passing the route name as the resource. `isAllowedByRouteName()` was removed because it duplicated `isAllowedRoute()` in all but name. `build: align tooling with webware-tools` (61e3192) made the code change and the docs were never brought along. architecture.md's Decision 8 is marked SUPERSEDED rather than rewritten — it records why the method was introduced, and the route-mapping subsystem it describes was removed with it. The webware-tools alignment audit keeps its point-in-time findings and gains a note saying so. Signed-off-by: Joey Smith --- README.md | 7 +++---- docs/architecture.md | 9 ++++++++- docs/component-reference.md | 26 ++++++++++++++++---------- docs/extending.md | 4 ++-- docs/overview.md | 2 +- docs/testing.md | 13 ++++++------- docs/webware-tools-alignment.md | 8 ++++++++ 7 files changed, 44 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 47512dc..b23894f 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,9 @@ Private package — not published to Packagist. Consume via a VCS repository ent After each change run `composer update webware/webware-navigation` (the lock pins the commit hash). -Note: the package types against `Webware\Acl\AclInterface` and -`Webware\UserManager\UserInterface`, which the consuming application provides -until `webware-acl` + `webware-usermanager` publish — see -`docs/webware-tools-alignment.md`. +Note: the package types against `Webware\Core\AclInterface` and +`Webware\Core\UserInterface`, both provided by `webware/webware-core`, which this +package requires — see `docs/webware-tools-alignment.md`. Add to `config/config.php`: diff --git a/docs/architecture.md b/docs/architecture.md index 347880e..36b108a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,7 +209,14 @@ Placing it before `RouteMiddleware` (as was briefly the case during development) --- -## Decision 8 — isAllowedByRouteName on AclInterface +## Decision 8 — isAllowedByRouteName on AclInterface — SUPERSEDED + +> **Superseded — do not implement from this section.** `isAllowedByRouteName()` was +> removed from the ACL contract because it duplicated `isAllowedRoute()` in all but +> name, and the route-mapping subsystem (`$this->routeMappings`) described below was +> removed with it. `NavigationFilterIterator` now calls the inherited Laminas +> `isAllowed()` with the route name as the resource. The text below is retained as the +> record of why the method was introduced. `AclInterface::isAllowedRoute()` requires a `ServerRequestInterface` because it reads `RouteResult` from the request. The view helper and filter iterator have no request diff --git a/docs/component-reference.md b/docs/component-reference.md index a78748b..fadbd84 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -64,7 +64,8 @@ public function __construct( Called internally by SPL for each item in the inner iterator. Returns `true` when: 1. `options['navigation']` equals `$navId` (string) or contains it (array). -2. `AclInterface::isAllowedByRouteName($route->getName(), $roles)` returns `true`. +2. The ACL allows the route name for the current user — `isAllowed()` with + `$route->getName()` as the resource — returns `true`. Both conditions must be satisfied. If either fails the route is excluded. @@ -241,16 +242,21 @@ Webware\Navigation\ConfigProvider::class, --- -## AclInterface additions +## The ACL check -Two methods were added to `Webware\Acl\AclInterface` to support request-free ACL -checks from the filter iterator: +The iterator asks the ACL directly, passing the route name as the resource: -### `isAllowedByRouteName(string $routeName, $roles): bool` +```php +$this->acl->isAllowed(role: $this->user, resource: $route->getName()); +``` + +`Webware\Core\AclInterface` extends `Laminas\Permissions\Acl\AclInterface`, so +`isAllowed()` is the inherited Laminas method, and `Acl::load()` registers resources +under their route names — which is why the route name is what gets passed. -Looks up `$routeName` in the internal route-to-resource/privilege mapping. Returns -`true` (visible) when the route has no ACL mapping — unmapped routes are not protected. -Returns `false` when the mapping exists and no role in `$roles` is permitted. +The interface itself adds `isAllowedRoute(?UserInterface $user, ResourceInterface +$resource)`, a thin wrapper over the same call for callers that already hold a +`ResourceInterface`. -This is distinct from `isAllowedRoute(ServerRequestInterface, $roles)` which reads the -mapping from the live `RouteResult` on the request. +`isAllowedByRouteName()` is not part of the API. It was removed because it duplicated +`isAllowedRoute()` in all but name. diff --git a/docs/extending.md b/docs/extending.md index 9a79cdc..9c18624 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -143,9 +143,9 @@ Then render each independently: ## Overriding the ACL check -`NavigationFilterIterator` calls `AclInterface::isAllowedByRouteName()`. To change +`NavigationFilterIterator` calls `AclInterface::isAllowed()`. To change ACL filtering behaviour, implement `AclInterface` and register your implementation -in the DI container under `AclInterface::class`. This affects all ACL consumers +in the DI container under `Webware\Core\AclInterface::class`. This affects all ACL consumers including `AuthorizationMiddleware`. To change filtering only for navigation without affecting the main ACL, wrap the diff --git a/docs/overview.md b/docs/overview.md index cafad60..30887bb 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -12,7 +12,7 @@ the result without any separate navigation configuration file. | Goal | How it is met | |---|---| | Single source of truth for nav membership | `Route::setOptions(['navigation' => 'admin', ...])` lives beside the route definition | -| ACL enforcement before render time | `NavigationFilterIterator` calls `AclInterface::isAllowedByRouteName()` per route | +| ACL enforcement before render time | `NavigationFilterIterator` calls `AclInterface::isAllowed()` per route | | Multiple representations from one filtered tree | `NavigationContainer` exposes `menu()`, `breadcrumbs()`, and `sitemap()` | | Replaceable rendering | Every render method delegates to an optional `RendererInterface` before falling back to inline markup | | Active-item highlighting | `NavigationMiddleware` injects the matched route name from `RouteResult` after routing completes | diff --git a/docs/testing.md b/docs/testing.md index b38d3fb..d1f8032 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -12,10 +12,9 @@ ## Unit testing the filter iterator -**Blocked until dependencies publish:** these tests type against -`Webware\Acl\AclInterface` and `Webware\UserManager\UserInterface` — neither -package is published yet (see `docs/webware-tools-alignment.md`, Research -section). Skip until then. +Both contracts — `Webware\Core\AclInterface` and `Webware\Core\UserInterface` — +ship in `webware/webware-core`, which this package requires, so these tests run like +any other. `NavigationFilterIterator` is the easiest component to test in isolation. Construct it with a stub `AclInterface` and an array of `Route` objects: @@ -23,7 +22,7 @@ it with a stub `AclInterface` and an array of `Route` objects: ```php use Mezzio\Router\Route; use PHPUnit\Framework\TestCase; -use Webware\Acl\AclInterface; +use Webware\Core\AclInterface; use Webware\Navigation\NavigationFilterIterator; final class NavigationFilterIteratorTest extends TestCase @@ -45,7 +44,7 @@ final class NavigationFilterIteratorTest extends TestCase { $route = $this->makeRoute('/secret', 'admin.secret', ['navigation' => 'admin']); $acl = $this->createStub(AclInterface::class); - $acl->method('isAllowedByRouteName')->willReturn(false); + $acl->method('isAllowed')->willReturn(false); $iterator = new NavigationFilterIterator([$route], 'admin', ['guest'], $acl); $results = iterator_to_array($iterator); @@ -75,7 +74,7 @@ final class NavigationFilterIteratorTest extends TestCase private function alwaysAllowAcl(): AclInterface { $acl = $this->createStub(AclInterface::class); - $acl->method('isAllowedByRouteName')->willReturn(true); + $acl->method('isAllowed')->willReturn(true); return $acl; } } diff --git a/docs/webware-tools-alignment.md b/docs/webware-tools-alignment.md index 75e8188..e8ac744 100644 --- a/docs/webware-tools-alignment.md +++ b/docs/webware-tools-alignment.md @@ -8,6 +8,14 @@ established by `webware/webware-tools` (reusable workflow webware-mailer alignment (`webware-mailer/docs/webware-tools-alignment.md`) — mailer is the aligned consumer and the template this repo adopts. +> **Names in this audit are point-in-time.** It records the state as it was during +> the alignment and is not maintained, so class and method names below reflect what +> the code looked like then. In particular `Webware\Acl\AclInterface` now lives in +> `webware/webware-core` as `Webware\Core\AclInterface`, and +> `Webware\UserManager\UserInterface` is provided by core as +> `Webware\Core\UserInterface`. For the current API see +> `docs/component-reference.md` and `docs/architecture.md`. + **Scope:** CI/CD pipeline, tooling configs, composer metadata, baseline files, and the minimum test scaffolding required for a green pipeline. From dab654335285077c19d6ac2623477a2a39aa6c06 Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Mon, 5 Oct 2026 01:03:44 -0500 Subject: [PATCH 2/3] docs: drop the historical framing, describe current behaviour architecture.md's Decision 8 documented a method that no longer exists, a routeMappings lookup that was removed with it, and an "unmapped routes are allowed" default that is the opposite of the current fail-closed check. It is replaced with a section describing what the iterator actually does. The point-in-time note on the alignment audit is reverted: that document is untouched in this branch and needs its own decision, not a caveat. Signed-off-by: Joey Smith --- docs/architecture.md | 47 +++++++++++++++------------------ docs/component-reference.md | 2 +- docs/webware-tools-alignment.md | 8 ------ 3 files changed, 23 insertions(+), 34 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 36b108a..0b35f2b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -209,38 +209,35 @@ Placing it before `RouteMiddleware` (as was briefly the case during development) --- -## Decision 8 — isAllowedByRouteName on AclInterface — SUPERSEDED +## Decision 8 — How the filter iterator checks the ACL -> **Superseded — do not implement from this section.** `isAllowedByRouteName()` was -> removed from the ACL contract because it duplicated `isAllowedRoute()` in all but -> name, and the route-mapping subsystem (`$this->routeMappings`) described below was -> removed with it. `NavigationFilterIterator` now calls the inherited Laminas -> `isAllowed()` with the route name as the resource. The text below is retained as the -> record of why the method was introduced. - -`AclInterface::isAllowedRoute()` requires a `ServerRequestInterface` because it reads -`RouteResult` from the request. The view helper and filter iterator have no request -object — they run inside a renderer context. - -Rather than inject the request into the helper (see Decision 6), a second method was -added to `AclInterface`: +`NavigationFilterIterator` runs inside a renderer context and inspects routes before +any of them is dispatched, so it has no request and cannot build the `RouteResource` +that `isAllowedRoute()` takes. It calls the ACL directly, passing the route name as +the resource: ```php -public function isAllowedByRouteName( - string $routeName, - array|RoleInterface|string|null $roles = null, -): bool; +$this->acl->isAllowed(role: $this->user, resource: $route->getName()); ``` -The implementation looks up the route name in `$this->routeMappings`. If no mapping -exists for the route name, it returns `true` — the route is not ACL-protected and is -visible to all authenticated users. +Resource ids are route names — `Acl::load()` registers every route from the route +collector — so a route name is a valid id and resolves to the same rule an +`isAllowedRoute()` call would find. There is no route-name variant on the interface: +one would duplicate `isAllowedRoute()` in all but name. -This is the correct default because routes without ACL mappings are intentionally -public-access routes. Denying them by default would hide unprotected routes from -navigation, which would be wrong. +Three properties of `Acl::isAllowed()` decide the result: ---- +1. **Fail-closed** — a resource id the ACL has not registered is denied before + Laminas sees it. +2. **Lazy load** — the first call materialises the rules from the `acl_rule` table + and registers the routes as resources. +3. **Ancestor rules apply** — a route with no rule of its own inherits from its + nearest registered ancestor, so rules on an anchor such as `user` cover the + routes beneath it. + +The result decides visibility, so the ACL rows and `Route::setOptions(['navigation' +=> ...])` have to agree: a nav entry whose route the ACL denies is filtered out +before render. ## Active-item detection algorithm diff --git a/docs/component-reference.md b/docs/component-reference.md index fadbd84..3038e70 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -258,5 +258,5 @@ The interface itself adds `isAllowedRoute(?UserInterface $user, ResourceInterfac $resource)`, a thin wrapper over the same call for callers that already hold a `ResourceInterface`. -`isAllowedByRouteName()` is not part of the API. It was removed because it duplicated +There is no route-name variant on the interface: one would duplicate `isAllowedRoute()` in all but name. diff --git a/docs/webware-tools-alignment.md b/docs/webware-tools-alignment.md index e8ac744..75e8188 100644 --- a/docs/webware-tools-alignment.md +++ b/docs/webware-tools-alignment.md @@ -8,14 +8,6 @@ established by `webware/webware-tools` (reusable workflow webware-mailer alignment (`webware-mailer/docs/webware-tools-alignment.md`) — mailer is the aligned consumer and the template this repo adopts. -> **Names in this audit are point-in-time.** It records the state as it was during -> the alignment and is not maintained, so class and method names below reflect what -> the code looked like then. In particular `Webware\Acl\AclInterface` now lives in -> `webware/webware-core` as `Webware\Core\AclInterface`, and -> `Webware\UserManager\UserInterface` is provided by core as -> `Webware\Core\UserInterface`. For the current API see -> `docs/component-reference.md` and `docs/architecture.md`. - **Scope:** CI/CD pipeline, tooling configs, composer metadata, baseline files, and the minimum test scaffolding required for a green pipeline. From 8fb3f564873b4a14c32d0b27c3bbbba1a5f514cc Mon Sep 17 00:00:00 2001 From: Joey Smith Date: Mon, 5 Oct 2026 01:20:28 -0500 Subject: [PATCH 3/3] docs: record the current state, verified against the source Every claim in these documents was checked against src/ and corrected where it did not match. Nothing is written as history, as intent, or as a plan. - NavigationFilterIterator takes (routes, navId, UserInterface, AclInterface). component-reference.md documented the third parameter as `array $roles`, which is what put ['guest'] into the testing examples. - NavigationMiddleware is Webware\Navigation\Http\Middleware in src/Http/Middleware/. It calls setUser(), setAcl() and setActiveRouteName(); setRoles() does not exist on the helper. - NavigationFactory passes null for all three renderers, so the inline fallbacks run. There is no RendererPluginManager in this package, and the sections describing one are removed rather than described as planned. - overview.md's package layout showed a monorepo path and the middleware in a namespace it no longer occupies. - README.md pointed at a document that no longer exists, and at the 0.1.x line for the mutation badge and the install constraint. Removes docs/webware-tools-alignment.md: an audit of an alignment exercise whose findings are resolved and whose paths predate the standalone repositories. Not touched: docblocks in src/ still name a planned RendererPluginManager (RendererInterface.php, Navigation.php, NavigationFactory.php, NavigationContainer.php). Signed-off-by: Joey Smith --- README.md | 10 +- docs/architecture.md | 43 ++-- docs/component-reference.md | 41 ++-- docs/extending.md | 38 +-- docs/overview.md | 32 +-- docs/testing.md | 19 +- docs/webware-tools-alignment.md | 421 -------------------------------- 7 files changed, 80 insertions(+), 524 deletions(-) delete mode 100644 docs/webware-tools-alignment.md diff --git a/README.md b/README.md index b23894f..e7dfbaf 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License](https://img.shields.io/github/license/webinertia/webware-navigation)](LICENSE) [![Continuous Integration](https://github.com/webinertia/webware-navigation/actions/workflows/required/webinertia/.github/.github/workflows/org-required-ci.yml/badge.svg)](https://github.com/webinertia/webware-navigation/actions/workflows/required/webinertia/.github/.github/workflows/org-required-ci.yml) [![codecov](https://codecov.io/gh/webinertia/webware-navigation/graph/badge.svg)](https://codecov.io/gh/webinertia/webware-navigation) -[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fwebinertia%2Fwebware-navigation%2F0.1.x)](https://dashboard.stryker-mutator.io/reports/github.com/webinertia/webware-navigation/0.1.x) +[![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fwebinertia%2Fwebware-navigation%2F1.0.x)](https://dashboard.stryker-mutator.io/reports/github.com/webinertia/webware-navigation/1.0.x) ACL-aware navigation trees for Mezzio, driven by `Route::setOptions()`. @@ -16,7 +16,7 @@ ACL-aware navigation trees for Mezzio, driven by `Route::setOptions()`. | [Overview](docs/overview.md) | Goals, quick start, route options reference | | [Architecture](docs/architecture.md) | Design decisions and rationale | | [Component Reference](docs/component-reference.md) | API docs for every class | -| [Extending](docs/extending.md) | Custom renderers, planned RendererPluginManager | +| [Extending](docs/extending.md) | Custom renderers, replacing the factory | | [Testing](docs/testing.md) | Unit test examples and guidance | ## Installation @@ -29,7 +29,7 @@ Private package — not published to Packagist. Consume via a VCS repository ent { "type": "vcs", "url": "https://github.com/webinertia/webware-navigation" } ], "require": { - "webware/webware-navigation": "0.1.x-dev" + "webware/webware-navigation": "1.0.x-dev" } } ``` @@ -39,7 +39,7 @@ pins the commit hash). Note: the package types against `Webware\Core\AclInterface` and `Webware\Core\UserInterface`, both provided by `webware/webware-core`, which this -package requires — see `docs/webware-tools-alignment.md`. +package requires. Add to `config/config.php`: @@ -69,6 +69,6 @@ cannot express safely. See [Architecture](docs/architecture.md), Decision 7. ## Namespace ``` -Package: webware/navigation +Package: webware/webware-navigation Namespace: Webware\Navigation\ ``` diff --git a/docs/architecture.md b/docs/architecture.md index 0b35f2b..1b69cfb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -138,9 +138,9 @@ NavigationContainer::menu() → else: inline Bootstrap markup ``` -The renderers are constructor parameters on `NavigationContainer`, injected by -`Navigation` helper which receives them from `NavigationFactory`. The factory currently -passes `null` for all three with a comment marking the future injection point. +The renderers are constructor parameters on `NavigationContainer`, injected by the +`Navigation` helper, which receives them from `NavigationFactory`. The factory passes +`null` for all three, so the inline fallbacks run. **Why plan for renderers now rather than deferring entirely:** - The constructor signature is the public API surface. Adding renderer parameters @@ -148,32 +148,20 @@ passes `null` for all three with a comment marking the future injection point. - The `null` default means zero cost until renderers exist. - The `RendererInterface` contract is documented so implementors know what to build. -**Planned renderer injection via PluginManager:** -```php -// Future NavigationFactory -$renderers = $container->get(RendererPluginManager::class); -return new Navigation( - routeCollector: $container->get(RouteCollectorInterface::class), - acl: $container->get(AclInterface::class), - menuRenderer: $renderers->get(MenuRenderer::class), - breadcrumbRenderer: $renderers->get(BreadcrumbRenderer::class), - sitemapRenderer: $renderers->get(SitemapRenderer::class), -); -``` - -Custom renderers will be registered via service-manager config, allowing per-application -override of any representation without forking the package. +A custom renderer is supplied by replacing `NavigationFactory` in the consumer's +service-manager configuration and passing it to the shown parameter. See +[Extending](extending.md). --- -## Decision 6 — StatefulHelperInterface for role injection +## Decision 6 — StatefulHelperInterface for per-request state The `Navigation` view helper implements `Laminas\View\Helper\StatefulHelperInterface`. This pattern is used throughout the Laminas/Mezzio ecosystem (e.g. `UrlHelper`) for helpers that need per-request state injected after construction but before invocation. -`NavigationMiddleware` calls `$helper->setRoles()` and `$helper->setActiveRouteName()` -on the **same shared instance** the DI container holds. The helper is a long-lived +`NavigationMiddleware` calls `$helper->setUser()`, `$helper->setAcl()` and +`$helper->setActiveRouteName()` on the **same shared instance** the DI container holds. The helper is a long-lived service; `resetState()` is called by Laminas' `HelperPluginManager` between requests (in long-lived runtimes) to prevent state leakage. @@ -250,9 +238,9 @@ admin.users ← active (because descendant is active) admin.users.edit ← active (matched route) ``` -`NavigationContainer::isActive(NavigationItem)` walks the tree depth-first. -`NavigationContainer::findTrail(NavigationItem)` does the same for breadcrumbs, -returning the ancestor chain from the root to the active item. +`NavigationContainer::isActive(NavigationItem)` walks the tree depth-first. The private +`findTrail()` helper does the same for breadcrumbs, returning the ancestor chain from the +root to the active item. --- @@ -263,7 +251,8 @@ Request │ ▼ NavigationMiddleware - setRoles([...]) + setUser($user) + setAcl($acl) — only when the attribute is already set setActiveRouteName('admin.users.edit') │ ▼ @@ -272,10 +261,10 @@ Template: $this->navigation('admin') ▼ Navigation::__invoke('admin') │ - ├── NavigationFilterIterator(routes, 'admin', $roles, $acl) + ├── NavigationFilterIterator(routes, 'admin', $user, $acl) │ forEach route: │ belongsToNav(options, 'admin') ? - │ isAllowedByRouteName(routeName, $roles) ? + │ isAllowed(role: $user, resource: routeName) ? │ ├── Build NavigationItem objects from filtered routes ├── Wire parent→child relationships diff --git a/docs/component-reference.md b/docs/component-reference.md index 3038e70..7d9e58c 100644 --- a/docs/component-reference.md +++ b/docs/component-reference.md @@ -46,7 +46,7 @@ name, `icon` = `''`, `parent` = `null`, `order` = `0`. **Extends:** `FilterIterator` SPL filter iterator that accepts only routes belonging to a given nav identifier that -the current user's roles are permitted to access. +the current user is permitted to access. ### Constructor @@ -54,7 +54,7 @@ the current user's roles are permitted to access. public function __construct( array $routes, // list from RouteCollectorInterface::getRoutes() string $navId, // navigation identifier to filter for - array $roles, // string[] current user's roles + UserInterface $user, // current user object AclInterface $acl, ) ``` @@ -114,7 +114,7 @@ Inline fallback options: | Key | Default | Description | |---|---|---| -| `type` | `'sidebar'` | `'sidebar'` → `nav flex-column`; `'horizontal'` → `navbar-nav` | +| `type` | `'sidebar'` | `'sidebar'` → `nav flex-column gap-1`; `'horizontal'` → `navbar-nav` | Active items receive the `active` CSS class. If an item has children a nested `