From 57410663d93eb935337abfbee923ce93516f6d55 Mon Sep 17 00:00:00 2001
From: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
Date: Tue, 28 Apr 2026 16:28:57 +0200
Subject: [PATCH] feat(wfe): add runtime operations
Signed-off-by: Salvatore Martire <4652631+salmart-dev@users.noreply.github.com>
# Conflicts:
# apps/workflowengine/lib/Manager.php
---
apps/workflowengine/appinfo/info.xml | 1 +
.../composer/composer/autoload_classmap.php | 1 +
.../composer/composer/autoload_static.php | 1 +
.../lib/AppInfo/Application.php | 12 +-
apps/workflowengine/lib/Command/Runtime.php | 103 ++++++++
apps/workflowengine/lib/Manager.php | 235 +++++++++++++++++-
.../lib/Service/RuleMatcher.php | 11 +-
apps/workflowengine/tests/ManagerTest.php | 126 +++++++++-
lib/composer/composer/autoload_classmap.php | 1 +
lib/composer/composer/autoload_static.php | 1 +
.../Events/RegisterRuntimeOperationsEvent.php | 28 +++
11 files changed, 514 insertions(+), 6 deletions(-)
create mode 100644 apps/workflowengine/lib/Command/Runtime.php
create mode 100644 lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php
diff --git a/apps/workflowengine/appinfo/info.xml b/apps/workflowengine/appinfo/info.xml
index c6fce1d533439..92462330d3982 100644
--- a/apps/workflowengine/appinfo/info.xml
+++ b/apps/workflowengine/appinfo/info.xml
@@ -41,6 +41,7 @@
OCA\WorkflowEngine\Command\Index
+ OCA\WorkflowEngine\Command\Runtime
diff --git a/apps/workflowengine/composer/composer/autoload_classmap.php b/apps/workflowengine/composer/composer/autoload_classmap.php
index 0fd869905d4ee..71ae91689da17 100644
--- a/apps/workflowengine/composer/composer/autoload_classmap.php
+++ b/apps/workflowengine/composer/composer/autoload_classmap.php
@@ -21,6 +21,7 @@
'OCA\\WorkflowEngine\\Check\\TFileCheck' => $baseDir . '/../lib/Check/TFileCheck.php',
'OCA\\WorkflowEngine\\Check\\UserGroupMembership' => $baseDir . '/../lib/Check/UserGroupMembership.php',
'OCA\\WorkflowEngine\\Command\\Index' => $baseDir . '/../lib/Command/Index.php',
+ 'OCA\\WorkflowEngine\\Command\\Runtime' => $baseDir . '/../lib/Command/Runtime.php',
'OCA\\WorkflowEngine\\Controller\\AWorkflowOCSController' => $baseDir . '/../lib/Controller/AWorkflowOCSController.php',
'OCA\\WorkflowEngine\\Controller\\GlobalWorkflowsController' => $baseDir . '/../lib/Controller/GlobalWorkflowsController.php',
'OCA\\WorkflowEngine\\Controller\\RequestTimeController' => $baseDir . '/../lib/Controller/RequestTimeController.php',
diff --git a/apps/workflowengine/composer/composer/autoload_static.php b/apps/workflowengine/composer/composer/autoload_static.php
index dddcf5611dab1..1bb73b2f8b92e 100644
--- a/apps/workflowengine/composer/composer/autoload_static.php
+++ b/apps/workflowengine/composer/composer/autoload_static.php
@@ -36,6 +36,7 @@ class ComposerStaticInitWorkflowEngine
'OCA\\WorkflowEngine\\Check\\TFileCheck' => __DIR__ . '/..' . '/../lib/Check/TFileCheck.php',
'OCA\\WorkflowEngine\\Check\\UserGroupMembership' => __DIR__ . '/..' . '/../lib/Check/UserGroupMembership.php',
'OCA\\WorkflowEngine\\Command\\Index' => __DIR__ . '/..' . '/../lib/Command/Index.php',
+ 'OCA\\WorkflowEngine\\Command\\Runtime' => __DIR__ . '/..' . '/../lib/Command/Runtime.php',
'OCA\\WorkflowEngine\\Controller\\AWorkflowOCSController' => __DIR__ . '/..' . '/../lib/Controller/AWorkflowOCSController.php',
'OCA\\WorkflowEngine\\Controller\\GlobalWorkflowsController' => __DIR__ . '/..' . '/../lib/Controller/GlobalWorkflowsController.php',
'OCA\\WorkflowEngine\\Controller\\RequestTimeController' => __DIR__ . '/..' . '/../lib/Controller/RequestTimeController.php',
diff --git a/apps/workflowengine/lib/AppInfo/Application.php b/apps/workflowengine/lib/AppInfo/Application.php
index 4f02b885adebb..ba1474fe636e9 100644
--- a/apps/workflowengine/lib/AppInfo/Application.php
+++ b/apps/workflowengine/lib/AppInfo/Application.php
@@ -42,15 +42,25 @@ public function register(IRegistrationContext $context): void {
#[\Override]
public function boot(IBootContext $context): void {
+ $context->injectFn(Closure::fromCallable([$this, 'emitRuntimeEvent']));
$context->injectFn(Closure::fromCallable([$this, 'registerRuleListeners']));
}
+ private function emitRuntimeEvent(IEventDispatcher $dispatcher, ContainerInterface $container): void {
+ /** @var Manager $manager */
+ $manager = $container->get(Manager::class);
+ $manager->reloadRuntimeOperations();
+ }
+
private function registerRuleListeners(IEventDispatcher $dispatcher,
ContainerInterface $container,
LoggerInterface $logger): void {
/** @var Manager $manager */
$manager = $container->get(Manager::class);
- $configuredEvents = $manager->getAllConfiguredEvents();
+ $configuredEvents = array_merge_recursive(
+ $manager->getAllConfiguredEvents(),
+ $manager->getAllConfiguredRuntimeEvents(),
+ );
foreach ($configuredEvents as $operationClass => $events) {
foreach ($events as $entityClass => $eventNames) {
diff --git a/apps/workflowengine/lib/Command/Runtime.php b/apps/workflowengine/lib/Command/Runtime.php
new file mode 100644
index 0000000000000..1e6008a2d9f8c
--- /dev/null
+++ b/apps/workflowengine/lib/Command/Runtime.php
@@ -0,0 +1,103 @@
+setName('workflows:runtime:list')
+ ->setDescription('Lists configured runtime workflows')
+ // need to add an optional filtering by app
+ ->addArgument(
+ 'appId',
+ InputArgument::OPTIONAL,
+ 'Filter runtime workflows by appId',
+ null
+ )
+ ->addArgument(
+ 'scope',
+ InputArgument::OPTIONAL,
+ 'Lists workflows for "admin", "user"',
+ 'admin'
+ )
+ ->addArgument(
+ 'userId',
+ InputArgument::OPTIONAL,
+ 'User ID used for user scope and session',
+ null
+ );
+ }
+
+ protected function mappedScope(string $scope): int {
+ return match($scope) {
+ 'admin' => IManager::SCOPE_ADMIN,
+ 'user' => IManager::SCOPE_USER,
+ default => -1,
+ };
+ }
+
+ #[Override]
+ protected function execute(InputInterface $input, OutputInterface $output): int {
+ $appId = $input->getArgument('appId');
+ $userId = $input->getArgument('userId');
+
+ if ($userId !== null) {
+ $user = $this->userManager->get($userId);
+ if (is_null($user)) {
+ throw new NoUserException("user $userId not found");
+ }
+ $this->userSession->setUser($user);
+ $this->manager->reloadRuntimeOperations();
+ }
+
+ $opsByClass = $this->manager->getAllRuntimeOperations(
+ new ScopeContext(
+ $this->mappedScope($input->getArgument('scope')),
+ $input->getArgument('userId')
+ ),
+ $appId,
+ );
+
+ foreach ($opsByClass as &$operations) {
+ foreach ($operations as &$operation) {
+ $checks = $operation['checks'];
+ $appId = $operation['appId'];
+ $decodedChecks = json_decode($checks, true);
+ $operation['checks'] = $this->manager->getRuntimeChecks($decodedChecks, $appId);
+ }
+ unset($operation);
+ }
+ unset($operations);
+
+ $output->writeln(\json_encode($opsByClass, JSON_PRETTY_PRINT));
+ return 0;
+ }
+}
diff --git a/apps/workflowengine/lib/Manager.php b/apps/workflowengine/lib/Manager.php
index a23a1f55524a6..e0c4cdf100568 100644
--- a/apps/workflowengine/lib/Manager.php
+++ b/apps/workflowengine/lib/Manager.php
@@ -6,6 +6,7 @@
*/
namespace OCA\WorkflowEngine;
+use OC\WorkflowEngine\Events\RegisterRuntimeOperationsEvent;
use OCA\WorkflowEngine\Check\FileMimeType;
use OCA\WorkflowEngine\Check\FileName;
use OCA\WorkflowEngine\Check\FileSize;
@@ -19,6 +20,7 @@
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Service\Logger;
use OCA\WorkflowEngine\Service\RuleMatcher;
+use OCP\App\IAppManager;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Cache\CappedMemoryCache;
use OCP\DB\Exception;
@@ -45,14 +47,51 @@
/**
* @psalm-import-type WorkflowEngineCheck from ResponseDefinitions
* @psalm-import-type WorkflowEngineRule from ResponseDefinitions
+ *
+ * @psalm-type RuntimeOperation = array{
+ * id: string,
+ * class: class-string,
+ * name: string,
+ * checks: string,
+ * operation: string,
+ * entity: class-string,
+ * events: string,
+ * appId: string,
+ * runtime: true
+ * }
*/
class Manager implements IManager {
- /** @var array[] */
+ /** @var array>> */
protected array $operations = [];
/** @var array */
protected array $checks = [];
+ /** @var array> */
+ protected array $registeredRuntimeChecks = [];
+
+ /**
+ * @var array,
+ * name: string,
+ * checks: list,
+ * operation: string,
+ * entity: class-string,
+ * events: list,
+ * }>>
+ */
+ protected array $registeredRuntimeOperations = [];
+
+ /**
+ * @var array>
+ */
+ protected array $registeredRuntimeScopes = [];
+
/** @var IEntity[] */
protected array $registeredEntities = [];
@@ -77,6 +116,7 @@ public function __construct(
private readonly IEventDispatcher $dispatcher,
private readonly IAppConfig $appConfig,
private readonly ICacheFactory $cacheFactory,
+ private readonly IAppManager $appManager,
) {
$this->operationsByScope = new CappedMemoryCache(64);
}
@@ -92,7 +132,7 @@ public function getRuleMatcher(): IRuleMatcher {
);
}
- public function getAllConfiguredEvents() {
+ public function getAllConfiguredEvents(): array {
$cache = $this->cacheFactory->createDistributed('flow');
$cached = $cache->get('events');
if ($cached !== null) {
@@ -127,6 +167,30 @@ public function getAllConfiguredEvents() {
return $operations;
}
+ /**
+ * Returns the events configured by runtime operations, in the same structure as getAllConfiguredEvents().
+ *
+ * @return array, array, list>>
+ */
+ public function getAllConfiguredRuntimeEvents(): array {
+ $eventsByOperationAndEntity = [];
+ foreach ($this->registeredRuntimeOperations as $appOperations) {
+ foreach ($appOperations as $operation) {
+ $operationClass = $operation['class'];
+ $entityClass = $operation['entity'];
+ $eventsByOperationAndEntity[$operationClass] ??= [];
+ $eventsByOperationAndEntity[$operationClass][$entityClass] ??= [];
+ /** @var list $events */
+ $events = array_unique(
+ array_merge($eventsByOperationAndEntity[$operationClass][$entityClass], $operation['events'])
+ );
+ $eventsByOperationAndEntity[$operationClass][$entityClass] = $events;
+ }
+ }
+
+ return $eventsByOperationAndEntity;
+ }
+
/**
* @param class-string $operationClass
* @return ScopeContext[]
@@ -168,6 +232,33 @@ public function getAllConfiguredScopesForOperation(string $operationClass): arra
return $this->scopesByOperation[$operationClass];
}
+ /**
+ * Gets configured scopes for operations registered at runtime.
+ *
+ * @param class-string $operationClass
+ * @return ScopeContext[]
+ */
+ public function getAllConfiguredScopesForRuntimeOperation(string $operationClass): array {
+ $scopes = [];
+ foreach ($this->registeredRuntimeOperations as $appId => $appOperations) {
+ foreach ($appOperations as $operationId => $operation) {
+ if ($operation['class'] !== $operationClass) {
+ continue;
+ }
+
+ $scopeInfo = $this->registeredRuntimeScopes[$appId][$operationId] ?? null;
+ if ($scopeInfo === null) {
+ continue;
+ }
+
+ $scope = new ScopeContext($scopeInfo['type'], $scopeInfo['value']);
+ $scopes[$scope->getHash()] = $scope;
+ }
+ }
+
+ return $scopes;
+ }
+
public function getAllOperations(ScopeContext $scopeContext): array {
if (isset($this->operations[$scopeContext->getHash()])) {
return $this->operations[$scopeContext->getHash()];
@@ -264,6 +355,122 @@ protected function insertOperation(
return $query->getLastInsertId();
}
+ /**
+ * Get all operations registered at runtime
+ *
+ * @param ScopeContext $scopeContext
+ * @return array, list>
+ */
+ public function getAllRuntimeOperations(ScopeContext $scopeContext, ?string $appFilter = null): array {
+ $result = [];
+ foreach ($this->registeredRuntimeOperations as $appId => $appOperations) {
+ if ($appFilter !== null && $appId !== $appFilter) {
+ continue;
+ }
+
+ foreach ($appOperations as $operationId => $operation) {
+ // scope stored per-app per-operation in registeredRuntimeScopes
+ $scopeInfo = $this->registeredRuntimeScopes[$appId][$operationId] ?? null;
+ if ($scopeInfo === null) {
+ continue;
+ }
+ // filter by provided $scopeContext
+ if ((int)$scopeInfo['type'] !== $scopeContext->getScope()) {
+ continue;
+ }
+ if ($scopeContext->getScope() === IManager::SCOPE_USER && (string)$scopeInfo['value'] !== $scopeContext->getScopeId()) {
+ continue;
+ }
+
+ $encodedChecks = json_encode($operation['checks']);
+ $encodedEvents = json_encode($operation['events']);
+ $row = [
+ 'id' => $operationId, // string uniqid
+ 'class' => $operation['class'],
+ 'name' => $operation['name'],
+ // encode checks as JSON of hashes to be resolved later
+ 'checks' => $encodedChecks !== false ? $encodedChecks : '',
+ 'operation' => $operation['operation'],
+ 'entity' => $operation['entity'],
+ 'events' => $encodedEvents !== false ? $encodedEvents : '',
+ 'appId' => $appId,
+ 'runtime' => true,
+ ];
+ $result[$operation['class']][] = $row;
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Return operations registered at runtime, which are not persisted in the DB nor shown in the UI.
+ *
+ * @param class-string $class
+ * @param ScopeContext $scopeContext
+ * @return list
+ */
+ public function getRuntimeOperations(string $class, ScopeContext $scopeContext): array {
+ $operations = $this->getAllRuntimeOperations($scopeContext);
+
+ return $operations[$class] ?? [];
+ }
+
+ /**
+ * @param string $appId
+ * @param class-string $class
+ * @param string $name
+ * @param list $checks
+ * @param string $operation
+ * @param class-string $entity
+ * @param list> $events
+ */
+ public function addRuntimeOperation(
+ string $appId,
+ string $class,
+ string $name,
+ array $checks,
+ string $operation,
+ ScopeContext $scope,
+ string $entity,
+ array $events,
+ ): void {
+ if (!$this->appManager->isEnabledForAnyone($appId)) {
+ throw new \InvalidArgumentException("App {$appId} is not enabled");
+ }
+
+ $this->validateOperation($class, $name, $checks, $operation, $scope, $entity, $events);
+
+ $checkHashes = [];
+ foreach ($checks as $check) {
+ $hash = md5($check['class'] . '::' . $check['operator'] . '::' . $check['value']);
+ $checkHashes[] = $hash;
+ $this->registeredRuntimeChecks[$appId] ??= [];
+ $this->registeredRuntimeChecks[$appId][$hash] ??= $check;
+ }
+
+ $operationId = uniqid($appId, true);
+ $runtimeOperation = [
+ 'id' => $operationId,
+ 'class' => $class,
+ 'name' => $name,
+ 'checks' => $checkHashes,
+ 'operation' => $operation,
+ 'entity' => $entity,
+ 'events' => $events,
+ ];
+ $this->registeredRuntimeOperations[$appId] ??= [];
+ $this->registeredRuntimeOperations[$appId][$operationId] ??= $runtimeOperation;
+
+ $runtimeScope = [
+ 'operationId' => $operationId,
+ 'type' => $scope->getScope(),
+ 'value' => $scope->getScopeId(),
+ ];
+ $this->registeredRuntimeScopes[$appId] ??= [];
+ $this->registeredRuntimeScopes[$appId][$operationId] ??= $runtimeScope;
+ }
+
/**
* @param string $class
* @param string $name
@@ -523,6 +730,22 @@ public function validateOperation(string $class, string $name, array $checks, st
}
}
+ /**
+ * @param list $checkHashes
+ * @param string $appId
+ * @return array checks indexed by their ID
+ */
+ public function getRuntimeChecks(array $checkHashes, string $appId): array {
+ $checks = [];
+ foreach ($checkHashes as $hash) {
+ if (!isset($this->registeredRuntimeChecks[$appId][$hash])) {
+ throw new \UnexpectedValueException("Runtime check {$hash} for app {$appId} missing");
+ }
+ $checks[$hash] = $this->registeredRuntimeChecks[$appId][$hash];
+ }
+ return $checks;
+ }
+
/**
* @param int[] $checkIds
* @return array
@@ -665,6 +888,14 @@ public function registerOperation(IOperation $operator): void {
$this->registeredOperators[get_class($operator)] = $operator;
}
+ public function reloadRuntimeOperations(): void {
+ $this->registeredRuntimeOperations = [];
+ $this->registeredRuntimeScopes = [];
+ $this->registeredRuntimeChecks = [];
+
+ $this->dispatcher->dispatchTyped(new RegisterRuntimeOperationsEvent($this));
+ }
+
#[\Override]
public function registerCheck(ICheck $check): void {
$this->registeredChecks[get_class($check)] = $check;
diff --git a/apps/workflowengine/lib/Service/RuleMatcher.php b/apps/workflowengine/lib/Service/RuleMatcher.php
index b92bfdcff7b4b..486d6196c868b 100644
--- a/apps/workflowengine/lib/Service/RuleMatcher.php
+++ b/apps/workflowengine/lib/Service/RuleMatcher.php
@@ -112,10 +112,12 @@ public function getFlows(bool $returnFirstMatchingOperationOnly = true): array {
$operations = [];
foreach ($scopes as $scope) {
$operations = array_merge($operations, $this->manager->getOperations($class, $scope));
+ $operations = array_merge($operations, $this->manager->getRuntimeOperations($class, $scope));
}
if ($this->entity instanceof IEntity) {
- $additionalScopes = $this->manager->getAllConfiguredScopesForOperation($class);
+ $additionalScopes = $this->manager->getAllConfiguredScopesForOperation($class)
+ + $this->manager->getAllConfiguredScopesForRuntimeOperation($class);
foreach ($additionalScopes as $hash => $scopeCandidate) {
if ($scopeCandidate->getScope() !== IManager::SCOPE_USER || in_array($scopeCandidate, $scopes)) {
continue;
@@ -128,6 +130,7 @@ public function getFlows(bool $returnFirstMatchingOperationOnly = true): array {
->setOperation($this->operation);
$this->logger->logScopeExpansion($ctx);
$operations = array_merge($operations, $this->manager->getOperations($class, $scopeCandidate));
+ $operations = array_merge($operations, $this->manager->getRuntimeOperations($class, $scopeCandidate));
}
}
}
@@ -140,7 +143,11 @@ public function getFlows(bool $returnFirstMatchingOperationOnly = true): array {
}
$checkIds = json_decode($operation['checks'], true);
- $checks = $this->manager->getChecks($checkIds);
+ if (($operation['runtime'] ?? null) === true) {
+ $checks = $this->manager->getRuntimeChecks($checkIds, $operation['appId']);
+ } else {
+ $checks = $this->manager->getChecks($checkIds);
+ }
foreach ($checks as $check) {
if (!$this->check($check)) {
diff --git a/apps/workflowengine/tests/ManagerTest.php b/apps/workflowengine/tests/ManagerTest.php
index e5b32cd78f403..411475c6dca4e 100644
--- a/apps/workflowengine/tests/ManagerTest.php
+++ b/apps/workflowengine/tests/ManagerTest.php
@@ -10,6 +10,7 @@
use OCA\WorkflowEngine\Entity\File;
use OCA\WorkflowEngine\Helper\ScopeContext;
use OCA\WorkflowEngine\Manager;
+use OCP\App\IAppManager;
use OCP\AppFramework\QueryException;
use OCP\AppFramework\Services\IAppConfig;
use OCP\EventDispatcher\Event;
@@ -33,6 +34,7 @@
use OCP\WorkflowEngine\IManager;
use OCP\WorkflowEngine\IOperation;
use OCP\WorkflowEngine\IRuleMatcher;
+use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
@@ -84,6 +86,7 @@ class ManagerTest extends TestCase {
protected IEventDispatcher&MockObject $dispatcher;
protected IAppConfig&MockObject $config;
protected ICacheFactory&MockObject $cacheFactory;
+ protected IAppManager&MockObject $appManager;
protected function setUp(): void {
parent::setUp();
@@ -101,6 +104,7 @@ protected function setUp(): void {
$this->dispatcher = $this->createMock(IEventDispatcher::class);
$this->config = $this->createMock(IAppConfig::class);
$this->cacheFactory = $this->createMock(ICacheFactory::class);
+ $this->appManager = $this->createMock(IAppManager::class);
$this->manager = new Manager(
$this->db,
@@ -110,7 +114,8 @@ protected function setUp(): void {
$this->session,
$this->dispatcher,
$this->config,
- $this->cacheFactory
+ $this->cacheFactory,
+ $this->appManager,
);
$this->clearTables();
}
@@ -737,6 +742,125 @@ public function testValidateOperationDataLengthError(): void {
}
}
+ private function prepareContainerForRuntimeOperation(): void {
+ $operationMock = $this->createMock(IOperation::class);
+ $operationMock->method('isAvailableForScope')->willReturn(true);
+
+ $myEventMock = $this->createMock(IEntityEvent::class);
+ $myEventMock->method('getEventName')->willReturn('MyEvent');
+ $otherEventMock = $this->createMock(IEntityEvent::class);
+ $otherEventMock->method('getEventName')->willReturn('OtherEvent');
+
+ $entityMock = $this->createMock(IEntity::class);
+ $entityMock->method('getEvents')->willReturn([$myEventMock, $otherEventMock]);
+
+ $checkMock = $this->createMock(ICheck::class);
+ $checkMock->method('supportedEntities')->willReturn([]);
+
+ $this->container->expects($this->any())
+ ->method('get')
+ ->willReturnCallback(fn ($class) => match ($class) {
+ IOperation::class => $operationMock,
+ IEntity::class => $entityMock,
+ ICheck::class => $checkMock,
+ default => $this->createMock($class),
+ });
+ }
+
+ public function testAddRuntimeOperationRejectsUnknownApp(): void {
+ $this->appManager->method('isEnabledForAnyone')->willReturn(false);
+ $this->expectException(\InvalidArgumentException::class);
+
+ $scope = $this->buildScope();
+ $check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
+ $this->manager->addRuntimeOperation('unknownapp', IOperation::class, 'Test', [$check], '', $scope, IEntity::class, ['MyEvent']);
+ }
+
+ public function testGetAllConfiguredRuntimeEventsEmpty(): void {
+ $this->assertSame([], $this->manager->getAllConfiguredRuntimeEvents());
+ }
+
+ public function testGetAllConfiguredRuntimeEvents(): void {
+ $this->appManager->method('isEnabledForAnyone')->willReturn(true);
+ $this->prepareContainerForRuntimeOperation();
+
+ $scope = $this->buildScope();
+ $check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
+
+ $this->manager->addRuntimeOperation('testapp', IOperation::class, 'Op1', [$check], '', $scope, IEntity::class, ['MyEvent']);
+ $this->manager->addRuntimeOperation('testapp', IOperation::class, 'Op2', [$check], '', $scope, IEntity::class, ['MyEvent', 'OtherEvent']);
+
+ $events = $this->manager->getAllConfiguredRuntimeEvents();
+
+ $this->assertArrayHasKey(IOperation::class, $events);
+ $this->assertArrayHasKey(IEntity::class, $events[IOperation::class]);
+ $eventNames = $events[IOperation::class][IEntity::class];
+ $this->assertContains('MyEvent', $eventNames);
+ $this->assertContains('OtherEvent', $eventNames);
+ $this->assertCount(2, $eventNames);
+ }
+
+ public function testRuntimeOperationScopeIsolation(): void {
+ $this->appManager->method('isEnabledForAnyone')->willReturn(true);
+ $this->prepareContainerForRuntimeOperation();
+
+ $adminScope = $this->buildScope();
+ $userScope = $this->buildScope('alice');
+ $check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'test'];
+
+ $this->manager->addRuntimeOperation('testapp', IOperation::class, 'AdminOp', [$check], '', $adminScope, IEntity::class, ['MyEvent']);
+ $this->manager->addRuntimeOperation('testapp', IOperation::class, 'UserOp', [$check], '', $userScope, IEntity::class, ['MyEvent']);
+
+ $adminOps = $this->manager->getRuntimeOperations(IOperation::class, $adminScope);
+ $userOps = $this->manager->getRuntimeOperations(IOperation::class, $userScope);
+ $this->assertCount(1, $adminOps);
+ $this->assertSame('AdminOp', $adminOps[0]['name']);
+ $this->assertCount(1, $userOps);
+ $this->assertSame('UserOp', $userOps[0]['name']);
+
+ $scopes = $this->manager->getAllConfiguredScopesForRuntimeOperation(IOperation::class);
+ $this->assertCount(2, $scopes);
+ $scopeTypes = array_map(fn ($s) => $s->getScope(), array_values($scopes));
+ $this->assertContains(IManager::SCOPE_ADMIN, $scopeTypes);
+ $this->assertContains(IManager::SCOPE_USER, $scopeTypes);
+ }
+
+ public static function dataGetRuntimeChecks(): array {
+ return [
+ 'single operation' => [1],
+ 'two operations with same check are deduplicated' => [2],
+ ];
+ }
+
+ #[DataProvider(methodName: 'dataGetRuntimeChecks')]
+ public function testGetRuntimeChecks(int $opCount): void {
+ $this->appManager->method('isEnabledForAnyone')->willReturn(true);
+ $this->prepareContainerForRuntimeOperation();
+
+ $scope = $this->buildScope();
+ $check = ['class' => ICheck::class, 'operator' => 'is', 'value' => 'testvalue'];
+
+ for ($i = 0; $i < $opCount; $i++) {
+ $this->manager->addRuntimeOperation('myapp', IOperation::class, "Op{$i}", [$check], '', $scope, IEntity::class, ['MyEvent']);
+ }
+
+ $ops = $this->manager->getRuntimeOperations(IOperation::class, $scope);
+ $this->assertCount($opCount, $ops);
+
+ $hash = md5(ICheck::class . '::is::testvalue');
+ $resolved = $this->manager->getRuntimeChecks([$hash], 'myapp');
+ $this->assertCount(1, $resolved);
+ $this->assertArrayHasKey($hash, $resolved);
+ $this->assertSame(ICheck::class, $resolved[$hash]['class']);
+ $this->assertSame('is', $resolved[$hash]['operator']);
+ $this->assertSame('testvalue', $resolved[$hash]['value']);
+ }
+
+ public function testGetRuntimeChecksThrowsForUnknownHash(): void {
+ $this->expectException(\UnexpectedValueException::class);
+ $this->manager->getRuntimeChecks(['unknownhash'], 'myapp');
+ }
+
public function testValidateOperationScopeNotAvailable(): void {
$check = [
'id' => 1,
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php
index 6d57f1e487842..f74781925d3d6 100644
--- a/lib/composer/composer/autoload_classmap.php
+++ b/lib/composer/composer/autoload_classmap.php
@@ -2268,6 +2268,7 @@
'OC\\User\\PartiallyDeletedUsersBackend' => $baseDir . '/lib/private/User/PartiallyDeletedUsersBackend.php',
'OC\\User\\Session' => $baseDir . '/lib/private/User/Session.php',
'OC\\User\\User' => $baseDir . '/lib/private/User/User.php',
+ 'OC\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => $baseDir . '/lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php',
'OC_App' => $baseDir . '/lib/private/legacy/OC_App.php',
'OC_Defaults' => $baseDir . '/lib/private/legacy/OC_Defaults.php',
'OC_Helper' => $baseDir . '/lib/private/legacy/OC_Helper.php',
diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php
index 564995dbfa757..3a9f722dbe5c9 100644
--- a/lib/composer/composer/autoload_static.php
+++ b/lib/composer/composer/autoload_static.php
@@ -2309,6 +2309,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2
'OC\\User\\PartiallyDeletedUsersBackend' => __DIR__ . '/../../..' . '/lib/private/User/PartiallyDeletedUsersBackend.php',
'OC\\User\\Session' => __DIR__ . '/../../..' . '/lib/private/User/Session.php',
'OC\\User\\User' => __DIR__ . '/../../..' . '/lib/private/User/User.php',
+ 'OC\\WorkflowEngine\\Events\\RegisterRuntimeOperationsEvent' => __DIR__ . '/../../..' . '/lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php',
'OC_App' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_App.php',
'OC_Defaults' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Defaults.php',
'OC_Helper' => __DIR__ . '/../../..' . '/lib/private/legacy/OC_Helper.php',
diff --git a/lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php b/lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php
new file mode 100644
index 0000000000000..0b918e2e549b8
--- /dev/null
+++ b/lib/private/WorkflowEngine/Events/RegisterRuntimeOperationsEvent.php
@@ -0,0 +1,28 @@
+manager;
+ }
+}