diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8496010 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +# file generated with AI assistance: Claude Code - 2026-10-28 21:10:00 UTC +name: Tests + +on: + push: + branches: [main, master, "feature/**"] + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ["8.5"] + name: PHPUnit (PHP ${{ matrix.php }}) + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pdo_sqlite, zip + coverage: none + - run: composer install --no-interaction --no-progress + - run: vendor/bin/phpunit diff --git a/CHANGELOG.md b/CHANGELOG.md index 04f766c..c814d24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,26 @@ All notable changes to this bundle. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/) and come from the Git tags. Releases before 0.9.0 are described in the [GitHub releases](https://github.com/dmstr/flowable-bundle/releases). +## [0.11.0] - Unreleased + +The bundle no longer checks role names: every HTTP operation and every MCP tool checks its own permission. + +### Breaking changes + +- **Every operation and every MCP tool needs a permission voter in the application.** Each of the 13 permissions (`flowable:read`, `flowable:read_stacktrace`, `flowable:mcp_read`, `flowable:deploy`, `flowable:deploy_delete`, `flowable:decision_evaluate`, `flowable:process_start`, `flowable:process_delete`, `flowable:task_complete`, `flowable:event_send`, `flowable:execution_trigger`, `flowable:worker`, `flowable:job_execute`) is checked with `is_granted()` and subject `null`. Without a voter that maps them, everything answers `403`, so provide the mapping before upgrading. Before: the read operations required the base user role, all write operations and all 14 MCP tools required `ROLE_FLOWABLE_ADMIN`; the job stacktrace declared the same role but did not enforce it (see Security). To reproduce that, map the former user role to `flowable:read` and the former administrator role to all 13 permissions (the map has no hierarchy, so the administrator needs `flowable:read` explicitly). The README has a migration section with an example voter. +- **Requires PHP >= 8.5.** +- **Requires `dmstr/api-configuration-bundle` `^0.6`** (and `doctrine/orm` `^3`). Applications on api-configuration-bundle 0.5 stay on 0.10.0. +- **Requires `symfony/expression-language` and `symfony/object-mapper`** (`^7.0`). The first evaluates the operations' `security` expressions; the second because API Platform enables its MCP integration only when it is installed. + +### Added + +- `Dmstr\Flowable\Security\FlowablePermissions` with the 13 permission constants and `all()`. They are registered as 13 `za7_core.permission` container tags (tag attribute `permission`) in `config/services.yaml`, so applications that collect such tags can offer them for assignment. The bundle has no permission map, no config node and no za7-core dependency. The MCP switches `dmstr_flowable.mcp.read` / `mcp.write` / `mcp.deploy` are unchanged and independent: a tool needs its switch and its permission. +- Kernel-based HTTP and MCP test suite (`tests/Kernel`, `tests/Http`) with its own test authentication (`X-Test-Roles` header, only under `tests/`), a GitHub Action (`.github/workflows/tests.yml`, PHP 8.5) and a `docker-compose.yml` CLI runner (`docker compose run --rm php`). Partially addresses Issue #14 (bundle test suite). + +### Security + +- `GET /jobs/{id}/stacktrace`, `GET /tasks/{id}/input_schema` and `GET /process_definitions/{id}/input_schema` did not evaluate their `security` expression: API Platform skips it for a custom controller with `read: false`, so every request that passed the firewall got them, with or without the declared role (the expression only filtered the hydra:operation list). The three controllers now check `flowable:read_stacktrace` and `flowable:read` themselves before calling the engine: `403` without the permission, `401` without authentication. This was already the case in 0.10.0 and earlier. + ## [0.10.0] - 2026-10-21 ### Breaking changes diff --git a/CLAUDE.md b/CLAUDE.md index 917d3ac..00f3474 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,11 @@ packages are public and English-only. ## Requirements -PHP >= 8.4 (see `composer.json`). There is a PHPUnit 12 suite in `tests/` (configuration `phpunit.dist.xml`) that needs no Flowable engine, database or kernel: the client is a `FlowableClientInterface` double, and the MCP tool metadata is built from the resource attributes. Run it with `composer install && vendor/bin/phpunit`, or in any PHP 8.4 image with the repository mounted, e.g. `docker run --rm -v "$PWD:/app" -w /app vendor/bin/phpunit`. There is no CI in this repository yet; the bundle is also exercised through its consuming applications (e.g. the za7 API stacks). +PHP >= 8.5 (see `composer.json`). The PHPUnit 12 suite in `tests/` (configuration `phpunit.dist.xml`) needs no Flowable engine or database. It has unit tests (the client is a `FlowableClientInterface` double, MCP tool metadata is built from the resource attributes) and kernel-based suites under `tests/Kernel` and `tests/Http` that boot a test kernel and send HTTP and MCP requests; their test authentication (`X-Test-Roles` header) lives only under `tests/`. Run it with the compose CLI runner: `docker compose run --rm php composer install && docker compose run --rm php vendor/bin/phpunit`. CI is the GitHub Action `.github/workflows/tests.yml` (PHP 8.5); the bundle is also exercised through its consuming applications (e.g. the za7 API stacks). + +## Permissions + +The bundle checks only `flowable:*` permissions (`Dmstr\Flowable\Security\FlowablePermissions`, registered as `za7_core.permission` tags in `config/services.yaml`) with `is_granted('')` and subject `null`. No `ROLE_*` name appears in `src/`, `config/` or the docs: the application maps permissions to roles through its own voter. A new operation or tool gets a permission constant, a tag and a test, and the README permission table is updated in the same change. + diff --git a/README.md b/README.md index 1334e6c..a711112 100644 --- a/README.md +++ b/README.md @@ -14,9 +14,11 @@ credentials) are resolved per request from an `ApiConfiguration` of type ## Requirements -- PHP >= 8.4 +- PHP >= 8.5 - Symfony 7 - API Platform 4, at least 4.3.18 (earlier releases do not enforce `security` on MCP tools) +- `dmstr/api-configuration-bundle` `^0.6` (and `doctrine/orm` `^3`) +- A permission voter in the application that maps the `flowable:*` [permissions](#security) to its roles - A reachable Flowable REST engine (`flowable-rest`) - `ext-pcntl` — only for `flowable:external-worker:run` as a long-lived process, where it enables the graceful shutdown described under @@ -259,33 +261,33 @@ The bundle declares 14 [MCP](https://modelcontextprotocol.io/) tools as API Plat - `symfony/mcp-bundle` serves the tools; - `mcp/sdk` is the MCP server runtime it runs on. -Nothing is exposed until the [switches](#bundle-configuration) `dmstr_flowable.mcp.read` / `mcp.write` / `mcp.deploy` are turned on. Every tool requires `ROLE_FLOWABLE_ADMIN`, the read tools included: unlike the HTTP read operations, they are not open to `ROLE_USER`, because tool results go to a language model and the engine has no per-user access check on tasks, variables and history. +Nothing is exposed until the [switches](#bundle-configuration) `dmstr_flowable.mcp.read` / `mcp.write` / `mcp.deploy` are turned on. A tool needs its switch and its permission: the switch decides whether the tool exists, the permission whether the caller may use it (see [Security](#security)). The read tools have their own permission, `flowable:mcp_read`, separate from `flowable:read` of the HTTP read operations, because tool results go to a language model and the engine has no per-user access check on tasks, variables and history. > **Warning:** tool results contain engine data that users or external systems wrote (variables, task descriptions, exception messages). Treat it as untrusted input to the model, and do not give an agent that reads such data the write or deploy tools without a human confirming each call. -| Tool | Switch | Security | Tag (`_meta` `de.dmstr/tag`) | Backing operation | +| Tool | Switch | Permission | Tag (`_meta` `de.dmstr/tag`) | Backing operation | |---|---|---|---|---| -| `flowable_list_process_definitions` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable` | `GET /process_definitions` (`FlowProcessDefinitionProvider`) | -| `flowable_list_tasks` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable` | `GET /tasks` (`FlowTaskProvider`) | -| `flowable_get_task_form` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable` | like `GET /tasks/{id}/input_schema` (`FlowTaskFormProvider`) | -| `flowable_get_process_status` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable` | composite: process instance, open tasks, executions (`FlowProcessStatusProvider`) | -| `flowable_history_get` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable/History` | composite: historic activities, historic variables, failed decision executions (`FlowProcessHistoryProvider`) | -| `flowable_system_list_deadletter_jobs` | read | `ROLE_FLOWABLE_ADMIN` | `Flowable/System` | `GET /jobs?kind=deadletter`, kind fixed (`FlowJobProvider`) | -| `flowable_start_process` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable` | `POST /process_instances`, by key or id (`ProcessInstanceCreateProcessor`) | -| `flowable_complete_task` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable` | `POST /tasks/{id}/complete` (`TaskCompleteProcessor`) | -| `flowable_dmn_evaluate` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable/DMN` | `POST /decisions/execute` (`DecisionExecuteProcessor`) | -| `flowable_events_send` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable/Events` | `POST /event_instances` (`EventInstanceCreateProcessor`) | -| `flowable_system_trigger_execution` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable/System` | `POST /executions/{id}/trigger` (`ExecutionTriggerProcessor`) | -| `flowable_dmn_deploy` | write + deploy | `ROLE_FLOWABLE_ADMIN` | `Flowable/DMN` | `POST /dmn_deployments/upload` with an inline file (`DmnDeploymentUploadProcessor`) | -| `flowable_deploy_bundle` | write + deploy | `ROLE_FLOWABLE_ADMIN` | `Flowable` | several inline files as one `.bar` deployment (`DeploymentBundleProcessor`) | -| `flowable_system_execute_timer_job` | write | `ROLE_FLOWABLE_ADMIN` | `Flowable/System` | no HTTP operation; `FlowableClientInterface::executeTimerJob()` (`TimerJobExecuteProcessor`) | - -Every tool carries `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`), its `security` expression and the `de.dmstr/tag` entry in `_meta`. Every tool that changes engine state in a way that cannot be undone (start, complete, trigger, send an event, deploy, execute a timer job) declares `destructiveHint: true`, so MCP clients ask before calling it; only `flowable_dmn_evaluate`, which adds no more than a history row, is a write tool with `destructiveHint: false`. Each description is one line that names the fields of the result, because some agent frameworks ignore `structuredContent`; the result is the serialised resource (or result object) in API Platform's MCP output format. +| `flowable_list_process_definitions` | read | `flowable:mcp_read` | `Flowable` | `GET /process_definitions` (`FlowProcessDefinitionProvider`) | +| `flowable_list_tasks` | read | `flowable:mcp_read` | `Flowable` | `GET /tasks` (`FlowTaskProvider`) | +| `flowable_get_task_form` | read | `flowable:mcp_read` | `Flowable` | like `GET /tasks/{id}/input_schema` (`FlowTaskFormProvider`) | +| `flowable_get_process_status` | read | `flowable:mcp_read` | `Flowable` | composite: process instance, open tasks, executions (`FlowProcessStatusProvider`) | +| `flowable_history_get` | read | `flowable:mcp_read` | `Flowable/History` | composite: historic activities, historic variables, failed decision executions (`FlowProcessHistoryProvider`) | +| `flowable_system_list_deadletter_jobs` | read | `flowable:mcp_read` | `Flowable/System` | `GET /jobs?kind=deadletter`, kind fixed (`FlowJobProvider`) | +| `flowable_start_process` | write | `flowable:process_start` | `Flowable` | `POST /process_instances`, by key or id (`ProcessInstanceCreateProcessor`) | +| `flowable_complete_task` | write | `flowable:task_complete` | `Flowable` | `POST /tasks/{id}/complete` (`TaskCompleteProcessor`) | +| `flowable_dmn_evaluate` | write | `flowable:decision_evaluate` | `Flowable/DMN` | `POST /decisions/execute` (`DecisionExecuteProcessor`) | +| `flowable_events_send` | write | `flowable:event_send` | `Flowable/Events` | `POST /event_instances` (`EventInstanceCreateProcessor`) | +| `flowable_system_trigger_execution` | write | `flowable:execution_trigger` | `Flowable/System` | `POST /executions/{id}/trigger` (`ExecutionTriggerProcessor`) | +| `flowable_dmn_deploy` | write + deploy | `flowable:deploy` | `Flowable/DMN` | `POST /dmn_deployments/upload` with an inline file (`DmnDeploymentUploadProcessor`) | +| `flowable_deploy_bundle` | write + deploy | `flowable:deploy` | `Flowable` | several inline files as one `.bar` deployment (`DeploymentBundleProcessor`) | +| `flowable_system_execute_timer_job` | write | `flowable:job_execute` | `Flowable/System` | no HTTP operation; `FlowableClientInterface::executeTimerJob()` (`TimerJobExecuteProcessor`) | + +Every tool carries `annotations` (`readOnlyHint`, `destructiveHint`, `idempotentHint`), its `security` expression (`is_granted()` with the permission) and the `de.dmstr/tag` entry in `_meta`. Every tool that changes engine state in a way that cannot be undone (start, complete, trigger, send an event, deploy, execute a timer job) declares `destructiveHint: true`, so MCP clients ask before calling it; only `flowable_dmn_evaluate`, which adds no more than a history row, is a write tool with `destructiveHint: false`. Each description is one line that names the fields of the result, because some agent frameworks ignore `structuredContent`; the result is the serialised resource (or result object) in API Platform's MCP output format. Notes on individual tools: - `flowable_start_process` starts by `processDefinitionKey` (latest version) or `processDefinitionId`, so an agent needs no prior lookup of the definition id; it is backed by `POST /process_instances`, not by `POST /process_definitions/{id}/start`. -- `flowable_dmn_evaluate` changes no engine state except a history row, but it is gated behind `ROLE_FLOWABLE_ADMIN` like its HTTP operation, so it is declared a write tool (`readOnlyHint: false`, `idempotentHint: true`) and appears only with `mcp.write`. +- `flowable_dmn_evaluate` changes no engine state except a history row, but it is gated behind its own permission, `flowable:decision_evaluate`, like its HTTP operation, so it is declared a write tool (`readOnlyHint: false`, `idempotentHint: true`) and appears only with `mcp.write`. - `flowable_get_process_status` answers only for running instances; `flowable_history_get` covers ended ones. Both are composites: when one of their engine calls fails, the tool fails as a whole and never returns a partial result. Each list holds at most 200 rows; the `*Total` fields tell whether it was cut. - `flowable_system_execute_timer_job` moves the timer job to the executable jobs (`POST management/timer-jobs/{id}` with `{"action": "move"}`), where the async executor runs it at once. The remaining wait cannot be restored, hence `destructiveHint: true`. - `flowable_deploy_bundle` refuses `.dmn` files: a process deployment does not register decision tables in the DMN engine, so they go to `flowable_dmn_deploy`. Building the archive needs the PHP `zip` extension. @@ -357,11 +359,61 @@ every registered handler's topics), `--worker-id`, `--lock-duration=PT10M`, ## Security -Reading is open to any authenticated user. Starting, triggering and completing -(all write operations) require `ROLE_FLOWABLE_ADMIN`. The MCP tools all require -`ROLE_FLOWABLE_ADMIN`, see [MCP tools](#mcp-tools). The acting user is taken -from the authenticated identity (JWT `sub`) and propagated to Flowable — see -[`docs/tenant-and-user.md`](docs/tenant-and-user.md). +The bundle checks no role names. Every HTTP operation and every one of the 14 MCP tools checks its own permission attribute with `is_granted('')` and subject `null`. The bundle grants none of the permissions itself: a permission voter in the application maps its roles or users to them. Without such a voter every operation and every tool answers `403` (fail closed), so provide the mapping before you install or upgrade. + +| Permission | Guards | +|---|---| +| `flowable:read` | all read operations of the engine resources, the two input-schema operations (`/process_definitions/{id}/input_schema`, `/tasks/{id}/input_schema`) and `GET /jobs` with any `kind` (the dead-letter list is `GET /jobs?kind=deadletter`) | +| `flowable:read_stacktrace` | `GET /jobs/{id}/stacktrace` | +| `flowable:mcp_read` | the 6 read MCP tools, including `flowable_system_list_deadletter_jobs` | +| `flowable:deploy` | the three upload operations (`/deployments/upload`, `/dmn_deployments/upload`, `/event_deployments/upload`) and the tools `flowable_deploy_bundle` and `flowable_dmn_deploy` | +| `flowable:deploy_delete` | the three deployment delete operations (`DELETE /deployments/{id}`, `/dmn_deployments/{id}`, `/event_deployments/{id}`) | +| `flowable:decision_evaluate` | `POST /decisions/execute` and `flowable_dmn_evaluate` | +| `flowable:process_start` | `POST /process_definitions/{id}/start`, `POST /process_instances` and `flowable_start_process` | +| `flowable:process_delete` | `DELETE /process_instances/{id}` | +| `flowable:task_complete` | `POST /tasks/{id}/complete` and `flowable_complete_task` | +| `flowable:event_send` | `POST /event_instances` and `flowable_events_send` | +| `flowable:execution_trigger` | `POST /executions/{id}/trigger` and `flowable_system_trigger_execution` | +| `flowable:worker` | the five External Worker operations (`acquire`, `complete`, `fail`, `bpmnError`, `unacquire` under `/external_worker_jobs`) | +| `flowable:job_execute` | `flowable_system_execute_timer_job` | + +The constants are in `Dmstr\Flowable\Security\FlowablePermissions` (`all()` returns the 13 values). The bundle registers them as 13 `za7_core.permission` service tags (tag attribute `permission`), so applications that collect such tags, such as `hrzg/za7-core`, can offer them for assignment; the bundle itself depends on nothing for this and has no permission map or config node of its own. + +The MCP switches `dmstr_flowable.mcp.read` / `mcp.write` / `mcp.deploy` are independent of the permissions: a tool needs its switch and its permission. + +The acting user is taken from the authenticated identity (JWT `sub`) and propagated to Flowable, see [`docs/tenant-and-user.md`](docs/tenant-and-user.md). + +### Migrating from 0.10 + +0.10 checked fixed roles: the read operations required the application's base user role, all write operations and all 14 MCP tools required its administrator role. The job stacktrace and the two input-schema operations declared a role but did not enforce it, so any request that passed the firewall got them. 0.11 checks the permissions above, so an application without a voter for them loses all access after the upgrade. The old behaviour is reproduced by mapping the former user role to `flowable:read` and the former administrator role to all 13 permissions. The map has no hierarchy, so the administrator needs `flowable:read` explicitly. A minimal voter, with `` and `` replaced by the roles your application uses: + +```php +use Dmstr\Flowable\Security\FlowablePermissions; +use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\Voter\Vote; +use Symfony\Component\Security\Core\Authorization\Voter\Voter; + +final class FlowablePermissionVoter extends Voter +{ + protected function supports(string $attribute, mixed $subject): bool + { + return in_array($attribute, FlowablePermissions::all(), true); + } + + protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool + { + $roles = $token->getRoleNames(); + + if (in_array('', $roles, true)) { + return true; + } + + return $attribute === 'flowable:read' && in_array('', $roles, true); + } +} +``` + +With autoconfiguration the voter is registered by itself. If the application already has a permission voter driven by a role-to-permission map, add the permissions to the entries of your roles instead. Also note the other breaking changes of 0.11: PHP >= 8.5 and `dmstr/api-configuration-bundle` `^0.6`, see the [changelog](CHANGELOG.md). ## Documentation @@ -376,16 +428,19 @@ from the authenticated identity (JWT `sub`) and propagated to Flowable — see ## Tests -The PHPUnit suite (`tests/`, configuration `phpunit.dist.xml`) runs without an engine, a database or a kernel: the Flowable client is a test double, and the MCP tool metadata is read from the resource attributes with API Platform's attribute metadata factory. +The suite (`tests/`, configuration `phpunit.dist.xml`) needs no Flowable engine and no database: the Flowable client is a test double. Besides the unit tests it has kernel-based suites under `tests/Kernel` and `tests/Http` that boot a test kernel and send HTTP and MCP requests; they authenticate with a test-only firewall that reads the roles from the `X-Test-Roles` header (it exists only under `tests/`). CI runs the suite on PHP 8.5 (`.github/workflows/tests.yml`). ```bash -composer install -vendor/bin/phpunit +docker compose run --rm php composer install +docker compose run --rm php vendor/bin/phpunit ``` +Without Docker, `composer install && vendor/bin/phpunit` works on any PHP 8.5 setup. + ## License [MIT](LICENSE) © diemeisterei GmbH + diff --git a/composer.json b/composer.json index 447c15b..70ca4b2 100644 --- a/composer.json +++ b/composer.json @@ -18,27 +18,37 @@ } ], "require": { - "php": ">=8.4", + "php": ">=8.5", "api-platform/core": "^4.3.18", - "dmstr/api-configuration-bundle": "^0.5", + "dmstr/api-configuration-bundle": "^0.6", "dmstr/api-platform-utils-bundle": "^0.6.0", "dmstr/openapi-json-schema-bundle": "^0.3.0 || ^0.4.0", - "doctrine/orm": "^2.0|^3.0", + "doctrine/orm": "^3.0", "opis/json-schema": "^2.0", "psr/log": "^3.0", "symfony/config": "^7.0", "symfony/console": "^7.0", "symfony/dependency-injection": "^7.0", + "symfony/expression-language": "^7.0", "symfony/framework-bundle": "^7.0", "symfony/http-client": "^7.0", "symfony/http-foundation": "^7.0", "symfony/http-kernel": "^7.0", + "symfony/object-mapper": "^7.0", "symfony/security-bundle": "^7.0", "symfony/serializer": "^7.0", "symfony/yaml": "^7.0" }, "require-dev": { - "phpunit/phpunit": "^12.0" + "ext-pdo_sqlite": "*", + "ext-zip": "*", + "doctrine/doctrine-bundle": "^2.8", + "doctrine/doctrine-migrations-bundle": "^3.3", + "mcp/sdk": "^0.8.1", + "nyholm/psr7": "^1.8", + "phpunit/phpunit": "^12.0", + "symfony/browser-kit": "^7.0", + "symfony/mcp-bundle": "^0.14" }, "suggest": { "mcp/sdk": "Required by symfony/mcp-bundle; provides the MCP server runtime that API Platform's McpTool handler runs on.", diff --git a/config/services.yaml b/config/services.yaml index c203c85..e16e2e1 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -29,6 +29,27 @@ services: - '../src/Worker/ExternalWorkerJob.php' - '../src/Worker/ExternalWorkerOutcome.php' + # Permission attributes of the bundle's operations and MCP tools. An + # application (e.g. za7-core) collects the `za7_core.permission` tags to + # offer them for assignment; the bundle itself depends on nothing for it. + # Replaces the definition discovered above (a constants class, never + # instantiated). + Dmstr\Flowable\Security\FlowablePermissions: + tags: + - { name: 'za7_core.permission', permission: 'flowable:read' } + - { name: 'za7_core.permission', permission: 'flowable:read_stacktrace' } + - { name: 'za7_core.permission', permission: 'flowable:mcp_read' } + - { name: 'za7_core.permission', permission: 'flowable:deploy' } + - { name: 'za7_core.permission', permission: 'flowable:deploy_delete' } + - { name: 'za7_core.permission', permission: 'flowable:decision_evaluate' } + - { name: 'za7_core.permission', permission: 'flowable:process_start' } + - { name: 'za7_core.permission', permission: 'flowable:process_delete' } + - { name: 'za7_core.permission', permission: 'flowable:task_complete' } + - { name: 'za7_core.permission', permission: 'flowable:event_send' } + - { name: 'za7_core.permission', permission: 'flowable:execution_trigger' } + - { name: 'za7_core.permission', permission: 'flowable:worker' } + - { name: 'za7_core.permission', permission: 'flowable:job_execute' } + # The locator decrypts the writeOnly secrets (password, token) of a # "flowable" ApiConfiguration before it builds a client. ConfigSecrets is a # service of dmstr/api-configuration-bundle (^0.5); wired explicitly so the @@ -130,3 +151,4 @@ services: $decorated: '@.inner' # - revised 2026-10-08 (MCP tool input schema factory) # - revised 2026-10-09 (mcp.deploy switch) +# - revised 2026-10-28 (flowable:* permission tags) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2c7793 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +# file generated with AI assistance: Claude Code - 2026-10-28 21:10:00 UTC +# CLI-only test runner for this bundle: no database or web server containers, +# the tests use SQLite in memory. +# Usage (on host): +# docker compose run --rm php +# Runs `composer install` then PHPUnit against tests/. +services: + php: + build: + dockerfile_inline: | + FROM php:8.5-cli-alpine + COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/ + RUN install-php-extensions pdo_sqlite zip + COPY --from=composer:2 /usr/bin/composer /usr/bin/composer + working_dir: /repo + volumes: + - .:/repo + command: sh -c "composer install --no-interaction --no-progress && vendor/bin/phpunit" diff --git a/docs/walkthrough-vacation-request.md b/docs/walkthrough-vacation-request.md index f16ebbf..191d35d 100644 --- a/docs/walkthrough-vacation-request.md +++ b/docs/walkthrough-vacation-request.md @@ -46,15 +46,16 @@ flowchart TD > **Local-dev defaults** (docker compose): admin UI at `http://localhost:4401`, > Keycloak at `http://localhost:14405` (realm `demo`), Flowable REST at > `http://localhost:14408`. Sign-in user `za7-admin` / `test123` (realm role -> `admin`, which inherits `ROLE_FLOWABLE_ADMIN`). Adjust to your environment. +> `admin`, which the application's permission voter maps to the `flowable:*` permissions). Adjust to your environment. ## Steps ### 1. Sign in Open the admin UI and click **Anmelden mit IDP** / **Sign in**. Authenticate via -Keycloak. Starting and completing tasks requires `ROLE_FLOWABLE_ADMIN` -(`ROLE_ADMIN` inherits it); reading is open to any authenticated user. +Keycloak. Starting and completing tasks requires the permissions `flowable:process_start` and +`flowable:task_complete`, reading requires `flowable:read`; your application's permission +voter maps them to roles (see the Security section of the README). ### 2. Open the process definition diff --git a/phpunit.dist.xml b/phpunit.dist.xml index bfcbf8f..11291ce 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -17,6 +17,9 @@ tests + + + src diff --git a/src/ApiResource/FlowChannelDefinition.php b/src/ApiResource/FlowChannelDefinition.php index 03d0bdd..cf15847 100644 --- a/src/ApiResource/FlowChannelDefinition.php +++ b/src/ApiResource/FlowChannelDefinition.php @@ -27,7 +27,7 @@ * the engine. * * Deploying is done through FlowEventDeployment, so there are no write - * operations here. Read is open to ROLE_USER. + * operations here. Reading requires `flowable:read`. */ #[ApiResource( shortName: 'FlowChannelDefinition', @@ -65,7 +65,7 @@ provider: FlowChannelDefinitionProvider::class, ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_channel_definition:read']], diff --git a/src/ApiResource/FlowDecision.php b/src/ApiResource/FlowDecision.php index eced554..6b2572e 100644 --- a/src/ApiResource/FlowDecision.php +++ b/src/ApiResource/FlowDecision.php @@ -22,12 +22,12 @@ * Doctrine-less pass-through resource for Flowable DMN decisions (decision * tables deployed to the DMN engine, see FlowDmnDeployment). * - * Read is open to ROLE_USER. The custom collection operation + * Reading requires `flowable:read`. The custom collection operation * POST /decisions/execute evaluates a decision by key against the supplied * input variables and returns the matching rule outputs (design mirrors the * process-engine pass-through). Pass singleResult=true to use the engine's * single-result evaluation. Evaluation writes a historic execution and is - * therefore gated behind ROLE_FLOWABLE_ADMIN. + * therefore gated behind `flowable:decision_evaluate`. */ #[ApiResource( shortName: 'FlowDecision', @@ -60,7 +60,7 @@ validate: false, output: FlowDecision::class, status: 200, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:decision_evaluate')", openapi: new Operation(tags: ['Flowable/DMN']), ), ], @@ -70,13 +70,13 @@ description: 'Evaluate a deployed DMN decision by decisionKey with inputVariables, preferably as a map {name: value}; singleResult=true asks for one row. Returns the decision with id and key set to the decision key and result, a list of rows, each a map of output name to value (empty when no rule matched).', annotations: ['readOnlyHint' => false, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable/DMN'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:decision_evaluate')", processor: DecisionExecuteProcessor::class, read: false, extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowDecision/execute.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_decision:read']], diff --git a/src/ApiResource/FlowDeployment.php b/src/ApiResource/FlowDeployment.php index faf8cfe..4fe601e 100644 --- a/src/ApiResource/FlowDeployment.php +++ b/src/ApiResource/FlowDeployment.php @@ -30,8 +30,8 @@ * or more process definitions. The custom collection operation * POST /deployments/upload accepts a multipart file upload (a single .bpmn / * .dmn / .form / .json resource or a .bar / .zip bundle) and forwards it to the - * engine's repository service. Read is open to ROLE_USER; upload and delete - * require ROLE_FLOWABLE_ADMIN. + * engine's repository service. Reading requires `flowable:read`; upload + * requires `flowable:deploy`, delete requires `flowable:deploy_delete`. */ #[ApiResource( shortName: 'FlowDeployment', @@ -62,7 +62,7 @@ inputFormats: ['multipart' => ['multipart/form-data']], output: FlowDeployment::class, status: 201, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy')", openapi: new Operation( tags: ['Flowable'], requestBody: new RequestBody( @@ -113,7 +113,7 @@ // the default provider) and let the processor delete on the engine. // Same pattern as FlowProcessInstance::Delete. read: false, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy_delete')", ), ], mcp: [ @@ -122,13 +122,13 @@ description: 'Deploy several files as one process deployment, typically a .bpmn20.xml plus the .schema.json form schemas its user tasks reference (files: list of name, content and optional contentEncoding "base64"; decision tables go to flowable_dmn_deploy). Returns the deployment with id, name, deploymentTime, category, parentDeploymentId and tenantId.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy')", processor: DeploymentBundleProcessor::class, read: false, extraProperties: [McpToolSwitchResourceMetadataCollectionFactory::SWITCH_KEY => 'deploy', McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowDeployment/mcpBundle.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_deployment:read']], diff --git a/src/ApiResource/FlowDmnDeployment.php b/src/ApiResource/FlowDmnDeployment.php index 4b0e24d..8b11ad5 100644 --- a/src/ApiResource/FlowDmnDeployment.php +++ b/src/ApiResource/FlowDmnDeployment.php @@ -30,7 +30,8 @@ * as a decision, so decision tables must be deployed here. The custom collection * operation POST /dmn_deployments/upload accepts a multipart .dmn (or a * .bar/.zip bundle of them) and forwards it to the engine's dmn-repository. - * Read is open to ROLE_USER; upload and delete require ROLE_FLOWABLE_ADMIN. + * Reading requires `flowable:read`; upload requires `flowable:deploy`, delete requires + * `flowable:deploy_delete`. */ #[ApiResource( shortName: 'FlowDmnDeployment', @@ -61,7 +62,7 @@ inputFormats: ['multipart' => ['multipart/form-data']], output: FlowDmnDeployment::class, status: 201, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy')", openapi: new Operation( tags: ['Flowable/DMN'], requestBody: new RequestBody( @@ -107,7 +108,7 @@ // No Doctrine entity to load — skip the read step (which would 404 via // the default provider) and let the processor delete on the engine. read: false, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy_delete')", ), ], mcp: [ @@ -116,13 +117,13 @@ description: 'Deploy a DMN decision table to the DMN engine from inline content: name ending in .dmn and content as XML text (or base64 with contentEncoding "base64", e.g. for a .bar/.zip). Returns the DMN deployment with id, name, deploymentTime, category, parentDeploymentId and tenantId.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable/DMN'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy')", processor: DmnDeploymentUploadProcessor::class, read: false, extraProperties: [McpToolSwitchResourceMetadataCollectionFactory::SWITCH_KEY => 'deploy', McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowDmnDeployment/mcpDeploy.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_dmn_deployment:read']], diff --git a/src/ApiResource/FlowEventDefinition.php b/src/ApiResource/FlowEventDefinition.php index 350f0f6..86fabd3 100644 --- a/src/ApiResource/FlowEventDefinition.php +++ b/src/ApiResource/FlowEventDefinition.php @@ -25,7 +25,7 @@ * POST /event_instances (FlowEventInstance), which needs one of each. * * Deploying is done through FlowEventDeployment, so there are no write - * operations here. Read is open to ROLE_USER. + * operations here. Reading requires `flowable:read`. */ #[ApiResource( shortName: 'FlowEventDefinition', @@ -58,7 +58,7 @@ provider: FlowEventDefinitionProvider::class, ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_event_definition:read']], diff --git a/src/ApiResource/FlowEventDeployment.php b/src/ApiResource/FlowEventDeployment.php index 9c48137..9e35790 100644 --- a/src/ApiResource/FlowEventDeployment.php +++ b/src/ApiResource/FlowEventDeployment.php @@ -27,7 +27,8 @@ * "flowable" ApiConfiguration. It holds event definitions (.event) and channel * definitions (.channel); the custom collection operation * POST /event_deployments/upload forwards a multipart resource to it. - * Read is open to ROLE_USER; upload and delete require ROLE_FLOWABLE_ADMIN. + * Reading requires `flowable:read`; upload requires `flowable:deploy`, delete requires + * `flowable:deploy_delete`. * * Unlike the process and DMN repositories this one accepts ONLY .event and * .channel files — there is no .bar/.zip bundle support, so a deployment that @@ -68,7 +69,7 @@ inputFormats: ['multipart' => ['multipart/form-data']], output: FlowEventDeployment::class, status: 201, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy')", openapi: new Operation( tags: ['Flowable/Events'], requestBody: new RequestBody( @@ -114,10 +115,10 @@ // No Doctrine entity to load — skip the read step (which would 404 via // the default provider) and let the processor delete on the engine. read: false, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:deploy_delete')", ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_event_deployment:read']], diff --git a/src/ApiResource/FlowEventInstance.php b/src/ApiResource/FlowEventInstance.php index 6a07a3c..ba1fb94 100644 --- a/src/ApiResource/FlowEventInstance.php +++ b/src/ApiResource/FlowEventInstance.php @@ -29,7 +29,7 @@ * eventDefinitionId) AND a channel definition reference (channelDefinitionKey * or channelDefinitionId) — the channel is what turns the payload into a * correlated event, so the engine rejects a body without one. Sending events - * drives process state and therefore requires ROLE_FLOWABLE_ADMIN. + * drives process state and therefore requires `flowable:event_send`. */ #[ApiResource( shortName: 'FlowEventInstance', @@ -47,7 +47,7 @@ // the sent event, so there is nothing to serialise back. output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:event_send')", openapi: new Operation(tags: ['Flowable/Events']), ), ], @@ -57,14 +57,14 @@ description: 'Send an event to an inbound channel of the event registry; needs eventDefinitionKey or eventDefinitionId, channelDefinitionKey or channelDefinitionId, and eventPayload as an object. Returns null on success; failures are tool errors.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable/Events'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:event_send')", processor: EventInstanceCreateProcessor::class, read: false, structuredContent: false, extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowEventInstance/create.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", openapi: new Operation(tags: ['Flowable/Events']), )] final class FlowEventInstance diff --git a/src/ApiResource/FlowExecution.php b/src/ApiResource/FlowExecution.php index a67610d..cf42cbe 100644 --- a/src/ApiResource/FlowExecution.php +++ b/src/ApiResource/FlowExecution.php @@ -27,7 +27,7 @@ * child/leaf executions reference the flow elements where the process currently * waits (a receive task, an intermediate catch event, ...). Such a waiting * child execution — never the process-instance execution — is what `trigger` - * advances. Read is open to ROLE_USER; triggering requires ROLE_FLOWABLE_ADMIN. + * advances. Reading requires `flowable:read`; triggering requires `flowable:execution_trigger`. */ #[ApiResource( shortName: 'FlowExecution', @@ -58,7 +58,7 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:execution_trigger')", ), ], mcp: [ @@ -67,7 +67,7 @@ description: 'Trigger a waiting execution (e.g. a receive task) by execution id, taken from activeExecutions of flowable_get_process_status (not the process instance id), optionally with variables as a map {name: value}. Returns null on success; failures are tool errors.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable/System'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:execution_trigger')", processor: ExecutionTriggerProcessor::class, read: false, structuredContent: false, @@ -75,7 +75,7 @@ extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowExecution/trigger.input.json', 'uriVariables' => ['id' => 'Id of the waiting execution (not the process instance id).']]], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_execution:read']], diff --git a/src/ApiResource/FlowExternalWorkerJob.php b/src/ApiResource/FlowExternalWorkerJob.php index db484c9..07aae1d 100644 --- a/src/ApiResource/FlowExternalWorkerJob.php +++ b/src/ApiResource/FlowExternalWorkerJob.php @@ -33,8 +33,8 @@ * correct answer when the worker cannot do the work at all (no handler, or a * shutdown mid-flight). * - * Reading is open to ROLE_USER; every worker operation requires - * ROLE_FLOWABLE_ADMIN. Note that the actor of these operations is the *worker*, + * Reading requires `flowable:read`; every worker operation requires + * `flowable:worker`. Note that the actor of these operations is the *worker*, * not a za7 user, so no `triggeredBy` variable is injected — the worker id in * the request body is the identity that matters to the engine. * @@ -89,7 +89,7 @@ validate: false, output: FlowExternalWorkerJob::class, status: 200, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:worker')", ), new Post( uriTemplate: '/external_worker_jobs/{id}/complete', @@ -100,7 +100,7 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:worker')", ), new Post( uriTemplate: '/external_worker_jobs/{id}/fail', @@ -111,7 +111,7 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:worker')", ), new Post( uriTemplate: '/external_worker_jobs/{id}/bpmnError', @@ -122,7 +122,7 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:worker')", ), new Post( uriTemplate: '/external_worker_jobs/{id}/unacquire', @@ -133,10 +133,10 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:worker')", ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_external_worker_job:read']], diff --git a/src/ApiResource/FlowHistoricActivity.php b/src/ApiResource/FlowHistoricActivity.php index 34f63b5..e549374 100644 --- a/src/ApiResource/FlowHistoricActivity.php +++ b/src/ApiResource/FlowHistoricActivity.php @@ -37,7 +37,7 @@ ], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_historic_activity:read']], diff --git a/src/ApiResource/FlowHistoricDecisionExecution.php b/src/ApiResource/FlowHistoricDecisionExecution.php index d3de8a0..6708b44 100644 --- a/src/ApiResource/FlowHistoricDecisionExecution.php +++ b/src/ApiResource/FlowHistoricDecisionExecution.php @@ -41,7 +41,7 @@ provider: FlowHistoricDecisionExecutionProvider::class, ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_historic_decision_execution:read']], diff --git a/src/ApiResource/FlowHistoricProcessInstance.php b/src/ApiResource/FlowHistoricProcessInstance.php index 23882be..6a46f88 100644 --- a/src/ApiResource/FlowHistoricProcessInstance.php +++ b/src/ApiResource/FlowHistoricProcessInstance.php @@ -55,7 +55,7 @@ description: 'Get the history of a process instance, running or ended, in one call. Returns processInstanceId, activities (each with activityId, activityName, activityType, assignee, taskId, startTime, endTime, durationInMillis), activitiesTotal, variables (each with name, type, value, scope, taskId), variablesTotal, failedDecisionExecutions (each with id, decisionKey, decisionName, activityId, startTime, endTime) and failedDecisionExecutionsTotal; each list holds at most 200 rows.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable/History'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowProcessHistoryProvider::class, output: FlowProcessHistory::class, normalizationContext: ['groups' => ['flow_mcp:read']], @@ -63,7 +63,7 @@ extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowHistoricProcessInstance/mcpHistory.input.json', 'uriVariables' => ['id' => 'Id of the process instance.']]], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_historic_process_instance:read']], diff --git a/src/ApiResource/FlowHistoricTask.php b/src/ApiResource/FlowHistoricTask.php index 32e29ef..7315037 100644 --- a/src/ApiResource/FlowHistoricTask.php +++ b/src/ApiResource/FlowHistoricTask.php @@ -42,7 +42,7 @@ provider: FlowHistoricTaskProvider::class, ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_historic_task:read']], diff --git a/src/ApiResource/FlowHistoricVariable.php b/src/ApiResource/FlowHistoricVariable.php index 019bf94..ecfed1f 100644 --- a/src/ApiResource/FlowHistoricVariable.php +++ b/src/ApiResource/FlowHistoricVariable.php @@ -37,7 +37,7 @@ ], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_historic_variable:read']], diff --git a/src/ApiResource/FlowJob.php b/src/ApiResource/FlowJob.php index 39e119e..8e3e2e1 100644 --- a/src/ApiResource/FlowJob.php +++ b/src/ApiResource/FlowJob.php @@ -39,7 +39,7 @@ * Read-only on purpose: moving, retrying or deleting engine jobs is an operator * action with process-wide consequences, not something to expose as a generic * pass-through. `GET /jobs/{id}/stacktrace` returns the job's exception - * stacktrace as plain text and is gated behind ROLE_FLOWABLE_ADMIN, because a + * stacktrace as plain text and is gated behind `flowable:read_stacktrace`, because a * Java stacktrace leaks engine and application internals; `history` has no * stacktrace endpoint in the engine and always answers 404 here. */ @@ -114,7 +114,7 @@ controller: JobStacktraceController::class, read: false, output: false, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:read_stacktrace')", openapi: new Operation( tags: ['Flowable/System'], summary: 'Exception stacktrace of a job, as plain text.', @@ -127,7 +127,7 @@ description: 'List dead-letter jobs, i.e. engine jobs whose retries are used up and that no longer run. Returns a page of jobs, each with id, kind (deadletter), handlerType, processInstanceId, executionId, processDefinitionId, elementId, elementName, retries, exceptionMessage, createTime, dueDate and tenantId.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable/System'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowJobProvider::class, extraProperties: [ McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowJob/mcpDeadletterList.input.json'], @@ -139,7 +139,7 @@ description: 'Run a timer job now instead of at its due date, by timer job id; the remaining wait is skipped and cannot be restored. Returns null on success; failures are tool errors.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable/System'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:job_execute')", processor: TimerJobExecuteProcessor::class, read: false, structuredContent: false, @@ -147,7 +147,7 @@ extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowJob/mcpExecuteTimer.input.json', 'uriVariables' => ['id' => 'Id of the timer job.']]], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_job:read']], diff --git a/src/ApiResource/FlowProcessDefinition.php b/src/ApiResource/FlowProcessDefinition.php index 71e7b2a..2e763f1 100644 --- a/src/ApiResource/FlowProcessDefinition.php +++ b/src/ApiResource/FlowProcessDefinition.php @@ -24,7 +24,7 @@ * * The collection lists ALL versions by default (no implicit latest=true); * pass ?latest=true to keep only the newest version per key (design D2). - * Read is open to ROLE_USER; starting an instance requires ROLE_FLOWABLE_ADMIN. + * Reading requires `flowable:read`; starting an instance requires `flowable:process_start`. */ #[ApiResource( shortName: 'FlowProcessDefinition', @@ -55,7 +55,7 @@ validate: false, output: FlowProcessInstance::class, status: 201, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:process_start')", // Points the client at the per-definition start-form schema // (resolved at runtime); the static requestBody stays the generic // businessKey/variables/apiConfiguration body. @@ -71,7 +71,7 @@ controller: ProcessDefinitionStartInputSchemaController::class, read: false, output: false, - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", openapi: new Operation( tags: ['Flowable'], summary: 'Per-definition input JSON-Schema for the start operation.', @@ -84,12 +84,12 @@ description: 'List deployed BPMN process definitions; pass latest=true for one row per key. Returns a page of definitions, each with id, key, name, version, description, category, deploymentId, suspended, startFormDefined and tenantId; start one with flowable_start_process by its key.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowProcessDefinitionProvider::class, extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowProcessDefinition/mcpList.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_process_definition:read']], diff --git a/src/ApiResource/FlowProcessInstance.php b/src/ApiResource/FlowProcessInstance.php index 48c3bec..f2890ae 100644 --- a/src/ApiResource/FlowProcessInstance.php +++ b/src/ApiResource/FlowProcessInstance.php @@ -26,7 +26,8 @@ /** * Doctrine-less pass-through resource for Flowable process instances. * - * Read is open to ROLE_USER; create/delete require ROLE_FLOWABLE_ADMIN. The + * Reading requires `flowable:read`; create requires `flowable:process_start`, delete requires + * `flowable:process_delete`. The * acting za7 user (JWT sub) is propagated as startUserId / the startedBy * variable on create — never taken from client input. Advancing a waiting * branch is done on FlowExecution (POST /executions/{id}/trigger), because @@ -61,7 +62,7 @@ validate: false, output: FlowProcessInstance::class, status: 201, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:process_start')", ), new Delete( uriTemplate: '/process_instances/{id}', @@ -70,7 +71,7 @@ // No Doctrine entity to load — skip the read step (which would 404 via // the default provider) and let the processor delete on the engine. read: false, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:process_delete')", ), ], mcp: [ @@ -79,7 +80,7 @@ description: 'Get the current state of a running process instance in one call. Returns processInstance (id, processDefinitionId, processDefinitionKey, businessKey, startUserId, startTime, suspended, ended, tenantId), openTasks (each with id, name, assignee, taskDefinitionKey, createTime, dueDate), openTasksTotal, currentActivityIds (BPMN element ids it waits at) and activeExecutions (each with id and activityId); ended instances fail, use flowable_history_get for them.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowProcessStatusProvider::class, output: FlowProcessStatus::class, normalizationContext: ['groups' => ['flow_mcp:read']], @@ -91,13 +92,13 @@ description: 'Start a new process instance by processDefinitionKey (latest version, preferred) or processDefinitionId, with optional businessKey and variables, preferably as a map {name: value}. Returns the new process instance with id, processDefinitionId, processDefinitionKey, businessKey, startUserId, startTime, suspended, ended and tenantId.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:process_start')", processor: ProcessInstanceCreateProcessor::class, read: false, extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowProcessInstance/create.input.json']], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_process_instance:read']], diff --git a/src/ApiResource/FlowTask.php b/src/ApiResource/FlowTask.php index d67afa9..7dc6a76 100644 --- a/src/ApiResource/FlowTask.php +++ b/src/ApiResource/FlowTask.php @@ -26,7 +26,7 @@ /** * Doctrine-less pass-through resource for Flowable (user) tasks. * - * Read is open to ROLE_USER; completing a task requires ROLE_FLOWABLE_ADMIN. + * Reading requires `flowable:read`; completing a task requires `flowable:task_complete`. */ #[ApiResource( shortName: 'FlowTask', @@ -58,7 +58,7 @@ validate: false, output: false, status: 204, - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:task_complete')", // Points the client at the per-task form schema (resolved at // runtime); the static requestBody stays the generic variables map. openapi: new Operation( @@ -73,7 +73,7 @@ controller: TaskInputSchemaController::class, read: false, output: false, - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", openapi: new Operation( tags: ['Flowable'], summary: 'Per-task input JSON-Schema for the complete operation.', @@ -86,7 +86,7 @@ description: 'List open user tasks, newest first, optionally filtered by process instance, process definition, assignee or task definition key. Returns a page of tasks, each with id, name, description, assignee, owner, processInstanceId, processDefinitionId, executionId, taskDefinitionKey, priority, createTime, dueDate and tenantId.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowTaskProvider::class, extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowTask/mcpList.input.json']], ), @@ -95,7 +95,7 @@ description: 'Get the form of an open user task as the JSON schema of the flowable_complete_task arguments. Returns taskId and schema; the form fields (name, type, title, enum, required) are the properties of schema.properties.variables, and a task without form yields a schema without fields.', annotations: ['readOnlyHint' => true, 'destructiveHint' => false, 'idempotentHint' => true], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:mcp_read')", provider: FlowTaskFormProvider::class, output: FlowTaskForm::class, normalizationContext: ['groups' => ['flow_mcp:read'], 'preserve_empty_objects' => true], @@ -107,7 +107,7 @@ description: 'Complete an open user task with its form values as variables, preferably as a map {name: value} (flowable_get_task_form lists the fields). Returns null on success; failures are tool errors.', annotations: ['readOnlyHint' => false, 'destructiveHint' => true, 'idempotentHint' => false], meta: ['de.dmstr/tag' => 'Flowable'], - security: "is_granted('ROLE_FLOWABLE_ADMIN')", + security: "is_granted('flowable:task_complete')", processor: TaskCompleteProcessor::class, read: false, structuredContent: false, @@ -115,7 +115,7 @@ extraProperties: [McpToolInputSchemaFactory::EXTRA_KEY => ['input' => 'FlowTask/complete.input.json', 'uriVariables' => ['id' => 'Id of the open user task to complete.']]], ), ], - security: "is_granted('ROLE_USER')", + security: "is_granted('flowable:read')", paginationEnabled: true, paginationItemsPerPage: 30, normalizationContext: ['groups' => ['flow_task:read']], diff --git a/src/Controller/JobStacktraceController.php b/src/Controller/JobStacktraceController.php index a696ad3..7708905 100644 --- a/src/Controller/JobStacktraceController.php +++ b/src/Controller/JobStacktraceController.php @@ -6,10 +6,13 @@ namespace Dmstr\Flowable\Controller; use Dmstr\Flowable\Client\FlowableClientLocator; +use Dmstr\Flowable\Security\FlowablePermissions; use Dmstr\Flowable\State\FlowJobProvider; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * A job's exception stacktrace (GET /flowable/jobs/{id}/stacktrace). @@ -23,16 +26,25 @@ * do not resolve across kinds, so a dead-lettered job needs * `?kind=deadletter`. `kind=history` has no stacktrace endpoint in the engine * and always yields 404. + * + * The controller checks `flowable:read_stacktrace` itself: API Platform does + * not evaluate the operation's `security` expression for a custom controller + * with `read: false` (the expression only filters the hydra:operation list). */ final class JobStacktraceController { public function __construct( private readonly FlowableClientLocator $locator, + private readonly AuthorizationCheckerInterface $authorizationChecker, ) { } public function __invoke(string $id, Request $request): Response { + if (!$this->authorizationChecker->isGranted(FlowablePermissions::READ_STACKTRACE)) { + throw new AccessDeniedException(); + } + $apiConfiguration = $request->query->get('apiConfiguration'); $client = $this->locator->resolve( \is_string($apiConfiguration) && '' !== $apiConfiguration ? $apiConfiguration : null, @@ -52,3 +64,4 @@ public function __invoke(string $id, Request $request): Response return new Response($stacktrace, Response::HTTP_OK, ['Content-Type' => 'text/plain; charset=UTF-8']); } } +// - revised 2026-10-28 (checks flowable:read_stacktrace itself, security of read:false operations is not evaluated) diff --git a/src/Controller/ProcessDefinitionStartInputSchemaController.php b/src/Controller/ProcessDefinitionStartInputSchemaController.php index a2a8106..3d436d8 100644 --- a/src/Controller/ProcessDefinitionStartInputSchemaController.php +++ b/src/Controller/ProcessDefinitionStartInputSchemaController.php @@ -5,9 +5,12 @@ namespace Dmstr\Flowable\Controller; +use Dmstr\Flowable\Security\FlowablePermissions; use Dmstr\OpenApiJsonSchema\Interface\InputSchemaResolverInterface; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * Runtime, per-definition input schema for starting a process instance @@ -21,16 +24,25 @@ * vue-admin to render before the `start` call. When the definition has no * start form, the chain falls through to the static `start.input.json` * (generic businessKey/variables/apiConfiguration body). + * + * The controller checks `flowable:read` itself: API Platform does not + * evaluate the operation's `security` expression for a custom controller with + * `read: false` (the expression only filters the hydra:operation list). */ final class ProcessDefinitionStartInputSchemaController { public function __construct( private readonly InputSchemaResolverInterface $resolver, + private readonly AuthorizationCheckerInterface $authorizationChecker, ) { } public function __invoke(string $id, Request $request): JsonResponse { + if (!$this->authorizationChecker->isGranted(FlowablePermissions::READ)) { + throw new AccessDeniedException(); + } + $context = ['id' => $id]; $apiConfiguration = $request->query->get('apiConfiguration'); if (\is_string($apiConfiguration) && '' !== $apiConfiguration) { @@ -48,3 +60,4 @@ public function __invoke(string $id, Request $request): JsonResponse return new JsonResponse($schema, JsonResponse::HTTP_OK, ['Content-Type' => 'application/schema+json']); } } +// - revised 2026-10-28 (checks flowable:read itself, security of read:false operations is not evaluated) diff --git a/src/Controller/TaskInputSchemaController.php b/src/Controller/TaskInputSchemaController.php index 067dd58..eb76340 100644 --- a/src/Controller/TaskInputSchemaController.php +++ b/src/Controller/TaskInputSchemaController.php @@ -5,9 +5,12 @@ namespace Dmstr\Flowable\Controller; +use Dmstr\Flowable\Security\FlowablePermissions; use Dmstr\Flowable\Service\TaskInputSchema; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; +use Symfony\Component\Security\Core\Exception\AccessDeniedException; /** * Runtime, per-task input schema for completing a user task @@ -20,16 +23,25 @@ * task-specific form schema. Served as `application/schema+json` * for the vue-admin to render before the `complete` call. When the task has no * form, an open object schema is returned so completion stays possible. + * + * The controller checks `flowable:read` itself: API Platform does not + * evaluate the operation's `security` expression for a custom controller with + * `read: false` (the expression only filters the hydra:operation list). */ final class TaskInputSchemaController { public function __construct( private readonly TaskInputSchema $taskInputSchema, + private readonly AuthorizationCheckerInterface $authorizationChecker, ) { } public function __invoke(string $id, Request $request): JsonResponse { + if (!$this->authorizationChecker->isGranted(FlowablePermissions::READ)) { + throw new AccessDeniedException(); + } + $apiConfiguration = $request->query->get('apiConfiguration'); $schema = $this->taskInputSchema->forTask($id, \is_string($apiConfiguration) ? $apiConfiguration : null); @@ -37,3 +49,4 @@ public function __invoke(string $id, Request $request): JsonResponse } } // - revised 2026-10-08 (schema resolution shared with the MCP form tool via TaskInputSchema) +// - revised 2026-10-28 (checks flowable:read itself, security of read:false operations is not evaluated) diff --git a/src/Security/FlowablePermissions.php b/src/Security/FlowablePermissions.php new file mode 100644 index 0000000..82c5c29 --- /dev/null +++ b/src/Security/FlowablePermissions.php @@ -0,0 +1,80 @@ +')` (subject `null`). The bundle ships no + * voter and no role mapping: the consuming application grants them, e.g. + * za7-core collects the `za7_core.permission` tags registered in + * `config/services.yaml` and offers them for assignment. Without a voter + * that grants them, every operation and tool is denied. + * + * A constants class, never instantiated. + */ +final class FlowablePermissions +{ + /** Read operations of the engine resources, input schemas, `GET /jobs` of any kind */ + public const string READ = 'flowable:read'; + + /** `GET /jobs/{id}/stacktrace`: a Java stacktrace leaks engine internals */ + public const string READ_STACKTRACE = 'flowable:read_stacktrace'; + + /** The six read MCP tools, the dead-letter job list included */ + public const string MCP_READ = 'flowable:mcp_read'; + + /** Upload of BPMN, DMN and event deployments; MCP tools `flowable_deploy_bundle`, `flowable_dmn_deploy` */ + public const string DEPLOY = 'flowable:deploy'; + + /** Delete of BPMN, DMN and event deployments */ + public const string DEPLOY_DELETE = 'flowable:deploy_delete'; + + /** `POST /decisions/execute`, MCP tool `flowable_dmn_evaluate` */ + public const string DECISION_EVALUATE = 'flowable:decision_evaluate'; + + /** Start a process instance (by definition or directly), MCP tool `flowable_start_process` */ + public const string PROCESS_START = 'flowable:process_start'; + + /** `DELETE /process_instances/{id}` */ + public const string PROCESS_DELETE = 'flowable:process_delete'; + + /** `POST /tasks/{id}/complete`, MCP tool `flowable_complete_task` */ + public const string TASK_COMPLETE = 'flowable:task_complete'; + + /** `POST /event_instances`, MCP tool `flowable_events_send` */ + public const string EVENT_SEND = 'flowable:event_send'; + + /** `POST /executions/{id}/trigger`, MCP tool `flowable_system_trigger_execution` */ + public const string EXECUTION_TRIGGER = 'flowable:execution_trigger'; + + /** The External Worker operations: acquire, complete, fail, bpmnError, unacquire */ + public const string WORKER = 'flowable:worker'; + + /** MCP tool `flowable_system_execute_timer_job` */ + public const string JOB_EXECUTE = 'flowable:job_execute'; + + /** + * @return list + */ + public static function all(): array + { + return [ + self::READ, + self::READ_STACKTRACE, + self::MCP_READ, + self::DEPLOY, + self::DEPLOY_DELETE, + self::DECISION_EVALUATE, + self::PROCESS_START, + self::PROCESS_DELETE, + self::TASK_COMPLETE, + self::EVENT_SEND, + self::EXECUTION_TRIGGER, + self::WORKER, + self::JOB_EXECUTE, + ]; + } +} diff --git a/tests/Http/ApiTestCase.php b/tests/Http/ApiTestCase.php new file mode 100644 index 0000000..58ef336 --- /dev/null +++ b/tests/Http/ApiTestCase.php @@ -0,0 +1,292 @@ +client = static::createClient(['environment' => static::kernelEnvironment()]); + // A reboot would drop the in-memory database between requests + $this->client->disableReboot(); + + $entityManager = $this->entityManager(); + (new SchemaTool($entityManager))->createSchema($entityManager->getMetadataFactory()->getAllMetadata()); + + $this->createFlowableConfiguration(); + } + + /** + * Sends a request as a user with the given roles. + * + * @param list $roles + * @param array $headers extra headers, e.g. ['Accept' => 'text/html'] + */ + protected function requestAs( + string $method, + string $uri, + array $roles, + ?array $json = null, + array $headers = [], + string $user = TestHeaderAuthenticator::DEFAULT_USER, + ): Response { + return $this->request($method, $uri, $json, [ + TestHeaderAuthenticator::HEADER_ROLES => implode(',', $roles), + TestHeaderAuthenticator::HEADER_USER => $user, + ...$headers, + ]); + } + + /** + * Sends a request without authentication. + * + * @param array $headers + */ + protected function requestAnonymous(string $method, string $uri, ?array $json = null, array $headers = []): Response + { + return $this->request($method, $uri, $json, [ + TestHeaderAuthenticator::HEADER_AUTH => TestHeaderAuthenticator::ANONYMOUS, + ...$headers, + ]); + } + + /** + * Sends a request with exactly the given headers. Defaults: JSON-LD + * `Accept`, and a body content type matching the method. + * + * @param array $headers + */ + protected function request(string $method, string $uri, ?array $json = null, array $headers = []): Response + { + $headers += ['Accept' => 'application/ld+json']; + if ($json !== null) { + $headers += ['Content-Type' => strtoupper($method) === 'PATCH' + ? 'application/merge-patch+json' + : 'application/ld+json']; + } + + $server = []; + foreach ($headers as $name => $value) { + $key = strtoupper(str_replace('-', '_', $name)); + $server[\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH'], true) ? $key : 'HTTP_' . $key] = $value; + } + + $this->client->request( + $method, + $uri, + server: $server, + content: $json === null ? null : json_encode($json, JSON_THROW_ON_ERROR), + ); + + return $this->client->getResponse(); + } + + /** + * Opens an MCP session as a user with the given roles: sends + * `initialize` and the `notifications/initialized` notification. + * + * @param list $roles + * @return string the session id to pass to {@see self::mcpRequest()} + */ + protected function mcpInitialize(array $roles): string + { + $response = $this->mcpPost($roles, [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'phpunit', 'version' => '1.0'], + ], + ]); + self::assertSame(200, $response->getStatusCode(), (string) $response->getContent()); + + $sessionId = (string) $response->headers->get('Mcp-Session-Id', ''); + self::assertNotSame('', $sessionId, 'initialize returned no Mcp-Session-Id'); + + $this->mcpPost($roles, ['jsonrpc' => '2.0', 'method' => 'notifications/initialized'], $sessionId); + + return $sessionId; + } + + /** + * Sends one JSON-RPC request to the MCP endpoint and returns the decoded + * JSON-RPC response (`result` or `error`). Opens a session first unless + * one is given. + * + * @param list $roles + * @param array $params + * @return array + */ + protected function mcpRequest(string $method, array $params, array $roles, ?string $sessionId = null): array + { + $sessionId ??= $this->mcpInitialize($roles); + $response = $this->mcpPost($roles, [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => $method, + 'params' => $params === [] ? new \stdClass() : $params, + ], $sessionId); + + return self::decodeMcp($response); + } + + /** + * Names of the tools `tools/list` shows to a user with the given roles. + * + * @param list $roles + * @return list sorted + */ + protected function mcpToolNames(array $roles): array + { + $result = $this->mcpRequest('tools/list', [], $roles); + self::assertArrayHasKey('result', $result, json_encode($result, JSON_THROW_ON_ERROR)); + + $names = array_map(static fn (array $tool): string => $tool['name'], $result['result']['tools']); + sort($names); + + return $names; + } + + /** + * Calls a tool (`tools/call`) as a user with the given roles and returns + * the JSON-RPC response: `result` (with `isError` set when the tool failed) + * or `error`. + * + * @param list $roles + * @param array $arguments + * @return array + */ + protected function mcpCallTool(string $name, array $arguments, array $roles): array + { + return $this->mcpRequest('tools/call', [ + 'name' => $name, + 'arguments' => $arguments === [] ? new \stdClass() : $arguments, + ], $roles); + } + + /** + * @param list $roles + * @param array $message + */ + private function mcpPost(array $roles, array $message, ?string $sessionId = null): Response + { + $headers = [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]; + if ($sessionId !== null) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + return $this->requestAs('POST', TestKernel::MCP_PATH, $roles, $message, $headers); + } + + /** + * @return array + */ + private static function decodeMcp(Response $response): array + { + $body = (string) $response->getContent(); + + // The transport may answer with a server-sent event instead of plain JSON + if (str_contains((string) $response->headers->get('Content-Type'), 'text/event-stream')) { + foreach (explode("\n", $body) as $line) { + if (str_starts_with($line, 'data:')) { + $body = trim(substr($line, 5)); + break; + } + } + } + + return json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } + + /** + * Stores the active `flowable` ApiConfiguration the locator resolves + * implicitly (its password is encrypted by the bundle's Doctrine + * listener and decrypted again by the locator) and detaches it. + */ + protected function createFlowableConfiguration(): ApiConfiguration + { + $configuration = (new ApiConfiguration()) + ->setName('Flowable engine') + ->setConfigJson([ + 'flowable' => true, + 'type' => 'flowable', + 'endpoint_type' => 'rest', + 'base_url' => TestFlowableEngine::BASE_URL, + 'auth_type' => 'basic', + 'username' => 'rest-admin', + 'password' => 'test-password', + ]) + ->setActive(true); + + $entityManager = $this->entityManager(); + $entityManager->persist($configuration); + $entityManager->flush(); + $entityManager->clear(); + + return $configuration; + } + + /** + * The stub that answers for the engine; its `requests` show what reached it. + */ + protected function engine(): TestFlowableEngine + { + return static::getContainer()->get(TestFlowableEngine::class); + } + + /** + * @return array + */ + protected static function json(Response $response): array + { + return json_decode((string) $response->getContent(), true, flags: JSON_THROW_ON_ERROR); + } + + protected function entityManager(): EntityManagerInterface + { + return static::getContainer()->get('doctrine.orm.entity_manager'); + } +} diff --git a/tests/Http/ControllerOperationPermissionTest.php b/tests/Http/ControllerOperationPermissionTest.php new file mode 100644 index 0000000..b19ada4 --- /dev/null +++ b/tests/Http/ControllerOperationPermissionTest.php @@ -0,0 +1,51 @@ + + */ + public static function operations(): iterable + { + foreach (PermissionCatalog::controllerOperations() as $name => [$uri, $permission]) { + yield $name => [$uri, $permission]; + } + } + + #[DataProvider('operations')] + public function testOperationNeedsExactlyItsPermission(string $uri, string $permission): void + { + $denied = $this->requestAs('GET', $uri, P::rolesExcept($permission)); + self::assertSame(403, $denied->getStatusCode(), (string) $denied->getContent()); + self::assertSame([], $this->engine()->requests, 'a denied operation reached the engine'); + + $allowed = $this->requestAs('GET', $uri, [P::roleFor($permission)]); + self::assertSame(200, $allowed->getStatusCode(), (string) $allowed->getContent()); + } + + #[DataProvider('operations')] + public function testOperationRequiresAuthentication(string $uri, string $permission): void + { + $response = $this->requestAnonymous('GET', $uri); + + self::assertSame(401, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame([], $this->engine()->requests); + } +} +// - revised 2026-10-28 (renamed from UnguardedControllerOperationsTest, the gap is fixed in the controllers) diff --git a/tests/Http/CurrentBehaviourSmokeTest.php b/tests/Http/CurrentBehaviourSmokeTest.php new file mode 100644 index 0000000..fccd1b5 --- /dev/null +++ b/tests/Http/CurrentBehaviourSmokeTest.php @@ -0,0 +1,84 @@ +requestAs('GET', self::DEFINITIONS, ['ROLE_USER']); + + self::assertSame(200, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame(0, self::json($response)['totalItems'] ?? null); + self::assertContains( + ['method' => 'GET', 'path' => '/flowable-rest/service/repository/process-definitions'], + $this->engine()->requests, + ); + } + + public function testItemReachesTheProvider(): void + { + $response = $this->requestAs('GET', self::DEFINITIONS . '/def-1', ['ROLE_USER']); + + self::assertSame(200, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame('def-1', self::json($response)['id'] ?? null); + } + + public function testCollectionRequiresAuthentication(): void + { + self::assertSame(401, $this->requestAnonymous('GET', self::DEFINITIONS)->getStatusCode()); + // No test header at all is anonymous too + self::assertSame(401, $this->request('GET', self::DEFINITIONS)->getStatusCode()); + } + + public function testWriteIsForbiddenForARoleUser(): void + { + $response = $this->requestAs('POST', self::DEFINITIONS . '/def-1/start', ['ROLE_USER'], []); + + self::assertSame(403, $response->getStatusCode()); + } + + public function testWriteIsAllowedForTheAdminRole(): void + { + $response = $this->requestAs('POST', self::DEFINITIONS . '/def-1/start', ['ROLE_FLOWABLE_ADMIN'], []); + + self::assertNotSame(403, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame(201, $response->getStatusCode(), (string) $response->getContent()); + } + + public function testToolsAreListedForTheAdminRoleOnly(): void + { + $admin = $this->mcpToolNames(['ROLE_FLOWABLE_ADMIN']); + + self::assertContains('flowable_list_process_definitions', $admin); + self::assertContains('flowable_start_process', $admin); + self::assertSame([], $this->mcpToolNames(['ROLE_USER'])); + } + + public function testToolCallSucceedsForTheAdminRole(): void + { + $result = $this->mcpCallTool('flowable_list_process_definitions', [], ['ROLE_FLOWABLE_ADMIN']); + + self::assertArrayHasKey('result', $result, json_encode($result, JSON_THROW_ON_ERROR)); + self::assertNotTrue($result['result']['isError'] ?? false, json_encode($result, JSON_THROW_ON_ERROR)); + } + + public function testPermissionVoterIsRegistered(): void + { + self::assertTrue(static::getContainer()->has(TestPermissionVoter::class)); + } +} diff --git a/tests/Http/HydraOperationsTest.php b/tests/Http/HydraOperationsTest.php new file mode 100644 index 0000000..c257f76 --- /dev/null +++ b/tests/Http/HydraOperationsTest.php @@ -0,0 +1,82 @@ +, list}> item, all operations, read operations + */ + public static function items(): iterable + { + yield 'process instance' => ['process_instances/p1', ['GET process_instances/p1', 'DELETE process_instances/p1'], ['GET process_instances/p1']]; + yield 'task' => [ + 'tasks/t1', + ['GET tasks/t1', 'POST tasks/t1/complete', 'GET tasks/t1/input_schema'], + ['GET tasks/t1', 'GET tasks/t1/input_schema'], + ]; + yield 'process definition' => [ + 'process_definitions/d1', + ['GET process_definitions/d1', 'POST process_definitions/d1/start', 'GET process_definitions/d1/input_schema'], + ['GET process_definitions/d1', 'GET process_definitions/d1/input_schema'], + ]; + yield 'deployment' => ['deployments/d1', ['GET deployments/d1', 'DELETE deployments/d1'], ['GET deployments/d1']]; + yield 'execution' => ['executions/x1', ['GET executions/x1', 'POST executions/x1/trigger'], ['GET executions/x1']]; + yield 'job' => ['jobs/j1', ['GET jobs/j1', 'GET jobs/j1/stacktrace'], ['GET jobs/j1']]; + yield 'external worker job' => [ + 'external_worker_jobs/j1', + [ + 'GET external_worker_jobs/j1', + 'POST external_worker_jobs/j1/complete', + 'POST external_worker_jobs/j1/fail', + 'POST external_worker_jobs/j1/bpmnError', + 'POST external_worker_jobs/j1/unacquire', + ], + ['GET external_worker_jobs/j1'], + ]; + } + + /** + * @param list $all + * @param list $reads + */ + #[DataProvider('items')] + public function testOperationsFollowThePermissions(string $item, array $all, array $reads): void + { + $allPermissions = array_map(P::roleFor(...), P::ALL); + + self::assertEqualsCanonicalizing($all, $this->operations($item, $allPermissions)); + self::assertEqualsCanonicalizing($all, $this->operations($item, ['ROLE_FLOWABLE_ADMIN'])); + self::assertEqualsCanonicalizing($reads, $this->operations($item, ['ROLE_USER'])); + self::assertEqualsCanonicalizing($reads, $this->operations($item, [P::roleFor(P::READ)])); + } + + /** + * @param list $roles + * @return list "METHOD path below /api/flowable" + */ + private function operations(string $item, array $roles): array + { + $response = $this->requestAs('GET', self::FLOWABLE . '/' . $item, $roles); + self::assertSame(200, $response->getStatusCode(), (string) $response->getContent()); + + $operations = []; + foreach (self::json($response)['hydra:operation'] ?? [] as $operation) { + $operations[] = $operation['hydra:method'] . ' ' . substr($operation['@id'], strlen(self::FLOWABLE) + 1); + } + + return $operations; + } +} diff --git a/tests/Http/McpToolPermissionTest.php b/tests/Http/McpToolPermissionTest.php new file mode 100644 index 0000000..3daaf90 --- /dev/null +++ b/tests/Http/McpToolPermissionTest.php @@ -0,0 +1,158 @@ +}> + */ + public static function tools(): iterable + { + foreach (PermissionCatalog::tools() as $name => [$permission, $arguments]) { + yield $name => [$name, $permission, $arguments]; + } + } + + /** + * @return iterable + */ + public static function permissions(): iterable + { + foreach (P::ALL as $permission) { + yield $permission => [$permission]; + } + } + + #[DataProvider('permissions')] + public function testSinglePermissionListsExactlyItsTools(string $permission): void + { + self::assertSame(PermissionCatalog::toolsOf($permission), $this->mcpToolNames([P::roleFor($permission)])); + } + + #[DataProvider('tools')] + public function testToolIsHiddenWithoutItsPermission(string $name, string $permission, array $arguments): void + { + self::assertNotContains($name, $this->mcpToolNames(P::rolesExcept($permission))); + } + + #[DataProvider('tools')] + public function testToolCallIsRefusedWithoutItsPermission(string $name, string $permission, array $arguments): void + { + $result = $this->mcpCallTool($name, $arguments, P::rolesExcept($permission)); + + self::assertTrue( + isset($result['error']) || ($result['result']['isError'] ?? false) === true, + json_encode($result, JSON_THROW_ON_ERROR), + ); + self::assertSame([], $this->engine()->requests, 'a refused tool call reached the engine'); + } + + #[DataProvider('tools')] + public function testToolCallSucceedsWithItsPermission(string $name, string $permission, array $arguments): void + { + $result = $this->mcpCallTool($name, $arguments, [P::roleFor($permission)]); + + self::assertArrayHasKey('result', $result, json_encode($result, JSON_THROW_ON_ERROR)); + self::assertNotTrue($result['result']['isError'] ?? false, json_encode($result, JSON_THROW_ON_ERROR)); + self::assertNotSame([], $this->engine()->requests); + } + + public function testDeadLetterToolNeedsMcpReadNotRead(): void + { + $tool = 'flowable_system_list_deadletter_jobs'; + + self::assertNotContains($tool, $this->mcpToolNames([P::roleFor(P::READ)])); + self::assertContains($tool, $this->mcpToolNames([P::roleFor(P::MCP_READ)])); + } + + public function testAdminRoleListsAllFourteenTools(): void + { + self::assertCount(14, PermissionCatalog::tools()); + $expected = array_keys(PermissionCatalog::tools()); + sort($expected); + self::assertSame($expected, $this->mcpToolNames(['ROLE_FLOWABLE_ADMIN'])); + self::assertSame([], $this->mcpToolNames(['ROLE_USER'])); + } + + public function testAnonymousSeesAndCallsNothing(): void + { + // The test firewall has no access_control, so the transport itself + // answers; what matters is that the tool checks deny an anonymous user. + $session = $this->anonymousMcp('initialize', [ + 'protocolVersion' => '2025-06-18', + 'capabilities' => new \stdClass(), + 'clientInfo' => ['name' => 'phpunit', 'version' => '1.0'], + ]); + $sessionId = (string) $session->headers->get('Mcp-Session-Id', ''); + + $list = $this->anonymousMcp('tools/list', [], $sessionId); + if ($list->getStatusCode() === 200) { + self::assertSame([], self::decode($list)['result']['tools'] ?? []); + } else { + self::assertSame(401, $list->getStatusCode()); + } + + $call = $this->anonymousMcp('tools/call', ['name' => 'flowable_list_tasks', 'arguments' => new \stdClass()], $sessionId); + self::assertContains($call->getStatusCode(), [200, 401]); + if ($call->getStatusCode() === 200) { + $result = self::decode($call); + self::assertTrue(isset($result['error']) || ($result['result']['isError'] ?? false) === true, json_encode($result, JSON_THROW_ON_ERROR)); + } + self::assertSame([], $this->engine()->requests); + } + + /** + * @param array $params + */ + private function anonymousMcp(string $method, array $params, ?string $sessionId = null): Response + { + $headers = ['Accept' => 'application/json, text/event-stream', 'Content-Type' => 'application/json']; + if ($sessionId !== null) { + $headers['Mcp-Session-Id'] = $sessionId; + } + + return $this->requestAnonymous('POST', TestKernel::MCP_PATH, [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => $method, + 'params' => $params === [] ? new \stdClass() : $params, + ], $headers); + } + + /** + * @return array + */ + private static function decode(Response $response): array + { + $body = (string) $response->getContent(); + if (str_contains((string) $response->headers->get('Content-Type'), 'text/event-stream')) { + foreach (explode("\n", $body) as $line) { + if (str_starts_with($line, 'data:')) { + $body = trim(substr($line, 5)); + break; + } + } + } + + return json_decode($body, true, flags: JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Http/NoVoterDeniesEverythingTest.php b/tests/Http/NoVoterDeniesEverythingTest.php new file mode 100644 index 0000000..0908b4e --- /dev/null +++ b/tests/Http/NoVoterDeniesEverythingTest.php @@ -0,0 +1,87 @@ + every role of the example map, so only a missing voter can deny + */ + private static function allRoles(): array + { + return ['ROLE_USER', 'ROLE_FLOWABLE_ADMIN', ...array_map(P::roleFor(...), P::ALL)]; + } + + /** + * @return iterable, ?string}> + */ + public static function sensitiveOperations(): iterable + { + yield from PermissionCatalog::guardedSensitiveOperations(); + } + + /** + * @return iterable + */ + public static function readUris(): iterable + { + foreach (PermissionCatalog::guardedReadUris() as $uri) { + yield $uri => [$uri]; + } + } + + #[DataProvider('sensitiveOperations')] + public function testSensitiveOperationIsDenied(string $method, string $uri, string $permission, ?array $body, ?string $upload): void + { + $response = $this->perform([$method, $uri, $permission, $body, $upload], self::allRoles()); + + self::assertSame(403, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame([], $this->engine()->requests); + } + + #[DataProvider('readUris')] + public function testReadOperationIsDenied(string $uri): void + { + $response = $this->requestAs('GET', $uri, self::allRoles()); + + self::assertSame(403, $response->getStatusCode(), (string) $response->getContent()); + self::assertSame([], $this->engine()->requests); + } + + public function testNoToolIsVisible(): void + { + self::assertSame([], $this->mcpToolNames(self::allRoles())); + } + + public function testNoToolIsCallable(): void + { + foreach (PermissionCatalog::tools() as $name => [, $arguments]) { + $result = $this->mcpCallTool($name, $arguments, self::allRoles()); + + self::assertTrue( + isset($result['error']) || ($result['result']['isError'] ?? false) === true, + $name . ': ' . json_encode($result, JSON_THROW_ON_ERROR), + ); + } + self::assertSame([], $this->engine()->requests); + } +} diff --git a/tests/Http/NoVoterKernelSmokeTest.php b/tests/Http/NoVoterKernelSmokeTest.php new file mode 100644 index 0000000..7b5d9c0 --- /dev/null +++ b/tests/Http/NoVoterKernelSmokeTest.php @@ -0,0 +1,30 @@ +has(TestPermissionVoter::class)); + } + + public function testCollectionStillRequiresAuthentication(): void + { + self::assertSame(401, $this->requestAnonymous('GET', self::FLOWABLE . '/process_definitions')->getStatusCode()); + } +} diff --git a/tests/Http/OperationPermissionTest.php b/tests/Http/OperationPermissionTest.php new file mode 100644 index 0000000..e2f6c52 --- /dev/null +++ b/tests/Http/OperationPermissionTest.php @@ -0,0 +1,106 @@ +, ?string}> + */ + public static function sensitiveOperations(): iterable + { + yield from PermissionCatalog::guardedSensitiveOperations(); + } + + /** + * @return iterable + */ + public static function readUris(): iterable + { + foreach (PermissionCatalog::guardedReadUris() as $uri) { + yield $uri => [$uri]; + } + } + + /** + * @param array{string, string, string, ?array, ?string} $operation + */ + #[DataProvider('sensitiveOperations')] + public function testSensitiveOperationNeedsExactlyItsPermission(string $method, string $uri, string $permission, ?array $body, ?string $upload): void + { + $operation = [$method, $uri, $permission, $body, $upload]; + + $denied = $this->perform($operation, P::rolesExcept($permission)); + self::assertSame(403, $denied->getStatusCode(), (string) $denied->getContent()); + self::assertSame([], $this->engine()->requests, 'a denied operation reached the engine'); + + $allowed = $this->perform($operation, [P::roleFor($permission)]); + self::assertLessThan(400, $allowed->getStatusCode(), (string) $allowed->getContent()); + self::assertNotSame([], $this->engine()->requests, 'an allowed operation did not reach the engine'); + } + + /** + * @param array{string, string, string, ?array, ?string} $operation + */ + #[DataProvider('sensitiveOperations')] + public function testSensitiveOperationRequiresAuthentication(string $method, string $uri, string $permission, ?array $body, ?string $upload): void + { + $response = $this->requestAnonymous($method, $uri, $body); + + self::assertSame(401, $response->getStatusCode(), (string) $response->getContent()); + } + + #[DataProvider('sensitiveOperations')] + public function testAdminRoleOfTheExampleMapPassesEverything(string $method, string $uri, string $permission, ?array $body, ?string $upload): void + { + $response = $this->perform([$method, $uri, $permission, $body, $upload], ['ROLE_FLOWABLE_ADMIN']); + + self::assertLessThan(400, $response->getStatusCode(), (string) $response->getContent()); + } + + #[DataProvider('readUris')] + public function testReadOperationNeedsFlowableRead(string $uri): void + { + $denied = $this->requestAs('GET', $uri, P::rolesExcept(P::READ)); + self::assertSame(403, $denied->getStatusCode(), (string) $denied->getContent()); + self::assertSame([], $this->engine()->requests); + + $allowed = $this->requestAs('GET', $uri, [P::roleFor(P::READ)]); + self::assertLessThan(400, $allowed->getStatusCode(), (string) $allowed->getContent()); + self::assertNotSame([], $this->engine()->requests); + } + + public function testDeadLetterListNeedsFlowableReadNotMcpRead(): void + { + $uri = self::FLOWABLE . '/jobs?kind=deadletter'; + + self::assertSame(403, $this->requestAs('GET', $uri, [P::roleFor(P::MCP_READ)])->getStatusCode()); + self::assertSame(200, $this->requestAs('GET', $uri, [P::roleFor(P::READ)])->getStatusCode()); + self::assertContains( + ['method' => 'GET', 'path' => '/flowable-rest/service/management/deadletter-jobs'], + $this->engine()->requests, + ); + } + + public function testRoleUserOfTheExampleMapOnlyReads(): void + { + foreach (PermissionCatalog::guardedSensitiveOperations() as $name => [$method, $uri, , $body, $upload]) { + $response = $this->perform([$method, $uri, '', $body, $upload], ['ROLE_USER']); + self::assertSame(403, $response->getStatusCode(), $name); + } + } +} diff --git a/tests/Http/OperationRequests.php b/tests/Http/OperationRequests.php new file mode 100644 index 0000000..e5be21f --- /dev/null +++ b/tests/Http/OperationRequests.php @@ -0,0 +1,48 @@ +, ?string} $operation + * @param list $roles + */ + private function perform(array $operation, array $roles): Response + { + [$method, $uri, , $body, $upload] = $operation; + if ($upload === null) { + return $this->requestAs($method, $uri, $roles, $body); + } + + $path = tempnam(sys_get_temp_dir(), 'upload'); + file_put_contents($path, ''); + try { + $this->client->request( + $method, + $uri, + files: ['file' => new UploadedFile($path, $upload, null, null, true)], + server: [ + 'HTTP_ACCEPT' => 'application/ld+json', + 'HTTP_' . strtoupper(str_replace('-', '_', TestHeaderAuthenticator::HEADER_ROLES)) => implode(',', $roles), + 'HTTP_' . strtoupper(str_replace('-', '_', TestHeaderAuthenticator::HEADER_USER)) => TestHeaderAuthenticator::DEFAULT_USER, + ], + ); + } finally { + @unlink($path); + } + + return $this->client->getResponse(); + } +} diff --git a/tests/Http/PermissionCatalog.php b/tests/Http/PermissionCatalog.php new file mode 100644 index 0000000..9c693f3 --- /dev/null +++ b/tests/Http/PermissionCatalog.php @@ -0,0 +1,164 @@ +, ?string}> + */ + public static function sensitiveOperations(): array + { + return [ + 'upload bpmn' => ['POST', self::F . '/deployments/upload', P::DEPLOY, null, 'test.bpmn'], + 'upload dmn' => ['POST', self::F . '/dmn_deployments/upload', P::DEPLOY, null, 'test.dmn'], + 'upload event' => ['POST', self::F . '/event_deployments/upload', P::DEPLOY, null, 'test.event'], + 'delete deployment' => ['DELETE', self::F . '/deployments/d1', P::DEPLOY_DELETE, null, null], + 'delete dmn deployment' => ['DELETE', self::F . '/dmn_deployments/d1', P::DEPLOY_DELETE, null, null], + 'delete event deployment' => ['DELETE', self::F . '/event_deployments/d1', P::DEPLOY_DELETE, null, null], + 'evaluate decision' => ['POST', self::F . '/decisions/execute', P::DECISION_EVALUATE, ['decisionKey' => 'k'], null], + 'start via definition' => ['POST', self::F . '/process_definitions/def-1/start', P::PROCESS_START, [], null], + 'start via instance' => ['POST', self::F . '/process_instances', P::PROCESS_START, ['processDefinitionKey' => 'k'], null], + 'delete process instance' => ['DELETE', self::F . '/process_instances/p1', P::PROCESS_DELETE, null, null], + 'complete task' => ['POST', self::F . '/tasks/t1/complete', P::TASK_COMPLETE, [], null], + 'send event' => ['POST', self::F . '/event_instances', P::EVENT_SEND, [ + 'eventDefinitionKey' => 'e', + 'channelDefinitionKey' => 'c', + 'eventPayload' => ['a' => 1], + ], null], + 'trigger execution' => ['POST', self::F . '/executions/x1/trigger', P::EXECUTION_TRIGGER, [], null], + 'worker acquire' => ['POST', self::F . '/external_worker_jobs/acquire', P::WORKER, ['topic' => 't', 'workerId' => 'w'], null], + 'worker complete' => ['POST', self::F . '/external_worker_jobs/j1/complete', P::WORKER, ['workerId' => 'w'], null], + 'worker fail' => ['POST', self::F . '/external_worker_jobs/j1/fail', P::WORKER, ['workerId' => 'w'], null], + 'worker bpmnError' => ['POST', self::F . '/external_worker_jobs/j1/bpmnError', P::WORKER, ['workerId' => 'w', 'errorCode' => 'x'], null], + 'worker unacquire' => ['POST', self::F . '/external_worker_jobs/j1/unacquire', P::WORKER, ['workerId' => 'w'], null], + 'job stacktrace' => ['GET', self::F . '/jobs/j1/stacktrace', P::READ_STACKTRACE, null, null], + ]; + } + + /** + * Read operations: every resource's collection and item, the input-schema + * operations, and `GET /jobs` of several kinds. + * + * @return list + */ + public static function readUris(): array + { + $uris = []; + foreach ([ + 'decisions', 'deployments', 'dmn_deployments', 'event_deployments', 'event_definitions', + 'channel_definitions', 'executions', 'external_worker_jobs', 'historic_process_instances', + 'historic_tasks', 'jobs', 'process_definitions', 'process_instances', 'tasks', + ] as $collection) { + $uris[] = self::F . '/' . $collection; + $uris[] = self::F . '/' . $collection . '/id-1'; + } + foreach (['historic_activities', 'historic_variables', 'historic_decision_executions'] as $collection) { + $uris[] = self::F . '/' . $collection; + } + + return [ + ...$uris, + self::F . '/tasks/t1/input_schema', + self::F . '/process_definitions/def-1/input_schema', + self::F . '/jobs?kind=deadletter', + self::F . '/jobs?kind=timer', + ]; + } + + /** + * The 14 MCP tools: permission and valid arguments for the engine stub. + * + * @return array}> + */ + public static function tools(): array + { + return [ + 'flowable_list_process_definitions' => [P::MCP_READ, []], + 'flowable_list_tasks' => [P::MCP_READ, []], + 'flowable_get_task_form' => [P::MCP_READ, ['id' => 't1']], + 'flowable_get_process_status' => [P::MCP_READ, ['id' => 'p1']], + 'flowable_history_get' => [P::MCP_READ, ['id' => 'p1']], + 'flowable_system_list_deadletter_jobs' => [P::MCP_READ, []], + 'flowable_start_process' => [P::PROCESS_START, ['processDefinitionKey' => 'k']], + 'flowable_complete_task' => [P::TASK_COMPLETE, ['id' => 't1']], + 'flowable_dmn_evaluate' => [P::DECISION_EVALUATE, ['decisionKey' => 'k']], + 'flowable_events_send' => [P::EVENT_SEND, [ + 'eventDefinitionKey' => 'e', + 'channelDefinitionKey' => 'c', + 'eventPayload' => ['a' => 1], + ]], + 'flowable_system_trigger_execution' => [P::EXECUTION_TRIGGER, ['id' => 'x1']], + 'flowable_dmn_deploy' => [P::DEPLOY, ['name' => 'a.dmn', 'content' => '']], + 'flowable_deploy_bundle' => [P::DEPLOY, [ + 'deploymentName' => 'bundle', + 'files' => [['name' => 'a.bpmn', 'content' => '']], + ]], + 'flowable_system_execute_timer_job' => [P::JOB_EXECUTE, ['id' => 'j1']], + ]; + } + + /** + * The three operations that run a custom controller with `read: false` + * (job stacktrace, the two input schemas). API Platform skips its provider + * chain for them and evaluates their `security` expression nowhere, so the + * controllers check the permission themselves. The generic matrices leave + * them out and {@see ControllerOperationPermissionTest} covers them. + * + * @return array name => [uri, permission] + */ + public static function controllerOperations(): array + { + return [ + 'job stacktrace' => [self::F . '/jobs/j1/stacktrace', P::READ_STACKTRACE], + 'task input schema' => [self::F . '/tasks/t1/input_schema', P::READ], + 'definition input schema' => [self::F . '/process_definitions/def-1/input_schema', P::READ], + ]; + } + + /** + * @return array, ?string}> sensitive operations the generic matrices can check + */ + public static function guardedSensitiveOperations(): array + { + return array_diff_key(self::sensitiveOperations(), self::controllerOperations()); + } + + /** + * @return list read uris the generic matrices can check + */ + public static function guardedReadUris(): array + { + $controllerUris = array_column(self::controllerOperations(), 0); + + return array_values(array_diff(self::readUris(), $controllerUris)); + } + + /** + * @return list sorted names of the tools guarded by $permission + */ + public static function toolsOf(string $permission): array + { + $names = array_keys(array_filter(self::tools(), static fn (array $tool): bool => $tool[0] === $permission)); + sort($names); + + return $names; + } +} diff --git a/tests/Kernel/TestFlowableEngine.php b/tests/Kernel/TestFlowableEngine.php new file mode 100644 index 0000000..6d1e1ba --- /dev/null +++ b/tests/Kernel/TestFlowableEngine.php @@ -0,0 +1,81 @@ +, name: ...}`; + * - `DELETE` returns 204; + * - `POST` and `PUT` return `{id: 'stub'}` with 201 / 200. + * + * Every request is recorded in {@see self::$requests}, so a test can assert + * that an allowed operation reached the engine and a denied one did not. + * No status above 399 is ever produced. + */ +final class TestFlowableEngine +{ + /** The `base_url` of the `flowable` ApiConfiguration the tests store */ + public const string BASE_URL = 'http://flowable.invalid/flowable-rest'; + + private const string COLLECTION_PATTERN = '#/(deployments|process-definitions|process-instances|tasks|executions' + . '|historic-[a-z-]+|decisions|event-definitions|channel-definitions|jobs|timer-jobs|suspended-jobs' + . '|deadletter-jobs|history-jobs)$#'; + + /** @var list */ + public array $requests = []; + + /** + * @param array $options + */ + public function __invoke(string $method, string $url, array $options = []): ResponseInterface + { + $path = (string) parse_url($url, PHP_URL_PATH); + $this->requests[] = ['method' => $method, 'path' => $path]; + + $headers = ['response_headers' => ['Content-Type: application/json']]; + + if ($method === 'DELETE') { + return new MockResponse('', ['http_code' => 204]); + } + if ($method === 'POST' || $method === 'PUT') { + return new MockResponse( + json_encode(['id' => 'stub'], JSON_THROW_ON_ERROR), + ['http_code' => $method === 'POST' ? 201 : 200] + $headers, + ); + } + + return new MockResponse(json_encode($this->read($path), JSON_THROW_ON_ERROR), $headers); + } + + /** + * @return array|list + */ + private function read(string $path): array + { + if (preg_match(self::COLLECTION_PATTERN, $path) === 1) { + return ['data' => [], 'total' => 0, 'start' => 0, 'size' => 0]; + } + if (preg_match('#/(variables|resources)$#', $path) === 1) { + return []; + } + if (str_ends_with($path, '/engine')) { + return ['name' => 'stub', 'version' => '7.0.0']; + } + + return ['id' => basename($path), 'name' => 'stub']; + } +} diff --git a/tests/Kernel/TestHeaderAuthenticator.php b/tests/Kernel/TestHeaderAuthenticator.php new file mode 100644 index 0000000..cc9aa57 --- /dev/null +++ b/tests/Kernel/TestHeaderAuthenticator.php @@ -0,0 +1,87 @@ +headers->get(self::HEADER_AUTH, ''))) === self::ANONYMOUS) { + return false; + } + + return $request->headers->has(self::HEADER_ROLES) || $request->headers->has(self::HEADER_USER); + } + + public function authenticate(Request $request): Passport + { + $identifier = trim((string) $request->headers->get(self::HEADER_USER, '')); + if ($identifier === '') { + $identifier = self::DEFAULT_USER; + } + $roles = self::parseRoles((string) $request->headers->get(self::HEADER_ROLES, '')); + + return new SelfValidatingPassport(new UserBadge( + $identifier, + static fn (string $identifier): InMemoryUser => new InMemoryUser($identifier, null, $roles), + )); + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response + { + return null; + } + + public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response + { + return new JsonResponse(['error' => $exception->getMessageKey()], Response::HTTP_UNAUTHORIZED); + } + + public function start(Request $request, ?AuthenticationException $authException = null): Response + { + return new JsonResponse(['error' => 'Authentication required'], Response::HTTP_UNAUTHORIZED); + } + + /** + * @return list + */ + public static function parseRoles(string $header): array + { + return array_values(array_unique(array_filter( + array_map('trim', explode(',', $header)), + static fn (string $role): bool => $role !== '', + ))); + } +} diff --git a/tests/Kernel/TestKernel.php b/tests/Kernel/TestKernel.php new file mode 100644 index 0000000..5e2c8f4 --- /dev/null +++ b/tests/Kernel/TestKernel.php @@ -0,0 +1,240 @@ +tmpDir() . '/cache'; + } + + public function getLogDir(): string + { + return $this->tmpDir() . '/log'; + } + + /** + * Not the repository's `config/` (the bundle's service definitions): + * in debug mode FrameworkBundle 7.4+ dumps `reference.php` into an + * existing config directory. This one does not exist, so nothing is + * written; the kernel reads no files from it ({@see self::configureContainer()} + * configures everything in code). + */ + private function getConfigDir(): string + { + return $this->tmpDir() . '/config'; + } + + public function hasPermissionVoter(): bool + { + return $this->environment !== self::ENV_NO_VOTER; + } + + private function tmpDir(): string + { + return sys_get_temp_dir() . '/dmstr-flowable-bundle-tests/' . $this->environment; + } + + private function configureContainer(ContainerConfigurator $container): void + { + $projectDir = $this->getProjectDir(); + $apiConfigurationEntities = $this->apiConfigurationDir() . '/src/Entity'; + + $container->parameters() + ->set('app.oauth.redirect_uri', self::OAUTH_REDIRECT_URI); + + $container->extension('framework', [ + 'test' => true, + 'secret' => 'test-secret', + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => ['log' => true], + 'cache' => ['app' => 'cache.adapter.array'], + ]); + + $container->extension('security', [ + 'providers' => [ + 'test_users' => ['memory' => ['users' => []]], + ], + 'firewalls' => [ + 'main' => [ + 'pattern' => '^/', + 'stateless' => true, + 'provider' => 'test_users', + 'custom_authenticators' => [TestHeaderAuthenticator::class], + ], + ], + ]); + + $container->extension('doctrine', [ + 'dbal' => [ + 'url' => 'sqlite:///:memory:', + ], + 'orm' => [ + 'enable_native_lazy_objects' => true, + 'naming_strategy' => 'doctrine.orm.naming_strategy.underscore_number_aware', + 'mappings' => [ + 'ApiConfiguration' => [ + 'type' => 'attribute', + 'is_bundle' => false, + 'dir' => $apiConfigurationEntities, + 'prefix' => 'Dmstr\\ApiConfiguration\\Entity', + ], + ], + ], + ]); + + // The bundle's ApiResource classes under src/ApiResource are found by + // API Platform on its own (every bundle's src/ApiResource is scanned). + $container->extension('api_platform', [ + 'title' => 'flowable-bundle tests', + 'formats' => [ + 'jsonld' => ['application/ld+json'], + 'json' => ['application/json'], + ], + 'patch_formats' => [ + 'json' => ['application/merge-patch+json'], + ], + 'enable_swagger_ui' => false, + 'enable_re_doc' => false, + 'mcp' => ['enabled' => true], + ]); + + $container->extension('dmstr_api_platform_utils', [ + 'credential_encryption' => ['key' => self::CREDENTIALS_KEY], + // The default, set explicitly because the tests rely on it + 'hydra_operations' => ['filter_operations_by_security' => true], + ]); + + // All switches on: otherwise the bundle removes its MCP tools + $container->extension('dmstr_flowable', [ + 'mcp' => ['read' => true, 'write' => true, 'deploy' => true], + ]); + + $container->extension('mcp', [ + 'servers' => [ + self::MCP_SERVER => [ + 'registry' => ['tools' => ['*']], + 'http' => ['path' => self::MCP_PATH], + ], + ], + ]); + + $services = $container->services() + ->defaults() + ->autowire() + ->autoconfigure(); + + // Keep the test output free of framework log lines + $services->set('logger', NullLogger::class); + // FlowableBundle binds its audit logger to the monolog channel + $services->set('monolog.logger.flowable', NullLogger::class); + + // The engine: every Flowable REST call is answered by the stub. + // Public so that tests can read the recorded requests. + $services->set(TestFlowableEngine::class)->public(); + $services->set('http_client', MockHttpClient::class) + ->args([service(TestFlowableEngine::class)]); + + $services->set(TestHeaderAuthenticator::class); + + if ($this->hasPermissionVoter()) { + // Autoconfiguration adds the `security.voter` tag; public so that + // tests can tell the two variants apart instead of finding an + // inlined service missing in both + $services->set(TestPermissionVoter::class)->public(); + } + } + + private function configureRoutes(RoutingConfigurator $routes): void + { + $routes->import('.', 'api_platform')->prefix(self::API_PREFIX); + $routes->import('.', 'mcp'); + } + + private function apiConfigurationDir(): string + { + return \dirname((new \ReflectionClass(ApiConfigurationBundle::class))->getFileName(), 2); + } +} diff --git a/tests/Kernel/TestPermissionVoter.php b/tests/Kernel/TestPermissionVoter.php new file mode 100644 index 0000000..bcaf7cf --- /dev/null +++ b/tests/Kernel/TestPermissionVoter.php @@ -0,0 +1,147 @@ + [self::READ], + 'ROLE_FLOWABLE_ADMIN' => self::ALL, + ]; + + public function vote(TokenInterface $token, mixed $subject, array $attributes, ?Vote $vote = null): int + { + try { + $result = self::ACCESS_ABSTAIN; + $granted = self::grantedPermissions($token->getRoleNames()); + + foreach ($attributes as $attribute) { + if (!self::supports($attribute)) { + continue; + } + if (in_array($attribute, $granted, true)) { + return self::ACCESS_GRANTED; + } + $result = self::ACCESS_DENIED; + } + + return $result; + } catch (\Throwable) { + return self::ACCESS_ABSTAIN; + } + } + + /** + * The role that grants exactly $permission. + */ + public static function roleFor(string $permission): string + { + return self::SINGLE_ROLE_PREFIX . strtoupper(str_replace(':', '_', $permission)); + } + + /** + * The single-permission roles of all permissions but $permission. + * + * @return list + */ + public static function rolesExcept(string $permission): array + { + return array_values(array_map( + self::roleFor(...), + array_filter(self::ALL, static fn (string $candidate): bool => $candidate !== $permission), + )); + } + + /** + * @param list $roles + * @return list + */ + public static function grantedPermissions(array $roles): array + { + $single = []; + foreach (self::ALL as $permission) { + $single[self::roleFor($permission)] = [$permission]; + } + + $granted = []; + foreach ($roles as $role) { + array_push($granted, ...(self::PERMISSIONS[$role] ?? $single[$role] ?? [])); + } + + return array_values(array_unique($granted)); + } + + private static function supports(mixed $attribute): bool + { + return is_string($attribute) + && str_starts_with($attribute, self::PREFIX) + && preg_match(self::ATTRIBUTE_PATTERN, $attribute) === 1; + } +} diff --git a/tests/Mcp/McpToolMetadataTest.php b/tests/Mcp/McpToolMetadataTest.php index 4f018e4..ed09b5b 100644 --- a/tests/Mcp/McpToolMetadataTest.php +++ b/tests/Mcp/McpToolMetadataTest.php @@ -21,9 +21,6 @@ */ final class McpToolMetadataTest extends TestCase { - /** No MCP tool is open to ROLE_USER, read tools included. */ - private const ADMIN = "is_granted('ROLE_FLOWABLE_ADMIN')"; - /** * name => destructiveHint; every state-changing tool that cannot be * undone is destructive, so clients ask before calling it. @@ -51,25 +48,25 @@ final class McpToolMetadataTest extends TestCase private const DEPLOY_TOOLS = ['flowable_dmn_deploy', 'flowable_deploy_bundle']; /** - * name => [resource short name, security, readOnlyHint, tag, input file, has id argument] + * name => [resource short name, security expression (one permission per tool), readOnlyHint, tag, input file, has id argument] * * @var array */ private const TOOLS = [ - 'flowable_list_process_definitions' => ['FlowProcessDefinition', self::ADMIN, true, 'Flowable', 'FlowProcessDefinition/mcpList.input.json', false], - 'flowable_list_tasks' => ['FlowTask', self::ADMIN, true, 'Flowable', 'FlowTask/mcpList.input.json', false], - 'flowable_get_task_form' => ['FlowTask', self::ADMIN, true, 'Flowable', 'FlowTask/mcpForm.input.json', true], - 'flowable_get_process_status' => ['FlowProcessInstance', self::ADMIN, true, 'Flowable', 'FlowProcessInstance/mcpStatus.input.json', true], - 'flowable_history_get' => ['FlowHistoricProcessInstance', self::ADMIN, true, 'Flowable/History', 'FlowHistoricProcessInstance/mcpHistory.input.json', true], - 'flowable_system_list_deadletter_jobs' => ['FlowJob', self::ADMIN, true, 'Flowable/System', 'FlowJob/mcpDeadletterList.input.json', false], - 'flowable_start_process' => ['FlowProcessInstance', self::ADMIN, false, 'Flowable', 'FlowProcessInstance/create.input.json', false], - 'flowable_complete_task' => ['FlowTask', self::ADMIN, false, 'Flowable', 'FlowTask/complete.input.json', true], - 'flowable_dmn_evaluate' => ['FlowDecision', self::ADMIN, false, 'Flowable/DMN', 'FlowDecision/execute.input.json', false], - 'flowable_events_send' => ['FlowEventInstance', self::ADMIN, false, 'Flowable/Events', 'FlowEventInstance/create.input.json', false], - 'flowable_system_trigger_execution' => ['FlowExecution', self::ADMIN, false, 'Flowable/System', 'FlowExecution/trigger.input.json', true], - 'flowable_dmn_deploy' => ['FlowDmnDeployment', self::ADMIN, false, 'Flowable/DMN', 'FlowDmnDeployment/mcpDeploy.input.json', false], - 'flowable_deploy_bundle' => ['FlowDeployment', self::ADMIN, false, 'Flowable', 'FlowDeployment/mcpBundle.input.json', false], - 'flowable_system_execute_timer_job' => ['FlowJob', self::ADMIN, false, 'Flowable/System', 'FlowJob/mcpExecuteTimer.input.json', true], + 'flowable_list_process_definitions' => ['FlowProcessDefinition', "is_granted('flowable:mcp_read')", true, 'Flowable', 'FlowProcessDefinition/mcpList.input.json', false], + 'flowable_list_tasks' => ['FlowTask', "is_granted('flowable:mcp_read')", true, 'Flowable', 'FlowTask/mcpList.input.json', false], + 'flowable_get_task_form' => ['FlowTask', "is_granted('flowable:mcp_read')", true, 'Flowable', 'FlowTask/mcpForm.input.json', true], + 'flowable_get_process_status' => ['FlowProcessInstance', "is_granted('flowable:mcp_read')", true, 'Flowable', 'FlowProcessInstance/mcpStatus.input.json', true], + 'flowable_history_get' => ['FlowHistoricProcessInstance', "is_granted('flowable:mcp_read')", true, 'Flowable/History', 'FlowHistoricProcessInstance/mcpHistory.input.json', true], + 'flowable_system_list_deadletter_jobs' => ['FlowJob', "is_granted('flowable:mcp_read')", true, 'Flowable/System', 'FlowJob/mcpDeadletterList.input.json', false], + 'flowable_start_process' => ['FlowProcessInstance', "is_granted('flowable:process_start')", false, 'Flowable', 'FlowProcessInstance/create.input.json', false], + 'flowable_complete_task' => ['FlowTask', "is_granted('flowable:task_complete')", false, 'Flowable', 'FlowTask/complete.input.json', true], + 'flowable_dmn_evaluate' => ['FlowDecision', "is_granted('flowable:decision_evaluate')", false, 'Flowable/DMN', 'FlowDecision/execute.input.json', false], + 'flowable_events_send' => ['FlowEventInstance', "is_granted('flowable:event_send')", false, 'Flowable/Events', 'FlowEventInstance/create.input.json', false], + 'flowable_system_trigger_execution' => ['FlowExecution', "is_granted('flowable:execution_trigger')", false, 'Flowable/System', 'FlowExecution/trigger.input.json', true], + 'flowable_dmn_deploy' => ['FlowDmnDeployment', "is_granted('flowable:deploy')", false, 'Flowable/DMN', 'FlowDmnDeployment/mcpDeploy.input.json', false], + 'flowable_deploy_bundle' => ['FlowDeployment', "is_granted('flowable:deploy')", false, 'Flowable', 'FlowDeployment/mcpBundle.input.json', false], + 'flowable_system_execute_timer_job' => ['FlowJob', "is_granted('flowable:job_execute')", false, 'Flowable/System', 'FlowJob/mcpExecuteTimer.input.json', true], ]; private const READ_TOOLS = [ @@ -235,3 +232,4 @@ private static function tools(ResourceMetadataCollectionFactoryInterface $factor } } // - revised 2026-10-09 (admin-only tools, destructiveHint, mcp.deploy) +// - revised 2026-10-28 (one flowable:* permission per tool) diff --git a/tests/Security/PermissionRegistrationTest.php b/tests/Security/PermissionRegistrationTest.php new file mode 100644 index 0000000..2ec59b5 --- /dev/null +++ b/tests/Security/PermissionRegistrationTest.php @@ -0,0 +1,210 @@ +getConstants()), + FlowablePermissions::all(), + 'all() lists the constants in declaration order', + ); + } + + public function testEveryExpressionChecksOneAttributeWithoutSubject(): void + { + // An `object` would make the MCP tool listing evaluate the expression + // against a null subject and error instead of denying. + foreach (self::expressions() as $where => $expression) { + self::assertMatchesRegularExpression(self::EXPRESSION_PATTERN, $expression, $where); + self::assertMatchesRegularExpression(self::PERMISSION_PATTERN, self::attribute($expression), $where); + } + } + + public function testNothingChecksARole(): void + { + foreach (self::expressions() as $where => $expression) { + self::assertStringNotContainsString('ROLE_', $expression, $where); + } + } + + public function testEverySensitiveOperationAndToolStatesItsOwnPermission(): void + { + $httpCounts = []; + $toolPermissions = []; + foreach (self::operations() as $where => [$kind, $permission, $sensitive]) { + if ($kind === 'tool') { + $toolPermissions[substr($where, strlen('tool '))] = $permission; + } elseif ($sensitive) { + $httpCounts[$permission] = ($httpCounts[$permission] ?? 0) + 1; + } else { + self::assertSame(FlowablePermissions::READ, $permission, $where . ' is a read operation'); + } + } + + $expectedCounts = []; + foreach (PermissionCatalog::sensitiveOperations() as [, , $permission]) { + $expectedCounts[$permission] = ($expectedCounts[$permission] ?? 0) + 1; + } + ksort($httpCounts); + ksort($expectedCounts); + self::assertSame(19, array_sum($httpCounts)); + self::assertSame($expectedCounts, $httpCounts); + + $expectedTools = array_map(static fn (array $tool): string => $tool[0], PermissionCatalog::tools()); + ksort($toolPermissions); + ksort($expectedTools); + self::assertCount(14, $toolPermissions); + self::assertSame($expectedTools, $toolPermissions); + } + + /** + * @return list + */ + private static function taggedPermissions(): array + { + $container = new ContainerBuilder(); + (new YamlFileLoader($container, new FileLocator(\dirname(__DIR__, 2) . '/config')))->load('services.yaml'); + + $permissions = []; + foreach ($container->findTaggedServiceIds(self::TAG) as $tags) { + foreach ($tags as $attributes) { + self::assertArrayHasKey('permission', $attributes); + $permissions[] = $attributes['permission']; + } + } + + return $permissions; + } + + /** + * Attributes of every `is_granted('…')` in the resource, operation and + * tool security expressions. + * + * @return list + */ + private static function checkedAttributes(): array + { + $attributes = []; + foreach (self::expressions() as $expression) { + preg_match_all("/is_granted\\(\\s*'([^']+)'/", $expression, $matches); + array_push($attributes, ...$matches[1]); + } + + return array_values(array_unique($attributes)); + } + + /** + * @return array where => security expression: resource level, operations and tools + */ + private static function expressions(): array + { + $expressions = []; + foreach (self::resources() as $class => $resource) { + $expressions[$class] = (string) $resource->getSecurity(); + } + foreach (self::operations() as $where => [, , , $expression]) { + $expressions[$where] = $expression; + } + + return $expressions; + } + + /** + * HTTP operations and MCP tools of every resource, read from the real + * attribute metadata. + * + * @return array "http " or "tool " => [kind, permission, sensitive, expression] + */ + private static function operations(): array + { + $result = []; + foreach (self::resources() as $class => $resource) { + foreach ($resource->getOperations() ?? [] as $operation) { + $expression = (string) $operation->getSecurity(); + $permission = self::attribute($expression); + // Every write operation, plus the stacktrace endpoint, which is a GET with its own permission + $sensitive = $operation->getMethod() !== 'GET' || $permission === FlowablePermissions::READ_STACKTRACE; + $result['http ' . $class . ' ' . $operation->getMethod() . ' ' . $operation->getUriTemplate()] = ['http', $permission, $sensitive, $expression]; + } + foreach ($resource->getMcp() ?? [] as $tool) { + $expression = (string) $tool->getSecurity(); + $result['tool ' . $tool->getName()] = ['tool', self::attribute($expression), true, $expression]; + } + } + + return $result; + } + + private static function attribute(string $expression): string + { + preg_match(self::EXPRESSION_PATTERN, $expression, $match); + + return $match[1] ?? ''; + } + + /** + * @return array first resource of each class of src/ApiResource + */ + private static function resources(): array + { + $factory = new AttributesResourceMetadataCollectionFactory(); + $resources = []; + foreach (glob(\dirname(__DIR__, 2) . '/src/ApiResource/*.php') ?: [] as $file) { + $class = 'Dmstr\\Flowable\\ApiResource\\' . basename($file, '.php'); + $resources[$class] = $factory->create($class)[0]; + } + + return $resources; + } +}