From ce83f736ba1c8b848dab2fc9e5a183755fe3d648 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 001/417] feat: add policy context Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyContext.php | 93 ++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 lib/Service/Policy/PolicyContext.php diff --git a/lib/Service/Policy/PolicyContext.php b/lib/Service/Policy/PolicyContext.php new file mode 100644 index 0000000000..4fb2c31c0c --- /dev/null +++ b/lib/Service/Policy/PolicyContext.php @@ -0,0 +1,93 @@ + */ + private array $groups = []; + /** @var list */ + private array $circles = []; + /** @var array|null */ + private ?array $activeContext = null; + /** @var array */ + private array $requestOverrides = []; + /** @var array */ + private array $actorCapabilities = []; + + public static function fromUserId(string $userId): self { + $context = new self(); + $context->setUserId($userId); + return $context; + } + + public function setUserId(?string $userId): self { + $this->userId = $userId; + return $this; + } + + public function getUserId(): ?string { + return $this->userId; + } + + /** @param list $groups */ + public function setGroups(array $groups): self { + $this->groups = $groups; + return $this; + } + + /** @return list */ + public function getGroups(): array { + return $this->groups; + } + + /** @param list $circles */ + public function setCircles(array $circles): self { + $this->circles = $circles; + return $this; + } + + /** @return list */ + public function getCircles(): array { + return $this->circles; + } + + /** @param array|null $activeContext */ + public function setActiveContext(?array $activeContext): self { + $this->activeContext = $activeContext; + return $this; + } + + /** @return array|null */ + public function getActiveContext(): ?array { + return $this->activeContext; + } + + /** @param array $requestOverrides */ + public function setRequestOverrides(array $requestOverrides): self { + $this->requestOverrides = $requestOverrides; + return $this; + } + + /** @return array */ + public function getRequestOverrides(): array { + return $this->requestOverrides; + } + + /** @param array $actorCapabilities */ + public function setActorCapabilities(array $actorCapabilities): self { + $this->actorCapabilities = $actorCapabilities; + return $this; + } + + /** @return array */ + public function getActorCapabilities(): array { + return $this->actorCapabilities; + } +} From c6f7d509b514df99c46d1e13aef929893e7dfe11 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 002/417] feat: add policy layer Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyLayer.php | 78 ++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 lib/Service/Policy/PolicyLayer.php diff --git a/lib/Service/Policy/PolicyLayer.php b/lib/Service/Policy/PolicyLayer.php new file mode 100644 index 0000000000..ea0137f610 --- /dev/null +++ b/lib/Service/Policy/PolicyLayer.php @@ -0,0 +1,78 @@ + */ + private array $allowedValues = []; + /** @var array */ + private array $notes = []; + + public function setScope(string $scope): self { + $this->scope = $scope; + return $this; + } + + public function getScope(): string { + return $this->scope; + } + + public function setValue(mixed $value): self { + $this->value = $value; + return $this; + } + + public function getValue(): mixed { + return $this->value; + } + + public function setAllowChildOverride(bool $allowChildOverride): self { + $this->allowChildOverride = $allowChildOverride; + return $this; + } + + public function isAllowChildOverride(): bool { + return $this->allowChildOverride; + } + + public function setVisibleToChild(bool $visibleToChild): self { + $this->visibleToChild = $visibleToChild; + return $this; + } + + public function isVisibleToChild(): bool { + return $this->visibleToChild; + } + + /** @param list $allowedValues */ + public function setAllowedValues(array $allowedValues): self { + $this->allowedValues = $allowedValues; + return $this; + } + + /** @return list */ + public function getAllowedValues(): array { + return $this->allowedValues; + } + + /** @param array $notes */ + public function setNotes(array $notes): self { + $this->notes = $notes; + return $this; + } + + /** @return array */ + public function getNotes(): array { + return $this->notes; + } +} From d5287ba96ed3eb5c657668ad1a2440a0c006814c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 003/417] feat: add resolved policy contract Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/ResolvedPolicy.php | 115 ++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 lib/Service/Policy/ResolvedPolicy.php diff --git a/lib/Service/Policy/ResolvedPolicy.php b/lib/Service/Policy/ResolvedPolicy.php new file mode 100644 index 0000000000..c117ac700b --- /dev/null +++ b/lib/Service/Policy/ResolvedPolicy.php @@ -0,0 +1,115 @@ + */ + private array $allowedValues = []; + private bool $canSaveAsUserDefault = false; + private bool $canUseAsRequestOverride = false; + private bool $preferenceWasCleared = false; + private ?string $blockedBy = null; + + public function setPolicyKey(string $policyKey): self { + $this->policyKey = $policyKey; + return $this; + } + + public function getPolicyKey(): string { + return $this->policyKey; + } + + public function setEffectiveValue(mixed $effectiveValue): self { + $this->effectiveValue = $effectiveValue; + return $this; + } + + public function getEffectiveValue(): mixed { + return $this->effectiveValue; + } + + public function setSourceScope(string $sourceScope): self { + $this->sourceScope = $sourceScope; + return $this; + } + + public function getSourceScope(): string { + return $this->sourceScope; + } + + public function setVisible(bool $visible): self { + $this->visible = $visible; + return $this; + } + + public function isVisible(): bool { + return $this->visible; + } + + public function setEditableByCurrentActor(bool $editableByCurrentActor): self { + $this->editableByCurrentActor = $editableByCurrentActor; + return $this; + } + + public function isEditableByCurrentActor(): bool { + return $this->editableByCurrentActor; + } + + /** @param list $allowedValues */ + public function setAllowedValues(array $allowedValues): self { + $this->allowedValues = $allowedValues; + return $this; + } + + /** @return list */ + public function getAllowedValues(): array { + return $this->allowedValues; + } + + public function setCanSaveAsUserDefault(bool $canSaveAsUserDefault): self { + $this->canSaveAsUserDefault = $canSaveAsUserDefault; + return $this; + } + + public function canSaveAsUserDefault(): bool { + return $this->canSaveAsUserDefault; + } + + public function setCanUseAsRequestOverride(bool $canUseAsRequestOverride): self { + $this->canUseAsRequestOverride = $canUseAsRequestOverride; + return $this; + } + + public function canUseAsRequestOverride(): bool { + return $this->canUseAsRequestOverride; + } + + public function setPreferenceWasCleared(bool $preferenceWasCleared): self { + $this->preferenceWasCleared = $preferenceWasCleared; + return $this; + } + + public function wasPreferenceCleared(): bool { + return $this->preferenceWasCleared; + } + + public function setBlockedBy(?string $blockedBy): self { + $this->blockedBy = $blockedBy; + return $this; + } + + public function getBlockedBy(): ?string { + return $this->blockedBy; + } +} From 952865edd96df4d1baedb4c4b1029f3b0a75e8a3 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 004/417] feat: add policy definition interface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/PolicyDefinitionInterface.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 lib/Service/Policy/PolicyDefinitionInterface.php diff --git a/lib/Service/Policy/PolicyDefinitionInterface.php b/lib/Service/Policy/PolicyDefinitionInterface.php new file mode 100644 index 0000000000..fc74a9842c --- /dev/null +++ b/lib/Service/Policy/PolicyDefinitionInterface.php @@ -0,0 +1,22 @@ + */ + public function allowedValues(PolicyContext $context): array; + + public function defaultSystemValue(): mixed; +} From 0b6c4cf130b43b2427672518d3fb7a3c848c0b05 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 005/417] feat: add policy resolver interface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyResolverInterface.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 lib/Service/Policy/PolicyResolverInterface.php diff --git a/lib/Service/Policy/PolicyResolverInterface.php b/lib/Service/Policy/PolicyResolverInterface.php new file mode 100644 index 0000000000..f47bff453d --- /dev/null +++ b/lib/Service/Policy/PolicyResolverInterface.php @@ -0,0 +1,18 @@ + $policyKeys + * @return array + */ + public function resolveMany(array $policyKeys, PolicyContext $context): array; +} From d59a5e8a5ffc39b85a3560e5abfdc7e1fed87e0c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:39 -0300 Subject: [PATCH 006/417] feat: add policy source interface Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicySourceInterface.php | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 lib/Service/Policy/PolicySourceInterface.php diff --git a/lib/Service/Policy/PolicySourceInterface.php b/lib/Service/Policy/PolicySourceInterface.php new file mode 100644 index 0000000000..e1e5ba00df --- /dev/null +++ b/lib/Service/Policy/PolicySourceInterface.php @@ -0,0 +1,25 @@ + */ + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array; + + /** @return list */ + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array; + + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function clearUserPreference(string $policyKey, PolicyContext $context): void; +} From 13fcc21f29ea35ee4582b9fde0029cfc3004c416 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 007/417] feat: add default policy resolver Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/DefaultPolicyResolver.php | 174 +++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 lib/Service/Policy/DefaultPolicyResolver.php diff --git a/lib/Service/Policy/DefaultPolicyResolver.php b/lib/Service/Policy/DefaultPolicyResolver.php new file mode 100644 index 0000000000..83d935f5f3 --- /dev/null +++ b/lib/Service/Policy/DefaultPolicyResolver.php @@ -0,0 +1,174 @@ + */ + private array $definitions = []; + + /** @param iterable $definitions */ + public function __construct( + private PolicySourceInterface $source, + iterable $definitions, + ) { + foreach ($definitions as $definition) { + $this->definitions[$definition->key()] = $definition; + } + } + + #[\Override] + public function resolve(string $policyKey, PolicyContext $context): ResolvedPolicy { + $definition = $this->definitions[$policyKey] ?? null; + if ($definition === null) { + throw new \InvalidArgumentException(sprintf('Unknown policy key: %s', $policyKey)); + } + + $resolved = (new ResolvedPolicy()) + ->setPolicyKey($policyKey) + ->setAllowedValues($definition->allowedValues($context)); + + $systemLayer = $this->source->loadSystemPolicy($policyKey); + $groupLayers = $this->source->loadGroupPolicies($policyKey, $context); + $circleLayers = $this->source->loadCirclePolicies($policyKey, $context); + + $currentValue = $definition->defaultSystemValue(); + $currentSourceScope = 'system'; + $currentBlockedBy = null; + $canOverrideBelow = false; + $visible = true; + + if ($systemLayer !== null) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $systemLayer, + $currentValue, + $currentSourceScope, + true, + $visible, + ); + } + + foreach (array_merge($groupLayers, $circleLayers) as $layer) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $layer, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + + $userPreference = $this->source->loadUserPreference($policyKey, $context); + if ($userPreference !== null) { + if ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible)) { + $currentValue = $definition->normalizeValue($userPreference->getValue()); + $definition->validateValue($currentValue); + $currentSourceScope = $userPreference->getScope(); + } else { + $this->source->clearUserPreference($policyKey, $context); + $currentBlockedBy = $currentSourceScope; + $resolved->setPreferenceWasCleared(true); + } + } + + $requestOverride = $this->source->loadRequestOverride($policyKey, $context); + if ($requestOverride !== null) { + if ($this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible)) { + $currentValue = $definition->normalizeValue($requestOverride->getValue()); + $definition->validateValue($currentValue); + $currentSourceScope = $requestOverride->getScope(); + } elseif ($currentBlockedBy === null) { + $currentBlockedBy = $currentSourceScope; + } + } + + $resolved + ->setEffectiveValue($currentValue) + ->setSourceScope($currentSourceScope) + ->setVisible($visible) + ->setEditableByCurrentActor($visible && $canOverrideBelow) + ->setCanSaveAsUserDefault($visible && $canOverrideBelow) + ->setCanUseAsRequestOverride($visible && $canOverrideBelow) + ->setBlockedBy($currentBlockedBy); + + return $resolved; + } + + #[\Override] + public function resolveMany(array $policyKeys, PolicyContext $context): array { + $resolved = []; + foreach ($policyKeys as $policyKey) { + $resolved[$policyKey] = $this->resolve($policyKey, $context); + } + return $resolved; + } + + private function applyLayer( + PolicyDefinitionInterface $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + mixed $currentValue, + string $currentSourceScope, + bool $canOverrideBelow, + bool $visible, + ): array { + $visible = $visible && $layer->isVisibleToChild(); + $resolved->setAllowedValues($this->mergeAllowedValues($resolved->getAllowedValues(), $layer->getAllowedValues())); + + if ($layer->getValue() !== null && ($currentSourceScope === 'system' || $canOverrideBelow)) { + $currentValue = $definition->normalizeValue($layer->getValue()); + $definition->validateValue($currentValue); + $currentSourceScope = $layer->getScope(); + } + + $canOverrideBelow = $layer->isAllowChildOverride(); + + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + + private function canApplyLowerLayer( + PolicyDefinitionInterface $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + bool $canOverrideBelow, + bool $visible, + ): bool { + if (!$visible || !$canOverrideBelow || $layer->getValue() === null) { + return false; + } + + $value = $definition->normalizeValue($layer->getValue()); + $allowedValues = $resolved->getAllowedValues(); + if ($allowedValues !== [] && !in_array($value, $allowedValues, true)) { + return false; + } + + $definition->validateValue($value); + return true; + } + + /** @param list $currentAllowedValues + * @param list $layerAllowedValues + * @return list + */ + private function mergeAllowedValues(array $currentAllowedValues, array $layerAllowedValues): array { + if ($layerAllowedValues === []) { + return $currentAllowedValues; + } + + if ($currentAllowedValues === []) { + return $layerAllowedValues; + } + + return array_values(array_intersect($currentAllowedValues, $layerAllowedValues)); + } +} From fdb669a5829597c08d0a4ac89e8922eb2f2317e5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 008/417] feat: add signature flow policy definition Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/SignatureFlowPolicyDefinition.php | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/Service/Policy/SignatureFlowPolicyDefinition.php diff --git a/lib/Service/Policy/SignatureFlowPolicyDefinition.php b/lib/Service/Policy/SignatureFlowPolicyDefinition.php new file mode 100644 index 0000000000..de56d7c86c --- /dev/null +++ b/lib/Service/Policy/SignatureFlowPolicyDefinition.php @@ -0,0 +1,52 @@ +value; + } + + if ($rawValue instanceof SignatureFlow) { + return $rawValue->value; + } + + return $rawValue; + } + + #[\Override] + public function validateValue(mixed $value): void { + if (!is_string($value) || !in_array($value, $this->allowedValues(new PolicyContext()), true)) { + throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key())); + } + } + + #[\Override] + public function allowedValues(PolicyContext $context): array { + return [ + SignatureFlow::NONE->value, + SignatureFlow::PARALLEL->value, + SignatureFlow::ORDERED_NUMERIC->value, + ]; + } + + #[\Override] + public function defaultSystemValue(): mixed { + return SignatureFlow::NONE->value; + } +} From 2f93b029276a7194794b35082b9109b9d7a23056 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 009/417] feat: add permission set entity Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/PermissionSet.php | 108 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 lib/Db/PermissionSet.php diff --git a/lib/Db/PermissionSet.php b/lib/Db/PermissionSet.php new file mode 100644 index 0000000000..e5e25c66a9 --- /dev/null +++ b/lib/Db/PermissionSet.php @@ -0,0 +1,108 @@ +addType('id', Types::INTEGER); + $this->addType('name', Types::STRING); + $this->addType('description', Types::TEXT); + $this->addType('scopeType', Types::STRING); + $this->addType('enabled', Types::SMALLINT); + $this->addType('priority', Types::SMALLINT); + $this->addType('policyJson', Types::TEXT); + $this->addType('createdAt', Types::DATETIME); + $this->addType('updatedAt', Types::DATETIME); + } + + public function isEnabled(): bool { + return $this->enabled === 1; + } + + public function setEnabled(bool $enabled): void { + $this->setter('enabled', [$enabled ? 1 : 0]); + } + + /** + * @param array $policyJson + */ + public function setPolicyJson(array $policyJson): void { + $this->setter('policyJson', [json_encode($policyJson, JSON_THROW_ON_ERROR)]); + } + + /** + * @return array + */ + public function getPolicyJson(): array { + $decoded = json_decode($this->policyJson, true); + return is_array($decoded) ? $decoded : []; + } + + /** + * @param \DateTime|string $createdAt + */ + public function setCreatedAt($createdAt): void { + if (!$createdAt instanceof \DateTime) { + $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC')); + } + $this->createdAt = $createdAt; + $this->markFieldUpdated('createdAt'); + } + + public function getCreatedAt(): ?\DateTime { + return $this->createdAt; + } + + /** + * @param \DateTime|string $updatedAt + */ + public function setUpdatedAt($updatedAt): void { + if (!$updatedAt instanceof \DateTime) { + $updatedAt = new \DateTime($updatedAt, new \DateTimeZone('UTC')); + } + $this->updatedAt = $updatedAt; + $this->markFieldUpdated('updatedAt'); + } + + public function getUpdatedAt(): ?\DateTime { + return $this->updatedAt; + } +} From eb1cc05cfc5a66009501c6b686a1a60cbe873c1e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 010/417] feat: add permission set binding entity Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/PermissionSetBinding.php | 52 +++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 lib/Db/PermissionSetBinding.php diff --git a/lib/Db/PermissionSetBinding.php b/lib/Db/PermissionSetBinding.php new file mode 100644 index 0000000000..f760af1b46 --- /dev/null +++ b/lib/Db/PermissionSetBinding.php @@ -0,0 +1,52 @@ +addType('id', Types::INTEGER); + $this->addType('permissionSetId', Types::INTEGER); + $this->addType('targetType', Types::STRING); + $this->addType('targetId', Types::STRING); + $this->addType('createdAt', Types::DATETIME); + } + + /** + * @param \DateTime|string $createdAt + */ + public function setCreatedAt($createdAt): void { + if (!$createdAt instanceof \DateTime) { + $createdAt = new \DateTime($createdAt, new \DateTimeZone('UTC')); + } + $this->createdAt = $createdAt; + $this->markFieldUpdated('createdAt'); + } + + public function getCreatedAt(): ?\DateTime { + return $this->createdAt; + } +} From 1397e8f8f4dedc033aa3f1273ee63ff3772c5a22 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 011/417] feat: add permission set mapper Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/PermissionSetMapper.php | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 lib/Db/PermissionSetMapper.php diff --git a/lib/Db/PermissionSetMapper.php b/lib/Db/PermissionSetMapper.php new file mode 100644 index 0000000000..cdd07269a2 --- /dev/null +++ b/lib/Db/PermissionSetMapper.php @@ -0,0 +1,43 @@ + + */ +class PermissionSetMapper extends CachedQBMapper { + public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) { + parent::__construct($db, $cacheFactory, 'libresign_permission_set'); + } + + /** + * @throws DoesNotExistException + */ + public function getById(int $id): PermissionSet { + $cached = $this->cacheGet('id:' . $id); + if ($cached instanceof PermissionSet) { + return $cached; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + /** @var PermissionSet */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } +} From c25fb4996ed35753c81110b55f514a252ea20b70 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:40 -0300 Subject: [PATCH 012/417] feat: add permission set binding mapper Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Db/PermissionSetBindingMapper.php | 59 +++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 lib/Db/PermissionSetBindingMapper.php diff --git a/lib/Db/PermissionSetBindingMapper.php b/lib/Db/PermissionSetBindingMapper.php new file mode 100644 index 0000000000..ff2ad572db --- /dev/null +++ b/lib/Db/PermissionSetBindingMapper.php @@ -0,0 +1,59 @@ + + */ +class PermissionSetBindingMapper extends CachedQBMapper { + public function __construct(IDBConnection $db, ICacheFactory $cacheFactory) { + parent::__construct($db, $cacheFactory, 'libresign_permission_set_binding'); + } + + /** + * @throws DoesNotExistException + */ + public function getById(int $id): PermissionSetBinding { + $cached = $this->cacheGet('id:' . $id); + if ($cached instanceof PermissionSetBinding) { + return $cached; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT))); + + /** @var PermissionSetBinding */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } + + /** + * @throws DoesNotExistException + */ + public function getByTarget(string $targetType, string $targetId): PermissionSetBinding { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))) + ->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId))); + + /** @var PermissionSetBinding */ + $entity = $this->findEntity($qb); + $this->cacheEntity($entity); + return $entity; + } +} From 147d59aea7a150cfbf0836b56536d8d7db6547b2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:41 -0300 Subject: [PATCH 013/417] feat: add permission set migration Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Version18000Date20260317000000.php | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 lib/Migration/Version18000Date20260317000000.php diff --git a/lib/Migration/Version18000Date20260317000000.php b/lib/Migration/Version18000Date20260317000000.php new file mode 100644 index 0000000000..fd6fde84c3 --- /dev/null +++ b/lib/Migration/Version18000Date20260317000000.php @@ -0,0 +1,98 @@ +hasTable('libresign_permission_set')) { + $table = $schema->createTable('libresign_permission_set'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('description', Types::TEXT, [ + 'notnull' => false, + ]); + $table->addColumn('scope_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('enabled', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 1, + ]); + $table->addColumn('priority', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + $table->addColumn('policy_json', Types::TEXT, [ + 'notnull' => true, + 'default' => '{}', + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->addColumn('updated_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['scope_type'], 'ls_perm_set_scope_idx'); + } + + if (!$schema->hasTable('libresign_permission_set_binding')) { + $table = $schema->createTable('libresign_permission_set_binding'); + $table->addColumn('id', Types::INTEGER, [ + 'autoincrement' => true, + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('permission_set_id', Types::INTEGER, [ + 'notnull' => true, + 'unsigned' => true, + ]); + $table->addColumn('target_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $table->addColumn('target_id', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $table->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + $table->addIndex(['permission_set_id'], 'ls_perm_bind_set_idx'); + $table->addUniqueIndex(['target_type', 'target_id'], 'ls_perm_bind_target_uidx'); + $table->addForeignKeyConstraint('libresign_permission_set', ['permission_set_id'], ['id'], [ + 'onDelete' => 'CASCADE', + ]); + } + + return $schema; + } +} From b7c140931826429e0449453b301ee12046ece9fc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:41 -0300 Subject: [PATCH 014/417] feat: add signature flow policy source Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/SignatureFlowPolicySource.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 lib/Service/Policy/SignatureFlowPolicySource.php diff --git a/lib/Service/Policy/SignatureFlowPolicySource.php b/lib/Service/Policy/SignatureFlowPolicySource.php new file mode 100644 index 0000000000..480dec3c05 --- /dev/null +++ b/lib/Service/Policy/SignatureFlowPolicySource.php @@ -0,0 +1,126 @@ +appConfig->getAppValueString($policyKey, SignatureFlow::NONE->value); + + $layer = (new PolicyLayer()) + ->setScope('system') + ->setValue($value) + ->setVisibleToChild(true); + + if ($value === SignatureFlow::NONE->value) { + return $layer->setAllowChildOverride(true); + } + + return $layer + ->setAllowChildOverride(false) + ->setAllowedValues([$value]); + } + + #[\Override] + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array { + $groupIds = $this->resolveGroupIds($context); + $layers = []; + + foreach ($groupIds as $groupId) { + try { + $binding = $this->bindingMapper->getByTarget('group', $groupId); + $permissionSet = $this->permissionSetMapper->getById($binding->getPermissionSetId()); + $policyConfig = $permissionSet->getPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $layers[] = (new PolicyLayer()) + ->setScope('group') + ->setValue($policyConfig['defaultValue'] ?? null) + ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false)) + ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true)) + ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []); + } catch (DoesNotExistException) { + continue; + } + } + + return $layers; + } + + #[\Override] + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { + return []; + } + + #[\Override] + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + $value = $this->appConfig->getUserValue($userId, self::USER_PREFERENCE_KEY, ''); + if ($value === '') { + return null; + } + + return (new PolicyLayer()) + ->setScope('user') + ->setValue($value); + } + + #[\Override] + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer { + $requestOverrides = $context->getRequestOverrides(); + if (!array_key_exists($policyKey, $requestOverrides)) { + return null; + } + + return (new PolicyLayer()) + ->setScope('request') + ->setValue($requestOverrides[$policyKey]); + } + + #[\Override] + public function clearUserPreference(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $this->appConfig->deleteUserValue($userId, self::USER_PREFERENCE_KEY); + } + + /** @return list */ + private function resolveGroupIds(PolicyContext $context): array { + $activeContext = $context->getActiveContext(); + if (($activeContext['type'] ?? null) === 'group' && is_string($activeContext['id'] ?? null)) { + return [$activeContext['id']]; + } + + return $context->getGroups(); + } +} From 10b984a6790adc837431bd44b68cd6648903b4cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:41 -0300 Subject: [PATCH 015/417] feat: add signature flow policy service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/SignatureFlowPolicyService.php | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 lib/Service/Policy/SignatureFlowPolicyService.php diff --git a/lib/Service/Policy/SignatureFlowPolicyService.php b/lib/Service/Policy/SignatureFlowPolicyService.php new file mode 100644 index 0000000000..5e2cc82f7d --- /dev/null +++ b/lib/Service/Policy/SignatureFlowPolicyService.php @@ -0,0 +1,50 @@ +resolver = new DefaultPolicyResolver($this->source, [new SignatureFlowPolicyDefinition()]); + } + + /** @param array $requestOverrides */ + public function resolveForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + $context = new PolicyContext(); + $context->setRequestOverrides($requestOverrides); + + if ($activeContext !== null) { + $context->setActiveContext($activeContext); + } + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + $user = $this->userManager->get($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $this->resolver->resolve('signature_flow', $context); + } + + /** @param array $requestOverrides */ + public function resolveForUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolveForUserId($user?->getUID(), $requestOverrides, $activeContext); + } +} From 5184951a274c379c8340fa8ac7066454fc2041c8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:41 -0300 Subject: [PATCH 016/417] feat: resolve request signature flow via policy service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 48 ++++++++++++------------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 78320822ec..36119a1de4 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; @@ -27,6 +26,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,6 +67,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, + protected SignatureFlowPolicyService $signatureFlowPolicyService, ) { } @@ -381,37 +382,34 @@ public function saveFile(array $data): FileEntity { } private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); - $adminForcedConfig = $adminFlow !== SignatureFlow::NONE->value; + $resolvedPolicy = $this->signatureFlowPolicyService->resolveForUserId( + $file->getUserId(), + $this->getSignatureFlowRequestOverrides($data), + ); + $newFlow = SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue()); - if ($adminForcedConfig) { - $adminFlowEnum = SignatureFlow::from($adminFlow); - if ($file->getSignatureFlowEnum() !== $adminFlowEnum) { - $file->setSignatureFlowEnum($adminFlowEnum); - $this->fileService->update($file); - } - return; - } - - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $newFlow = SignatureFlow::from($data['signatureFlow']); - if ($file->getSignatureFlowEnum() !== $newFlow) { - $file->setSignatureFlowEnum($newFlow); - $this->fileService->update($file); - } + if ($file->getSignatureFlowEnum() !== $newFlow) { + $file->setSignatureFlowEnum($newFlow); + $this->fileService->update($file); } } private function setSignatureFlow(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); + $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; + $resolvedPolicy = $this->signatureFlowPolicyService->resolveForUser( + $user, + $this->getSignatureFlowRequestOverrides($data), + ); + $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + } - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $file->setSignatureFlowEnum(SignatureFlow::from($data['signatureFlow'])); - } elseif ($adminFlow !== SignatureFlow::NONE->value) { - $file->setSignatureFlowEnum(SignatureFlow::from($adminFlow)); - } else { - $file->setSignatureFlowEnum(SignatureFlow::NONE); + /** @return array */ + private function getSignatureFlowRequestOverrides(array $data): array { + if (!isset($data['signatureFlow']) || empty($data['signatureFlow'])) { + return []; } + + return ['signature_flow' => (string)$data['signatureFlow']]; } private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void { From 72327d503261f3283a9cc6eaae992ff4ab92e3b7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:41 -0300 Subject: [PATCH 017/417] test: add policy context tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/Policy/PolicyContextTest.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/PolicyContextTest.php diff --git a/tests/php/Unit/Service/Policy/PolicyContextTest.php b/tests/php/Unit/Service/Policy/PolicyContextTest.php new file mode 100644 index 0000000000..82077999ec --- /dev/null +++ b/tests/php/Unit/Service/Policy/PolicyContextTest.php @@ -0,0 +1,55 @@ +assertNull($context->getUserId()); + $this->assertSame([], $context->getGroups()); + $this->assertSame([], $context->getCircles()); + $this->assertNull($context->getActiveContext()); + $this->assertSame([], $context->getRequestOverrides()); + $this->assertSame([], $context->getActorCapabilities()); + } + + public function testSettersStoreValues(): void { + $context = new PolicyContext(); + $activeContext = [ + 'type' => 'group', + 'id' => 'finance', + ]; + + $context + ->setUserId('john') + ->setGroups(['finance', 'legal']) + ->setCircles(['board']) + ->setActiveContext($activeContext) + ->setRequestOverrides(['signature_flow' => 'parallel']) + ->setActorCapabilities(['canManageOrganizationPolicies' => true]); + + $this->assertSame('john', $context->getUserId()); + $this->assertSame(['finance', 'legal'], $context->getGroups()); + $this->assertSame(['board'], $context->getCircles()); + $this->assertSame($activeContext, $context->getActiveContext()); + $this->assertSame(['signature_flow' => 'parallel'], $context->getRequestOverrides()); + $this->assertSame(['canManageOrganizationPolicies' => true], $context->getActorCapabilities()); + } + + public function testFromUserIdCreatesContextWithUserId(): void { + $context = PolicyContext::fromUserId('john'); + + $this->assertSame('john', $context->getUserId()); + $this->assertSame([], $context->getGroups()); + } +} From e5ef449ac05b24a417b91ff2785c83461212f33a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:42 -0300 Subject: [PATCH 018/417] test: add policy layer tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/Policy/PolicyLayerTest.php | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/PolicyLayerTest.php diff --git a/tests/php/Unit/Service/Policy/PolicyLayerTest.php b/tests/php/Unit/Service/Policy/PolicyLayerTest.php new file mode 100644 index 0000000000..1ac22f7c53 --- /dev/null +++ b/tests/php/Unit/Service/Policy/PolicyLayerTest.php @@ -0,0 +1,43 @@ +assertSame('', $layer->getScope()); + $this->assertNull($layer->getValue()); + $this->assertFalse($layer->isAllowChildOverride()); + $this->assertTrue($layer->isVisibleToChild()); + $this->assertSame([], $layer->getAllowedValues()); + $this->assertSame([], $layer->getNotes()); + } + + public function testSettersStoreValues(): void { + $layer = new PolicyLayer(); + $layer + ->setScope('group') + ->setValue(['type' => 'ordered_numeric']) + ->setAllowChildOverride(true) + ->setVisibleToChild(false) + ->setAllowedValues([['type' => 'parallel'], ['type' => 'ordered_numeric']]) + ->setNotes(['reason' => 'organization-default']); + + $this->assertSame('group', $layer->getScope()); + $this->assertSame(['type' => 'ordered_numeric'], $layer->getValue()); + $this->assertTrue($layer->isAllowChildOverride()); + $this->assertFalse($layer->isVisibleToChild()); + $this->assertSame([['type' => 'parallel'], ['type' => 'ordered_numeric']], $layer->getAllowedValues()); + $this->assertSame(['reason' => 'organization-default'], $layer->getNotes()); + } +} From 203270719d7af94e190c8e30992a3c0b155d0bcb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:42 -0300 Subject: [PATCH 019/417] test: add resolved policy tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/Policy/ResolvedPolicyTest.php | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/ResolvedPolicyTest.php diff --git a/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php b/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php new file mode 100644 index 0000000000..462643c558 --- /dev/null +++ b/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php @@ -0,0 +1,55 @@ +assertSame('', $policy->getPolicyKey()); + $this->assertNull($policy->getEffectiveValue()); + $this->assertSame('', $policy->getSourceScope()); + $this->assertFalse($policy->isVisible()); + $this->assertFalse($policy->isEditableByCurrentActor()); + $this->assertSame([], $policy->getAllowedValues()); + $this->assertFalse($policy->canSaveAsUserDefault()); + $this->assertFalse($policy->canUseAsRequestOverride()); + $this->assertFalse($policy->wasPreferenceCleared()); + $this->assertNull($policy->getBlockedBy()); + } + + public function testSettersStoreValues(): void { + $policy = new ResolvedPolicy(); + $policy + ->setPolicyKey('signature_flow') + ->setEffectiveValue(['type' => 'parallel']) + ->setSourceScope('group') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues([['type' => 'parallel'], ['type' => 'ordered_numeric']]) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(true) + ->setPreferenceWasCleared(true) + ->setBlockedBy('system'); + + $this->assertSame('signature_flow', $policy->getPolicyKey()); + $this->assertSame(['type' => 'parallel'], $policy->getEffectiveValue()); + $this->assertSame('group', $policy->getSourceScope()); + $this->assertTrue($policy->isVisible()); + $this->assertTrue($policy->isEditableByCurrentActor()); + $this->assertSame([['type' => 'parallel'], ['type' => 'ordered_numeric']], $policy->getAllowedValues()); + $this->assertTrue($policy->canSaveAsUserDefault()); + $this->assertTrue($policy->canUseAsRequestOverride()); + $this->assertTrue($policy->wasPreferenceCleared()); + $this->assertSame('system', $policy->getBlockedBy()); + } +} From 514dc6063d8293b7b3801bd4587e5408fc6287be Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:42 -0300 Subject: [PATCH 020/417] test: add default policy resolver tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/DefaultPolicyResolverTest.php | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php diff --git a/tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php b/tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php new file mode 100644 index 0000000000..bc2ec329d8 --- /dev/null +++ b/tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php @@ -0,0 +1,174 @@ +resolve('signature_flow', new PolicyContext()); + + $this->assertSame('none', $resolved->getEffectiveValue()); + $this->assertSame('system', $resolved->getSourceScope()); + $this->assertSame(['none', 'parallel', 'ordered_numeric'], $resolved->getAllowedValues()); + $this->assertFalse($resolved->isEditableByCurrentActor()); + } + + public function testResolveAppliesGroupValueWhenSystemAllowsOverride(): void { + $source = new InMemoryPolicySource(); + $source->systemLayer = (new PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true); + $source->groupLayers = [ + (new PolicyLayer()) + ->setScope('group') + ->setValue('ordered_numeric') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel', 'ordered_numeric']), + ]; + + $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); + $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('group', $resolved->getSourceScope()); + $this->assertTrue($resolved->isEditableByCurrentActor()); + $this->assertTrue($resolved->canSaveAsUserDefault()); + $this->assertTrue($resolved->canUseAsRequestOverride()); + } + + public function testResolveClearsInvalidUserPreferenceWhenGroupBlocksOverride(): void { + $source = new InMemoryPolicySource(); + $source->systemLayer = (new PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true); + $source->groupLayers = [ + (new PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(false) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel']), + ]; + $source->userPreference = (new PolicyLayer()) + ->setScope('user') + ->setValue('ordered_numeric'); + + $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); + $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + + $this->assertSame('parallel', $resolved->getEffectiveValue()); + $this->assertSame('group', $resolved->getSourceScope()); + $this->assertTrue($resolved->wasPreferenceCleared()); + $this->assertFalse($resolved->canSaveAsUserDefault()); + $this->assertFalse($resolved->canUseAsRequestOverride()); + $this->assertSame('group', $resolved->getBlockedBy()); + $this->assertTrue($source->userPreferenceCleared); + } + + public function testResolveAppliesRequestOverrideWhenAllowed(): void { + $source = new InMemoryPolicySource(); + $source->systemLayer = (new PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true); + $source->groupLayers = [ + (new PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel', 'ordered_numeric']), + ]; + $source->requestOverride = (new PolicyLayer()) + ->setScope('request') + ->setValue('ordered_numeric'); + + $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); + $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('request', $resolved->getSourceScope()); + $this->assertTrue($resolved->canUseAsRequestOverride()); + $this->assertNull($resolved->getBlockedBy()); + } +} + +final class InMemoryPolicySource implements PolicySourceInterface { + public ?PolicyLayer $systemLayer = null; + /** @var list */ + public array $groupLayers = []; + public ?PolicyLayer $userPreference = null; + public ?PolicyLayer $requestOverride = null; + public bool $userPreferenceCleared = false; + + public function loadSystemPolicy(string $policyKey): ?PolicyLayer { + return $this->systemLayer; + } + + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array { + return $this->groupLayers; + } + + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { + return []; + } + + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer { + return $this->userPreference; + } + + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer { + return $this->requestOverride; + } + + public function clearUserPreference(string $policyKey, PolicyContext $context): void { + $this->userPreferenceCleared = true; + } +} + +final class TestPolicyDefinition implements PolicyDefinitionInterface { + public function key(): string { + return 'signature_flow'; + } + + public function normalizeValue(mixed $rawValue): mixed { + return $rawValue; + } + + public function validateValue(mixed $value): void { + if (!in_array($value, $this->allowedValues(new PolicyContext()), true)) { + throw new \InvalidArgumentException('Invalid value'); + } + } + + public function allowedValues(PolicyContext $context): array { + return ['none', 'parallel', 'ordered_numeric']; + } + + public function defaultSystemValue(): mixed { + return 'none'; + } +} From 1d0ac891731528a52826c226a67f949d4c7cd259 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:42 -0300 Subject: [PATCH 021/417] test: add signature flow policy definition tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignatureFlowPolicyDefinitionTest.php | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php b/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php new file mode 100644 index 0000000000..68da687fc1 --- /dev/null +++ b/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php @@ -0,0 +1,39 @@ +assertSame('signature_flow', $definition->key()); + $this->assertSame('none', $definition->defaultSystemValue()); + $this->assertSame(['none', 'parallel', 'ordered_numeric'], $definition->allowedValues(new PolicyContext())); + } + + public function testNormalizeValueConvertsNumericValues(): void { + $definition = new SignatureFlowPolicyDefinition(); + + $this->assertSame('none', $definition->normalizeValue(0)); + $this->assertSame('parallel', $definition->normalizeValue(1)); + $this->assertSame('ordered_numeric', $definition->normalizeValue(2)); + $this->assertSame('parallel', $definition->normalizeValue('parallel')); + } + + public function testValidateValueRejectsUnexpectedValue(): void { + $this->expectException(\InvalidArgumentException::class); + + $definition = new SignatureFlowPolicyDefinition(); + $definition->validateValue('invalid'); + } +} From 94bbddc31c0c44232ee9de32fa7e59ca7c6031b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:42 -0300 Subject: [PATCH 022/417] test: add permission set tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Db/PermissionSetTest.php | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 tests/php/Unit/Db/PermissionSetTest.php diff --git a/tests/php/Unit/Db/PermissionSetTest.php b/tests/php/Unit/Db/PermissionSetTest.php new file mode 100644 index 0000000000..cf0aacbf03 --- /dev/null +++ b/tests/php/Unit/Db/PermissionSetTest.php @@ -0,0 +1,78 @@ +assertTrue($permissionSet->isEnabled()); + $this->assertSame([], $permissionSet->getPolicyJson()); + } + + public function testSetEnabledStoresBooleanAsFlag(): void { + $permissionSet = new PermissionSet(); + + $permissionSet->setEnabled(false); + $this->assertFalse($permissionSet->isEnabled()); + + $permissionSet->setEnabled(true); + $this->assertTrue($permissionSet->isEnabled()); + } + + public function testSetCreatedAtAndUpdatedAtAcceptStrings(): void { + $permissionSet = new PermissionSet(); + + $permissionSet->setCreatedAt('2026-03-17 10:00:00'); + $permissionSet->setUpdatedAt('2026-03-17 11:00:00'); + + $this->assertInstanceOf(\DateTime::class, $permissionSet->getCreatedAt()); + $this->assertInstanceOf(\DateTime::class, $permissionSet->getUpdatedAt()); + $this->assertSame('2026-03-17 10:00:00', $permissionSet->getCreatedAt()->format('Y-m-d H:i:s')); + $this->assertSame('2026-03-17 11:00:00', $permissionSet->getUpdatedAt()->format('Y-m-d H:i:s')); + } + + public function testPolicyJsonStoresStructuredPolicyData(): void { + $permissionSet = new PermissionSet(); + $policyJson = [ + 'signature_flow' => [ + 'defaultValue' => ['type' => 'parallel'], + 'allowChildOverride' => true, + ], + ]; + + $permissionSet->setPolicyJson($policyJson); + + $this->assertSame($policyJson, $permissionSet->getPolicyJson()); + } + + public function testPolicyJsonIsDecodedWhenHydratingFromDatabaseRow(): void { + $permissionSet = PermissionSet::fromRow([ + 'id' => 7, + 'name' => 'finance', + 'description' => null, + 'scope_type' => 'organization', + 'enabled' => 1, + 'priority' => 10, + 'policy_json' => '{"signature_flow":{"defaultValue":"parallel","allowChildOverride":true}}', + 'created_at' => '2026-03-17 10:00:00', + 'updated_at' => '2026-03-17 11:00:00', + ]); + + $this->assertSame([ + 'signature_flow' => [ + 'defaultValue' => 'parallel', + 'allowChildOverride' => true, + ], + ], $permissionSet->getPolicyJson()); + } +} From 5e2cc13f038cfbf6abe2f9a565913a8eb5c78501 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:43 -0300 Subject: [PATCH 023/417] test: add permission set binding tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../php/Unit/Db/PermissionSetBindingTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/php/Unit/Db/PermissionSetBindingTest.php diff --git a/tests/php/Unit/Db/PermissionSetBindingTest.php b/tests/php/Unit/Db/PermissionSetBindingTest.php new file mode 100644 index 0000000000..bf03d01227 --- /dev/null +++ b/tests/php/Unit/Db/PermissionSetBindingTest.php @@ -0,0 +1,41 @@ +assertSame('', $binding->getTargetType()); + $this->assertSame('', $binding->getTargetId()); + } + + public function testSetCreatedAtAcceptsString(): void { + $binding = new PermissionSetBinding(); + + $binding->setCreatedAt('2026-03-17 12:00:00'); + + $this->assertInstanceOf(\DateTime::class, $binding->getCreatedAt()); + $this->assertSame('2026-03-17 12:00:00', $binding->getCreatedAt()->format('Y-m-d H:i:s')); + } + + public function testBindingStoresPermissionSetAndTarget(): void { + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId(42); + $binding->setTargetType('group'); + $binding->setTargetId('finance'); + + $this->assertSame(42, $binding->getPermissionSetId()); + $this->assertSame('group', $binding->getTargetType()); + $this->assertSame('finance', $binding->getTargetId()); + } +} From b66f1fc0e927d5a9e61cbae37fc1fd37793b8116 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:43 -0300 Subject: [PATCH 024/417] test: add signature flow policy source tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/SignatureFlowPolicySourceTest.php | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php b/tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php new file mode 100644 index 0000000000..2fc5e9b47b --- /dev/null +++ b/tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php @@ -0,0 +1,152 @@ +appConfig = $this->createMock(IAppConfig::class); + $this->permissionSetMapper = $this->createMock(PermissionSetMapper::class); + $this->bindingMapper = $this->createMock(PermissionSetBindingMapper::class); + } + + public function testLoadSystemPolicyReturnsForcedLayerWhenAppConfigIsSet(): void { + $this->appConfig + ->expects($this->once()) + ->method('getAppValueString') + ->with('signature_flow', 'none') + ->willReturn('ordered_numeric'); + + $source = $this->getSource(); + $layer = $source->loadSystemPolicy('signature_flow'); + + $this->assertNotNull($layer); + $this->assertSame('system', $layer->getScope()); + $this->assertSame('ordered_numeric', $layer->getValue()); + $this->assertFalse($layer->isAllowChildOverride()); + $this->assertSame(['ordered_numeric'], $layer->getAllowedValues()); + } + + public function testLoadSystemPolicyReturnsInheritableLayerWhenAppConfigIsNone(): void { + $this->appConfig + ->expects($this->once()) + ->method('getAppValueString') + ->with('signature_flow', 'none') + ->willReturn('none'); + + $source = $this->getSource(); + $layer = $source->loadSystemPolicy('signature_flow'); + + $this->assertNotNull($layer); + $this->assertSame('none', $layer->getValue()); + $this->assertTrue($layer->isAllowChildOverride()); + $this->assertSame([], $layer->getAllowedValues()); + } + + public function testLoadGroupPoliciesReturnsBoundPermissionSetForActiveGroup(): void { + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId(77); + $binding->setTargetType('group'); + $binding->setTargetId('finance'); + + $permissionSet = new PermissionSet(); + $permissionSet->setPolicyJson([ + 'signature_flow' => [ + 'defaultValue' => 'ordered_numeric', + 'allowChildOverride' => false, + 'visibleToChild' => true, + 'allowedValues' => ['ordered_numeric'], + ], + ]); + + $this->bindingMapper + ->expects($this->once()) + ->method('getByTarget') + ->with('group', 'finance') + ->willReturn($binding); + + $this->permissionSetMapper + ->expects($this->once()) + ->method('getById') + ->with(77) + ->willReturn($permissionSet); + + $context = PolicyContext::fromUserId('john') + ->setGroups(['finance']) + ->setActiveContext(['type' => 'group', 'id' => 'finance']); + + $source = $this->getSource(); + $layers = $source->loadGroupPolicies('signature_flow', $context); + + $this->assertCount(1, $layers); + $this->assertSame('group', $layers[0]->getScope()); + $this->assertSame('ordered_numeric', $layers[0]->getValue()); + $this->assertFalse($layers[0]->isAllowChildOverride()); + $this->assertSame(['ordered_numeric'], $layers[0]->getAllowedValues()); + } + + public function testLoadUserPreferenceReturnsLayerFromUserConfig(): void { + $this->appConfig + ->expects($this->once()) + ->method('getUserValue') + ->with('john', 'policy.signature_flow', '') + ->willReturn('parallel'); + + $source = $this->getSource(); + $layer = $source->loadUserPreference('signature_flow', PolicyContext::fromUserId('john')); + + $this->assertNotNull($layer); + $this->assertSame('user', $layer->getScope()); + $this->assertSame('parallel', $layer->getValue()); + } + + public function testClearUserPreferenceDeletesUserConfig(): void { + $this->appConfig + ->expects($this->once()) + ->method('deleteUserValue') + ->with('john', 'policy.signature_flow'); + + $source = $this->getSource(); + $source->clearUserPreference('signature_flow', PolicyContext::fromUserId('john')); + } + + public function testLoadRequestOverrideReturnsLayerFromContext(): void { + $source = $this->getSource(); + $context = PolicyContext::fromUserId('john') + ->setRequestOverrides(['signature_flow' => 'ordered_numeric']); + + $layer = $source->loadRequestOverride('signature_flow', $context); + + $this->assertNotNull($layer); + $this->assertSame('request', $layer->getScope()); + $this->assertSame('ordered_numeric', $layer->getValue()); + } + + private function getSource(): SignatureFlowPolicySource { + return new SignatureFlowPolicySource( + $this->appConfig, + $this->permissionSetMapper, + $this->bindingMapper, + ); + } +} From c159c993f9e2c8b5b8c689a1558861ab638e79bd Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:43 -0300 Subject: [PATCH 025/417] test: add signature flow policy service tests Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/SignatureFlowPolicyServiceTest.php | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php b/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php new file mode 100644 index 0000000000..f0f4718b81 --- /dev/null +++ b/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php @@ -0,0 +1,126 @@ +userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->source = $this->createMock(SignatureFlowPolicySource::class); + } + + public function testResolveForUserIdBuildsContextWithGroupsAndRequestOverride(): void { + $user = $this->createMock(IUser::class); + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('john') + ->willReturn($user); + + $this->groupManager + ->expects($this->once()) + ->method('getUserGroupIds') + ->with($user) + ->willReturn(['finance']); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true)); + + $this->source + ->method('loadGroupPolicies') + ->willReturn([(new \OCA\Libresign\Service\Policy\PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel', 'ordered_numeric'])]); + + $this->source + ->method('loadCirclePolicies') + ->willReturn([]); + + $this->source + ->method('loadUserPreference') + ->willReturn(null); + + $this->source + ->method('loadRequestOverride') + ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) + ->setScope('request') + ->setValue('ordered_numeric')); + + $service = new SignatureFlowPolicyService( + $this->userManager, + $this->groupManager, + $this->source, + ); + + $resolved = $service->resolveForUserId('john', ['signature_flow' => 'ordered_numeric']); + + $this->assertInstanceOf(ResolvedPolicy::class, $resolved); + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('request', $resolved->getSourceScope()); + } + + public function testResolveForUserIdWithoutUserFallsBackToSystem(): void { + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('ghost') + ->willReturn(null); + + $this->groupManager + ->expects($this->never()) + ->method('getUserGroupIds'); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) + ->setScope('system') + ->setValue('parallel') + ->setAllowChildOverride(false) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel'])); + + $this->source->method('loadGroupPolicies')->willReturn([]); + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source->method('loadUserPreference')->willReturn(null); + $this->source->method('loadRequestOverride')->willReturn(null); + + $service = new SignatureFlowPolicyService( + $this->userManager, + $this->groupManager, + $this->source, + ); + + $resolved = $service->resolveForUserId('ghost'); + + $this->assertSame('parallel', $resolved->getEffectiveValue()); + $this->assertSame('system', $resolved->getSourceScope()); + } +} From 28d74536618ad0779e9bdd5990ff7d67c8edaf7d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 22:22:43 -0300 Subject: [PATCH 026/417] test: cover request signature policy resolution Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/RequestSignatureServiceTest.php | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index 3cfa29968c..0b218e0764 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -17,6 +17,7 @@ use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; use OCA\Libresign\Helper\FileUploadHelper; @@ -30,6 +31,8 @@ use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\ResolvedPolicy; +use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignRequest\SignRequestService; @@ -77,6 +80,7 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private EnvelopeFileRelocator&MockObject $envelopeFileRelocator; private FileUploadHelper&MockObject $uploadHelper; private SignRequestService&MockObject $signRequestService; + private SignatureFlowPolicyService&MockObject $signatureFlowPolicyService; public function setUp(): void { parent::setUp(); @@ -111,6 +115,7 @@ public function setUp(): void { $this->envelopeFileRelocator = $this->createMock(EnvelopeFileRelocator::class); $this->uploadHelper = $this->createMock(FileUploadHelper::class); $this->signRequestService = $this->createMock(SignRequestService::class); + $this->signatureFlowPolicyService = $this->createMock(SignatureFlowPolicyService::class); } private function getService(array $methods = []): RequestSignatureService|MockObject { @@ -142,6 +147,7 @@ private function getService(array $methods = []): RequestSignatureService|MockOb $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, + $this->signatureFlowPolicyService, ]) ->onlyMethods($methods) ->getMock(); @@ -173,6 +179,7 @@ private function getService(array $methods = []): RequestSignatureService|MockOb $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, + $this->signatureFlowPolicyService, ); } @@ -477,6 +484,7 @@ public function testDeleteIdentifyMethodIfNotExitsKeepsMatchingIdentifyMethods() $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, + $this->signatureFlowPolicyService, ]) ->onlyMethods(['unassociateToUser']) ->getMock(); @@ -549,6 +557,7 @@ public function testDeleteIdentifyMethodIfNotExitsRemovesMissingIdentifyMethods( $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, + $this->signatureFlowPolicyService, ]) ->onlyMethods(['unassociateToUser']) ->getMock(); @@ -833,4 +842,127 @@ public function testSaveEnvelopeExtractsFileDescriptorFromNestedFilesArrayItems( $this->assertSame($envelope, $result['envelope']); $this->assertCount(2, $result['files']); } + + public function testSetSignatureFlowPrefersPayloadOverGlobalConfig(): void { + $file = new \OCA\Libresign\Db\File(); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUser') + ->with(null, ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); + + self::invokePrivate($this->getService(), 'setSignatureFlow', [ + $file, + ['signatureFlow' => SignatureFlow::PARALLEL->value], + ]); + + $this->assertSame(SignatureFlow::PARALLEL, $file->getSignatureFlowEnum()); + } + + public function testSetSignatureFlowUsesGlobalConfigWhenPayloadMissing(): void { + $file = new \OCA\Libresign\Db\File(); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUser') + ->with(null, []) + ->willReturn($this->createResolvedPolicy(SignatureFlow::ORDERED_NUMERIC->value)); + + self::invokePrivate($this->getService(), 'setSignatureFlow', [ + $file, + [], + ]); + + $this->assertSame(SignatureFlow::ORDERED_NUMERIC, $file->getSignatureFlowEnum()); + } + + public function testSetSignatureFlowDefaultsToNoneWithoutPayloadOrGlobalConfig(): void { + $file = new \OCA\Libresign\Db\File(); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUser') + ->with(null, []) + ->willReturn($this->createResolvedPolicy(SignatureFlow::NONE->value)); + + self::invokePrivate($this->getService(), 'setSignatureFlow', [ + $file, + [], + ]); + + $this->assertSame(SignatureFlow::NONE, $file->getSignatureFlowEnum()); + } + + public function testUpdateSignatureFlowIfAllowedForcesGlobalConfigOverFileValue(): void { + $file = new \OCA\Libresign\Db\File(); + $file->setUserId('john'); + $file->setSignatureFlowEnum(SignatureFlow::PARALLEL); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUserId') + ->with('john', ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->willReturn($this->createResolvedPolicy(SignatureFlow::ORDERED_NUMERIC->value)); + + $this->fileService + ->expects($this->once()) + ->method('update') + ->with($this->identicalTo($file)); + + self::invokePrivate($this->getService(), 'updateSignatureFlowIfAllowed', [ + $file, + ['signatureFlow' => SignatureFlow::PARALLEL->value], + ]); + + $this->assertSame(SignatureFlow::ORDERED_NUMERIC, $file->getSignatureFlowEnum()); + } + + public function testUpdateSignatureFlowIfAllowedUsesPayloadWhenGlobalConfigNotForced(): void { + $file = new \OCA\Libresign\Db\File(); + $file->setUserId('john'); + $file->setSignatureFlowEnum(SignatureFlow::NONE); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUserId') + ->with('john', ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); + + $this->fileService + ->expects($this->once()) + ->method('update') + ->with($this->identicalTo($file)); + + self::invokePrivate($this->getService(), 'updateSignatureFlowIfAllowed', [ + $file, + ['signatureFlow' => SignatureFlow::PARALLEL->value], + ]); + + $this->assertSame(SignatureFlow::PARALLEL, $file->getSignatureFlowEnum()); + } + + public function testUpdateSignatureFlowIfAllowedKeepsCurrentValueWithoutPayloadOrForcedGlobal(): void { + $file = new \OCA\Libresign\Db\File(); + $file->setUserId('john'); + $file->setSignatureFlowEnum(SignatureFlow::PARALLEL); + $this->signatureFlowPolicyService + ->expects($this->once()) + ->method('resolveForUserId') + ->with('john', []) + ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); + + $this->fileService + ->expects($this->never()) + ->method('update'); + + self::invokePrivate($this->getService(), 'updateSignatureFlowIfAllowed', [ + $file, + [], + ]); + + $this->assertSame(SignatureFlow::PARALLEL, $file->getSignatureFlowEnum()); + } + + private function createResolvedPolicy(string $effectiveValue): ResolvedPolicy { + return (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue($effectiveValue) + ->setSourceScope('system'); + } } From c789aa4a215b41ac5ff87f3ad92d62675a57559e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:58 -0300 Subject: [PATCH 027/417] feat: serialize resolved policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/ResolvedPolicy.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/Service/Policy/ResolvedPolicy.php b/lib/Service/Policy/ResolvedPolicy.php index c117ac700b..94b46a1255 100644 --- a/lib/Service/Policy/ResolvedPolicy.php +++ b/lib/Service/Policy/ResolvedPolicy.php @@ -112,4 +112,20 @@ public function setBlockedBy(?string $blockedBy): self { public function getBlockedBy(): ?string { return $this->blockedBy; } + + /** @return array */ + public function toArray(): array { + return [ + 'policyKey' => $this->getPolicyKey(), + 'effectiveValue' => $this->getEffectiveValue(), + 'sourceScope' => $this->getSourceScope(), + 'visible' => $this->isVisible(), + 'editableByCurrentActor' => $this->isEditableByCurrentActor(), + 'allowedValues' => $this->getAllowedValues(), + 'canSaveAsUserDefault' => $this->canSaveAsUserDefault(), + 'canUseAsRequestOverride' => $this->canUseAsRequestOverride(), + 'preferenceWasCleared' => $this->wasPreferenceCleared(), + 'blockedBy' => $this->getBlockedBy(), + ]; + } } From ece8193abc186b0041fcc47b658dd59a3c99a492 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 028/417] feat: expose sidebar policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Files/TemplateLoader.php | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php index 7d3c8e3d1f..daed6659a3 100644 --- a/lib/Files/TemplateLoader.php +++ b/lib/Files/TemplateLoader.php @@ -16,11 +16,11 @@ use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; -use OCP\IAppConfig; use OCP\IRequest; use OCP\IUserSession; use OCP\Util; @@ -37,7 +37,7 @@ public function __construct( private ValidateHelper $validateHelper, private IdentifyMethodService $identifyMethodService, private CertificateEngineFactory $certificateEngineFactory, - private IAppConfig $appConfig, + private SignatureFlowPolicyService $signatureFlowPolicyService, private IAppManager $appManager, private ConfigService $docMdpConfigService, ) { @@ -66,20 +66,12 @@ protected function getInitialStatePayload(): array { return [ 'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(), 'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(), - 'signature_flow' => $this->getSignatureFlow(), + 'signature_flow_policy' => $this->signatureFlowPolicyService->resolveForUser($this->userSession->getUser())->toArray(), 'docmdp_config' => $this->docMdpConfigService->getConfig(), 'can_request_sign' => $this->canRequestSign(), ]; } - private function getSignatureFlow(): string { - return $this->appConfig->getValueString( - Application::APP_ID, - 'signature_flow', - \OCA\Libresign\Enum\SignatureFlow::NONE->value - ); - } - private function canRequestSign(): bool { try { $this->validateHelper->canRequestSign($this->userSession->getUser()); From 1adef1e5a8647f68043250a71a2bec82a93a2e4a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 029/417] feat: expose page policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 51d7305461..baa0d07a7c 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -23,6 +23,7 @@ use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Service\SignerElementsService; @@ -58,6 +59,7 @@ public function __construct( private AccountService $accountService, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, + private SignatureFlowPolicyService $signatureFlowPolicyService, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -106,7 +108,7 @@ public function index(): TemplateResponse { $this->provideSignerSignatues(); $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)); + $this->initialState->provideInitialState('signature_flow_policy', $this->signatureFlowPolicyService->resolveForUser($this->userSession->getUser())->toArray()); $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); From ece37a03c59a8f8095e93f8676675aaebee26dcc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 030/417] feat: add signature flow policy frontend type Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/index.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index a12fa7f3d6..1f70e25b6c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,18 @@ type ApiOcsResponseData Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 031/417] feat: consume signature flow policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.vue | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index b17c599146..f5f77cabf1 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -329,6 +329,7 @@ import type { IdentifyMethodSetting as IdentifyMethodConfig, LibresignCapabilities as RequestSignatureTabCapabilities, SignatureFlowMode, + SignatureFlowPolicyState, SignatureFlowValue, } from '../../types/index' @@ -398,7 +399,19 @@ const activeTab = ref('') const preserveOrder = ref(false) const showOrderDiagram = ref(false) const showEnvelopeFilesDialog = ref(false) -const adminSignatureFlow = ref(loadState('libresign', 'signature_flow', 'none')) +const DEFAULT_SIGNATURE_FLOW_POLICY: SignatureFlowPolicyState = { + policyKey: 'signature_flow', + effectiveValue: 'none', + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + allowedValues: ['none', 'parallel', 'ordered_numeric'], + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, +} +const signatureFlowPolicy = ref(loadState('libresign', 'signature_flow_policy', DEFAULT_SIGNATURE_FLOW_POLICY)) const signingProgress = ref(null) const signingProgressStatus = ref(null) const signingProgressStatusText = ref('') @@ -416,13 +429,14 @@ const signatureFlow = computed(() => { if (flow && flow !== 'none') { return flow } - if (adminSignatureFlow.value && adminSignatureFlow.value !== 'none') { - return adminSignatureFlow.value + const resolvedFlow = normalizeSignatureFlow(signatureFlowPolicy.value.effectiveValue) + if (resolvedFlow && resolvedFlow !== 'none') { + return resolvedFlow } return 'parallel' }) -const isAdminFlowForced = computed(() => adminSignatureFlow.value && adminSignatureFlow.value !== 'none') +const isAdminFlowForced = computed(() => !signatureFlowPolicy.value.canUseAsRequestOverride) const isOrderedNumeric = computed(() => signatureFlow.value === 'ordered_numeric') const hasSigners = computed(() => filesStore.hasSigners(filesStore.getFile())) const totalSigners = computed(() => Number(filesStore.getFile()?.signersCount || filesStore.getFile()?.signers?.length || 0)) @@ -1235,7 +1249,7 @@ defineExpose({ preserveOrder, showOrderDiagram, showEnvelopeFilesDialog, - adminSignatureFlow, + signatureFlowPolicy, debouncedSave, debouncedTabChange, signingProgress, From cbb7491027f36329060c4cb0589a24d46b450a0f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 032/417] test: cover resolved policy bootstrap export Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Service/Policy/ResolvedPolicyTest.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php b/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php index 462643c558..29a77bd6b2 100644 --- a/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php +++ b/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php @@ -52,4 +52,31 @@ public function testSettersStoreValues(): void { $this->assertTrue($policy->wasPreferenceCleared()); $this->assertSame('system', $policy->getBlockedBy()); } + + public function testToArrayExportsFrontendPayload(): void { + $policy = (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('parallel') + ->setSourceScope('group') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues(['parallel', 'ordered_numeric']) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(false) + ->setPreferenceWasCleared(true) + ->setBlockedBy('group'); + + $this->assertSame([ + 'policyKey' => 'signature_flow', + 'effectiveValue' => 'parallel', + 'sourceScope' => 'group', + 'visible' => true, + 'editableByCurrentActor' => true, + 'allowedValues' => ['parallel', 'ordered_numeric'], + 'canSaveAsUserDefault' => true, + 'canUseAsRequestOverride' => false, + 'preferenceWasCleared' => true, + 'blockedBy' => 'group', + ], $policy->toArray()); + } } From 471ca3b87ae2df27ccc9f83c152aaff6ecb94dbf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:08:59 -0300 Subject: [PATCH 033/417] test: cover sidebar policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Files/TemplateLoaderTest.php | 60 ++++++++++++++++----- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/tests/php/Unit/Files/TemplateLoaderTest.php b/tests/php/Unit/Files/TemplateLoaderTest.php index 28175e3197..eeb88e4b0e 100644 --- a/tests/php/Unit/Files/TemplateLoaderTest.php +++ b/tests/php/Unit/Files/TemplateLoaderTest.php @@ -15,10 +15,11 @@ use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\ResolvedPolicy; +use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; use OCA\Libresign\Tests\Unit\TestCase; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; -use OCP\IAppConfig; use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; @@ -32,7 +33,7 @@ final class TemplateLoaderTest extends TestCase { private ValidateHelper&MockObject $validateHelper; private IdentifyMethodService&MockObject $identifyMethodService; private CertificateEngineFactory&MockObject $certificateEngineFactory; - private IAppConfig&MockObject $appConfig; + private SignatureFlowPolicyService&MockObject $signatureFlowPolicyService; private IAppManager&MockObject $appManager; private ConfigService&MockObject $docMdpConfigService; @@ -44,7 +45,7 @@ public function setUp(): void { $this->validateHelper = $this->createMock(ValidateHelper::class); $this->identifyMethodService = $this->createMock(IdentifyMethodService::class); $this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); - $this->appConfig = $this->createMock(IAppConfig::class); + $this->signatureFlowPolicyService = $this->createMock(SignatureFlowPolicyService::class); $this->appManager = $this->createMock(IAppManager::class); $this->docMdpConfigService = $this->createMock(ConfigService::class); } @@ -60,10 +61,6 @@ public function testGetInitialStatePayload(): void { ->method('getIdentifyMethodsSettings') ->willReturn([]); - $this->appConfig - ->method('getValueString') - ->willReturn('none'); - $this->validateHelper ->method('canRequestSign'); @@ -72,6 +69,21 @@ public function testGetInitialStatePayload(): void { ->method('getUser') ->willReturn($user); + $this->signatureFlowPolicyService + ->method('resolveForUser') + ->with($user) + ->willReturn( + (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('parallel') + ->setSourceScope('group') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues(['parallel', 'ordered_numeric']) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(true) + ); + $docMdpConfig = [ 'enabled' => true, 'defaultLevel' => 1, @@ -87,7 +99,18 @@ public function testGetInitialStatePayload(): void { $this->assertSame([ 'certificate_ok' => true, 'identify_methods' => [], - 'signature_flow' => 'none', + 'signature_flow_policy' => [ + 'policyKey' => 'signature_flow', + 'effectiveValue' => 'parallel', + 'sourceScope' => 'group', + 'visible' => true, + 'editableByCurrentActor' => true, + 'allowedValues' => ['parallel', 'ordered_numeric'], + 'canSaveAsUserDefault' => true, + 'canUseAsRequestOverride' => true, + 'preferenceWasCleared' => false, + 'blockedBy' => null, + ], 'docmdp_config' => $docMdpConfig, 'can_request_sign' => true, ], $payload); @@ -104,10 +127,6 @@ public function testGetInitialStatePayloadWhenCannotRequestSign(): void { ->method('getIdentifyMethodsSettings') ->willReturn([]); - $this->appConfig - ->method('getValueString') - ->willReturn('none'); - $this->validateHelper ->method('canRequestSign') ->willThrowException(new \OCA\Libresign\Exception\LibresignException('no')); @@ -117,6 +136,21 @@ public function testGetInitialStatePayloadWhenCannotRequestSign(): void { ->method('getUser') ->willReturn($user); + $this->signatureFlowPolicyService + ->method('resolveForUser') + ->with($user) + ->willReturn( + (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('none') + ->setSourceScope('system') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues(['none', 'parallel', 'ordered_numeric']) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(true) + ); + $this->docMdpConfigService ->method('getConfig') ->willReturn([]); @@ -136,7 +170,7 @@ private function getLoader(): TemplateLoader { $this->validateHelper, $this->identifyMethodService, $this->certificateEngineFactory, - $this->appConfig, + $this->signatureFlowPolicyService, $this->appManager, $this->docMdpConfigService, ); From e0b679fca01555b1b96aa73feabf2399f67cc6d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Tue, 17 Mar 2026 23:09:00 -0300 Subject: [PATCH 034/417] test: cover request sidebar policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.spec.ts | 91 +++++++++++++++---- 1 file changed, 72 insertions(+), 19 deletions(-) diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 6ecbb9feee..35ebd3a160 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -38,6 +38,20 @@ vi.mock('@nextcloud/initial-state', () => ({ } } if (key === 'can_request_sign') return true + if (key === 'signature_flow_policy') { + return { + policyKey: 'signature_flow', + effectiveValue: 'none', + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + allowedValues: ['none', 'parallel', 'ordered_numeric'], + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + } + } return defaultValue }), })) @@ -691,7 +705,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) }) - describe('RULE: signatureFlow calculation with admin override', () => { + describe('RULE: signatureFlow calculation with effective policy bootstrap', () => { it('returns ordered_numeric when file flow is 2', async () => { await updateFile({ signatureFlow: 2 }) expect(wrapper.vm.signatureFlow).toBe('ordered_numeric') @@ -707,37 +721,58 @@ describe('RequestSignatureTab - Critical Business Rules', () => { expect(wrapper.vm.signatureFlow).toBe('none') }) - it('uses admin flow when file flow is none', async () => { - await setVmState({ adminSignatureFlow: 'ordered_numeric' }) + it('uses effective policy when file flow is none', async () => { + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + effectiveValue: 'ordered_numeric', + }, + }) await updateFile({ signatureFlow: 'none' }) expect(wrapper.vm.signatureFlow).toBe('ordered_numeric') }) - it('defaults to parallel when both file and admin are none', async () => { - await setVmState({ adminSignatureFlow: 'none' }) + it('defaults to parallel when both file and policy are none', async () => { + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + effectiveValue: 'none', + }, + }) await updateFile({ signatureFlow: 'none' }) expect(wrapper.vm.signatureFlow).toBe('parallel') }) }) describe('RULE: isAdminFlowForced detection', () => { - it('returns true when admin flow set to ordered_numeric', async () => { - await setVmState({ adminSignatureFlow: 'ordered_numeric' }) - expect(wrapper.vm.isAdminFlowForced).toBe(true) - }) - - it('returns true when admin flow set to parallel', async () => { - await setVmState({ adminSignatureFlow: 'parallel' }) + it('returns true when policy blocks request overrides', async () => { + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + canUseAsRequestOverride: false, + }, + }) expect(wrapper.vm.isAdminFlowForced).toBe(true) }) - it('returns false when admin flow is none', async () => { - await setVmState({ adminSignatureFlow: 'none' }) + it('returns false when policy allows request overrides', async () => { + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + canUseAsRequestOverride: true, + }, + }) expect(wrapper.vm.isAdminFlowForced).toBe(false) }) - it('hides preserve order switch when admin forces flow', async () => { - await setVmState({ adminSignatureFlow: 'ordered_numeric' }) + it('hides preserve order switch when policy forces flow', async () => { + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + canUseAsRequestOverride: false, + effectiveValue: 'ordered_numeric', + }, + }) await updateFile({ signers: [ { email: 'test1@example.com' }, @@ -936,7 +971,13 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('reverts to parallel when disabling', async () => { - await setVmState({ adminSignatureFlow: 'none' }) + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + effectiveValue: 'none', + canUseAsRequestOverride: true, + }, + }) await updateFile({ signatureFlow: 'ordered_numeric', signers: [ @@ -949,7 +990,13 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('preserves admin flow when disabling user preference', async () => { - await setVmState({ adminSignatureFlow: 'ordered_numeric' }) + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + effectiveValue: 'ordered_numeric', + canUseAsRequestOverride: false, + }, + }) await updateFile({ signatureFlow: 'ordered_numeric', signers: [ @@ -982,7 +1029,13 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('disables preserve order when admin forces flow', async () => { - await setVmState({ adminSignatureFlow: 'ordered_numeric' }) + await setVmState({ + signatureFlowPolicy: { + ...wrapper.vm.signatureFlowPolicy, + effectiveValue: 'ordered_numeric', + canUseAsRequestOverride: false, + }, + }) await updateFile({ signatureFlow: 'ordered_numeric' }) wrapper.vm.syncPreserveOrderWithFile() expect(wrapper.vm.preserveOrder).toBe(false) From 8e23fcfe3065fc053be0e8042cc2d32b21d37982 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:51:02 -0300 Subject: [PATCH 035/417] refactor(policy): restructure generic policy runtime Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 7 +- lib/Db/PermissionSetBindingMapper.php | 24 +++ lib/Db/PermissionSetMapper.php | 23 +++ lib/Files/TemplateLoader.php | 7 +- .../IPolicyDefinition.php} | 12 +- .../Contract/IPolicyDefinitionProvider.php | 16 ++ .../Policy/Contract/IPolicyResolver.php | 21 +++ .../IPolicySource.php} | 7 +- .../Policy/{ => Model}/PolicyContext.php | 2 +- .../Policy/{ => Model}/PolicyLayer.php | 2 +- lib/Service/Policy/Model/PolicySpec.php | 93 +++++++++++ .../Policy/{ => Model}/ResolvedPolicy.php | 2 +- .../Policy/PolicyResolverInterface.php | 18 -- lib/Service/Policy/PolicyService.php | 52 ++++++ .../Policy/Provider/PolicyProviders.php | 18 ++ .../Signature/SignatureFlowPolicy.php | 62 +++++++ .../{ => Runtime}/DefaultPolicyResolver.php | 58 ++++--- .../Policy/Runtime/PolicyContextFactory.php | 63 +++++++ lib/Service/Policy/Runtime/PolicyRegistry.php | 57 +++++++ lib/Service/Policy/Runtime/PolicySource.php | 158 ++++++++++++++++++ .../Policy/SignatureFlowPolicyDefinition.php | 52 ------ .../Policy/SignatureFlowPolicyService.php | 50 ------ .../Policy/SignatureFlowPolicySource.php | 126 -------------- 23 files changed, 643 insertions(+), 287 deletions(-) rename lib/Service/Policy/{PolicyDefinitionInterface.php => Contract/IPolicyDefinition.php} (55%) create mode 100644 lib/Service/Policy/Contract/IPolicyDefinitionProvider.php create mode 100644 lib/Service/Policy/Contract/IPolicyResolver.php rename lib/Service/Policy/{PolicySourceInterface.php => Contract/IPolicySource.php} (80%) rename lib/Service/Policy/{ => Model}/PolicyContext.php (98%) rename lib/Service/Policy/{ => Model}/PolicyLayer.php (97%) create mode 100644 lib/Service/Policy/Model/PolicySpec.php rename lib/Service/Policy/{ => Model}/ResolvedPolicy.php (98%) delete mode 100644 lib/Service/Policy/PolicyResolverInterface.php create mode 100644 lib/Service/Policy/PolicyService.php create mode 100644 lib/Service/Policy/Provider/PolicyProviders.php create mode 100644 lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php rename lib/Service/Policy/{ => Runtime}/DefaultPolicyResolver.php (75%) create mode 100644 lib/Service/Policy/Runtime/PolicyContextFactory.php create mode 100644 lib/Service/Policy/Runtime/PolicyRegistry.php create mode 100644 lib/Service/Policy/Runtime/PolicySource.php delete mode 100644 lib/Service/Policy/SignatureFlowPolicyDefinition.php delete mode 100644 lib/Service/Policy/SignatureFlowPolicyService.php delete mode 100644 lib/Service/Policy/SignatureFlowPolicySource.php diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index baa0d07a7c..f25cfcf953 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -23,7 +23,8 @@ use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService; use OCA\Libresign\Service\IdentifyMethodService; -use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Service\SignerElementsService; @@ -59,7 +60,7 @@ public function __construct( private AccountService $accountService, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, - private SignatureFlowPolicyService $signatureFlowPolicyService, + private PolicyService $policyService, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -108,7 +109,7 @@ public function index(): TemplateResponse { $this->provideSignerSignatues(); $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('signature_flow_policy', $this->signatureFlowPolicyService->resolveForUser($this->userSession->getUser())->toArray()); + $this->initialState->provideInitialState('signature_flow_policy', $this->policyService->resolve(SignatureFlowPolicy::KEY)->toArray()); $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); diff --git a/lib/Db/PermissionSetBindingMapper.php b/lib/Db/PermissionSetBindingMapper.php index ff2ad572db..e2363b9bb6 100644 --- a/lib/Db/PermissionSetBindingMapper.php +++ b/lib/Db/PermissionSetBindingMapper.php @@ -56,4 +56,28 @@ public function getByTarget(string $targetType, string $targetId): PermissionSet $this->cacheEntity($entity); return $entity; } + + /** + * @param list $targetIds + * @return list + */ + public function findByTargets(string $targetType, array $targetIds): array { + if ($targetIds === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))) + ->andWhere($qb->expr()->in('target_id', $qb->createNamedParameter($targetIds, IQueryBuilder::PARAM_STR_ARRAY))); + + /** @var list */ + $entities = $this->findEntities($qb); + foreach ($entities as $entity) { + $this->cacheEntity($entity); + } + + return $entities; + } } diff --git a/lib/Db/PermissionSetMapper.php b/lib/Db/PermissionSetMapper.php index cdd07269a2..bafa288eb2 100644 --- a/lib/Db/PermissionSetMapper.php +++ b/lib/Db/PermissionSetMapper.php @@ -40,4 +40,27 @@ public function getById(int $id): PermissionSet { $this->cacheEntity($entity); return $entity; } + + /** + * @param list $ids + * @return list + */ + public function findByIds(array $ids): array { + if ($ids === []) { + return []; + } + + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->in('id', $qb->createNamedParameter($ids, IQueryBuilder::PARAM_INT_ARRAY))); + + /** @var list */ + $entities = $this->findEntities($qb); + foreach ($entities as $entity) { + $this->cacheEntity($entity); + } + + return $entities; + } } diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php index daed6659a3..064155fd59 100644 --- a/lib/Files/TemplateLoader.php +++ b/lib/Files/TemplateLoader.php @@ -16,7 +16,8 @@ use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\IdentifyMethodService; -use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; @@ -37,7 +38,7 @@ public function __construct( private ValidateHelper $validateHelper, private IdentifyMethodService $identifyMethodService, private CertificateEngineFactory $certificateEngineFactory, - private SignatureFlowPolicyService $signatureFlowPolicyService, + private PolicyService $policyService, private IAppManager $appManager, private ConfigService $docMdpConfigService, ) { @@ -66,7 +67,7 @@ protected function getInitialStatePayload(): array { return [ 'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(), 'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(), - 'signature_flow_policy' => $this->signatureFlowPolicyService->resolveForUser($this->userSession->getUser())->toArray(), + 'signature_flow_policy' => $this->policyService->resolve(SignatureFlowPolicy::KEY)->toArray(), 'docmdp_config' => $this->docMdpConfigService->getConfig(), 'can_request_sign' => $this->canRequestSign(), ]; diff --git a/lib/Service/Policy/PolicyDefinitionInterface.php b/lib/Service/Policy/Contract/IPolicyDefinition.php similarity index 55% rename from lib/Service/Policy/PolicyDefinitionInterface.php rename to lib/Service/Policy/Contract/IPolicyDefinition.php index fc74a9842c..84da425b46 100644 --- a/lib/Service/Policy/PolicyDefinitionInterface.php +++ b/lib/Service/Policy/Contract/IPolicyDefinition.php @@ -6,14 +6,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Contract; -interface PolicyDefinitionInterface { +use OCA\Libresign\Service\Policy\Model\PolicyContext; + +interface IPolicyDefinition { public function key(): string; + public function getAppConfigKey(): string; + + public function getUserPreferenceKey(): string; + public function normalizeValue(mixed $rawValue): mixed; - public function validateValue(mixed $value): void; + public function validateValue(mixed $value, PolicyContext $context): void; /** @return list */ public function allowedValues(PolicyContext $context): array; diff --git a/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php new file mode 100644 index 0000000000..0a23c76c6e --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinitionProvider.php @@ -0,0 +1,16 @@ + */ + public function keys(): array; + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition; +} diff --git a/lib/Service/Policy/Contract/IPolicyResolver.php b/lib/Service/Policy/Contract/IPolicyResolver.php new file mode 100644 index 0000000000..5f9fd8d914 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyResolver.php @@ -0,0 +1,21 @@ + $definitions + * @return array + */ + public function resolveMany(array $definitions, PolicyContext $context): array; +} diff --git a/lib/Service/Policy/PolicySourceInterface.php b/lib/Service/Policy/Contract/IPolicySource.php similarity index 80% rename from lib/Service/Policy/PolicySourceInterface.php rename to lib/Service/Policy/Contract/IPolicySource.php index e1e5ba00df..0665ffa478 100644 --- a/lib/Service/Policy/PolicySourceInterface.php +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -6,9 +6,12 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Contract; -interface PolicySourceInterface { +use OCA\Libresign\Service\Policy\Model\PolicyContext; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; + +interface IPolicySource { public function loadSystemPolicy(string $policyKey): ?PolicyLayer; /** @return list */ diff --git a/lib/Service/Policy/PolicyContext.php b/lib/Service/Policy/Model/PolicyContext.php similarity index 98% rename from lib/Service/Policy/PolicyContext.php rename to lib/Service/Policy/Model/PolicyContext.php index 4fb2c31c0c..0e7d56f818 100644 --- a/lib/Service/Policy/PolicyContext.php +++ b/lib/Service/Policy/Model/PolicyContext.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Model; final class PolicyContext { private ?string $userId = null; diff --git a/lib/Service/Policy/PolicyLayer.php b/lib/Service/Policy/Model/PolicyLayer.php similarity index 97% rename from lib/Service/Policy/PolicyLayer.php rename to lib/Service/Policy/Model/PolicyLayer.php index ea0137f610..16e8cdc17b 100644 --- a/lib/Service/Policy/PolicyLayer.php +++ b/lib/Service/Policy/Model/PolicyLayer.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Model; final class PolicyLayer { private string $scope = ''; diff --git a/lib/Service/Policy/Model/PolicySpec.php b/lib/Service/Policy/Model/PolicySpec.php new file mode 100644 index 0000000000..b7346e4102 --- /dev/null +++ b/lib/Service/Policy/Model/PolicySpec.php @@ -0,0 +1,93 @@ +|Closure(PolicyContext): list */ + private array|Closure $allowedValuesResolver; + /** @var Closure(mixed): mixed|null */ + private ?Closure $normalizer; + /** @var Closure(mixed, PolicyContext): void|null */ + private ?Closure $validator; + + /** + * @param list|Closure(PolicyContext): list $allowedValues + * @param Closure(mixed): mixed|null $normalizer + * @param Closure(mixed, PolicyContext): void|null $validator + */ + public function __construct( + private string $key, + private mixed $defaultSystemValue, + array|Closure $allowedValues, + ?Closure $normalizer = null, + ?Closure $validator = null, + private ?string $appConfigKey = null, + private ?string $userPreferenceKey = null, + ) { + $this->allowedValuesResolver = $allowedValues; + $this->normalizer = $normalizer; + $this->validator = $validator; + } + + #[\Override] + public function key(): string { + return $this->key; + } + + #[\Override] + public function getAppConfigKey(): string { + return $this->appConfigKey ?? $this->key; + } + + #[\Override] + public function getUserPreferenceKey(): string { + return $this->userPreferenceKey ?? 'policy.' . $this->key; + } + + #[\Override] + public function normalizeValue(mixed $rawValue): mixed { + if ($this->normalizer !== null) { + return ($this->normalizer)($rawValue); + } + + return $rawValue; + } + + #[\Override] + public function validateValue(mixed $value, PolicyContext $context): void { + if ($this->validator !== null) { + ($this->validator)($value, $context); + return; + } + + if (!in_array($value, $this->allowedValues($context), true)) { + throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key())); + } + } + + #[\Override] + public function allowedValues(PolicyContext $context): array { + if ($this->allowedValuesResolver instanceof Closure) { + return ($this->allowedValuesResolver)($context); + } + + return $this->allowedValuesResolver; + } + + #[\Override] + public function defaultSystemValue(): mixed { + return $this->defaultSystemValue; + } +} diff --git a/lib/Service/Policy/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php similarity index 98% rename from lib/Service/Policy/ResolvedPolicy.php rename to lib/Service/Policy/Model/ResolvedPolicy.php index 94b46a1255..e934c20870 100644 --- a/lib/Service/Policy/ResolvedPolicy.php +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -6,7 +6,7 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Model; final class ResolvedPolicy { private string $policyKey = ''; diff --git a/lib/Service/Policy/PolicyResolverInterface.php b/lib/Service/Policy/PolicyResolverInterface.php deleted file mode 100644 index f47bff453d..0000000000 --- a/lib/Service/Policy/PolicyResolverInterface.php +++ /dev/null @@ -1,18 +0,0 @@ - $policyKeys - * @return array - */ - public function resolveMany(array $policyKeys, PolicyContext $context): array; -} diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php new file mode 100644 index 0000000000..c674e2151f --- /dev/null +++ b/lib/Service/Policy/PolicyService.php @@ -0,0 +1,52 @@ +resolver = new DefaultPolicyResolver($this->source); + } + + /** @param array $requestOverrides */ + public function resolve(string|\BackedEnum $policyKey, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forCurrentUser($requestOverrides, $activeContext), + ); + } + + /** @param array $requestOverrides */ + public function resolveForUserId(string|\BackedEnum $policyKey, ?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forUserId($userId, $requestOverrides, $activeContext), + ); + } + + /** @param array $requestOverrides */ + public function resolveForUser(string|\BackedEnum $policyKey, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { + return $this->resolver->resolve( + $this->registry->get($policyKey), + $this->contextFactory->forUser($user, $requestOverrides, $activeContext), + ); + } +} diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..b764b024bb --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,18 @@ + */ + public const BY_KEY = [ + SignatureFlowPolicy::KEY => SignatureFlowPolicy::class, + ]; +} diff --git a/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php new file mode 100644 index 0000000000..6a7af99d37 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php @@ -0,0 +1,62 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: SignatureFlow::NONE->value, + allowedValues: [ + SignatureFlow::NONE->value, + SignatureFlow::PARALLEL->value, + SignatureFlow::ORDERED_NUMERIC->value, + ], + normalizer: static function (mixed $rawValue): mixed { + if ($rawValue instanceof SignatureFlow) { + return $rawValue->value; + } + + if (is_int($rawValue)) { + return SignatureFlow::fromNumeric($rawValue)->value; + } + + return $rawValue; + }, + ), + default => throw new \InvalidArgumentException('Unknown policy key: ' . $this->normalizePolicyKey($policyKey)), + }; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php similarity index 75% rename from lib/Service/Policy/DefaultPolicyResolver.php rename to lib/Service/Policy/Runtime/DefaultPolicyResolver.php index 83d935f5f3..1f85c2e43b 100644 --- a/lib/Service/Policy/DefaultPolicyResolver.php +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -6,29 +6,24 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Service\Policy; +namespace OCA\Libresign\Service\Policy\Runtime; -final class DefaultPolicyResolver implements PolicyResolverInterface { - /** @var array */ - private array $definitions = []; +use OCA\Libresign\Service\Policy\Contract\IPolicyDefinition; +use OCA\Libresign\Service\Policy\Contract\IPolicyResolver; +use OCA\Libresign\Service\Policy\Contract\IPolicySource; +use OCA\Libresign\Service\Policy\Model\PolicyContext; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; - /** @param iterable $definitions */ +final class DefaultPolicyResolver implements IPolicyResolver { public function __construct( - private PolicySourceInterface $source, - iterable $definitions, + private IPolicySource $source, ) { - foreach ($definitions as $definition) { - $this->definitions[$definition->key()] = $definition; - } } #[\Override] - public function resolve(string $policyKey, PolicyContext $context): ResolvedPolicy { - $definition = $this->definitions[$policyKey] ?? null; - if ($definition === null) { - throw new \InvalidArgumentException(sprintf('Unknown policy key: %s', $policyKey)); - } - + public function resolve(IPolicyDefinition $definition, PolicyContext $context): ResolvedPolicy { + $policyKey = $definition->key(); $resolved = (new ResolvedPolicy()) ->setPolicyKey($policyKey) ->setAllowedValues($definition->allowedValues($context)); @@ -48,6 +43,7 @@ public function resolve(string $policyKey, PolicyContext $context): ResolvedPoli $definition, $resolved, $systemLayer, + $context, $currentValue, $currentSourceScope, true, @@ -60,6 +56,7 @@ public function resolve(string $policyKey, PolicyContext $context): ResolvedPoli $definition, $resolved, $layer, + $context, $currentValue, $currentSourceScope, $canOverrideBelow, @@ -69,9 +66,9 @@ public function resolve(string $policyKey, PolicyContext $context): ResolvedPoli $userPreference = $this->source->loadUserPreference($policyKey, $context); if ($userPreference !== null) { - if ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible)) { + if ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible, $context)) { $currentValue = $definition->normalizeValue($userPreference->getValue()); - $definition->validateValue($currentValue); + $definition->validateValue($currentValue, $context); $currentSourceScope = $userPreference->getScope(); } else { $this->source->clearUserPreference($policyKey, $context); @@ -82,9 +79,9 @@ public function resolve(string $policyKey, PolicyContext $context): ResolvedPoli $requestOverride = $this->source->loadRequestOverride($policyKey, $context); if ($requestOverride !== null) { - if ($this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible)) { + if ($this->canApplyLowerLayer($definition, $resolved, $requestOverride, $canOverrideBelow, $visible, $context)) { $currentValue = $definition->normalizeValue($requestOverride->getValue()); - $definition->validateValue($currentValue); + $definition->validateValue($currentValue, $context); $currentSourceScope = $requestOverride->getScope(); } elseif ($currentBlockedBy === null) { $currentBlockedBy = $currentSourceScope; @@ -104,18 +101,24 @@ public function resolve(string $policyKey, PolicyContext $context): ResolvedPoli } #[\Override] - public function resolveMany(array $policyKeys, PolicyContext $context): array { + /** @param list $definitions */ + public function resolveMany(array $definitions, PolicyContext $context): array { $resolved = []; - foreach ($policyKeys as $policyKey) { - $resolved[$policyKey] = $this->resolve($policyKey, $context); + foreach ($definitions as $definition) { + if (!$definition instanceof IPolicyDefinition) { + continue; + } + + $resolved[$definition->key()] = $this->resolve($definition, $context); } return $resolved; } private function applyLayer( - PolicyDefinitionInterface $definition, + IPolicyDefinition $definition, ResolvedPolicy $resolved, PolicyLayer $layer, + PolicyContext $context, mixed $currentValue, string $currentSourceScope, bool $canOverrideBelow, @@ -126,7 +129,7 @@ private function applyLayer( if ($layer->getValue() !== null && ($currentSourceScope === 'system' || $canOverrideBelow)) { $currentValue = $definition->normalizeValue($layer->getValue()); - $definition->validateValue($currentValue); + $definition->validateValue($currentValue, $context); $currentSourceScope = $layer->getScope(); } @@ -136,11 +139,12 @@ private function applyLayer( } private function canApplyLowerLayer( - PolicyDefinitionInterface $definition, + IPolicyDefinition $definition, ResolvedPolicy $resolved, PolicyLayer $layer, bool $canOverrideBelow, bool $visible, + PolicyContext $context, ): bool { if (!$visible || !$canOverrideBelow || $layer->getValue() === null) { return false; @@ -152,7 +156,7 @@ private function canApplyLowerLayer( return false; } - $definition->validateValue($value); + $definition->validateValue($value, $context); return true; } diff --git a/lib/Service/Policy/Runtime/PolicyContextFactory.php b/lib/Service/Policy/Runtime/PolicyContextFactory.php new file mode 100644 index 0000000000..c5e48c66ae --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,63 @@ + $requestOverrides */ + public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + return $this->forUser($this->userSession->getUser(), $requestOverrides, $activeContext); + } + + /** @param array $requestOverrides */ + public function forUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext); + } + + /** @param array $requestOverrides */ + public function forUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = null; + if ($userId !== null && $userId !== '') { + $loadedUser = $this->userManager->get($userId); + if ($loadedUser instanceof IUser) { + $user = $loadedUser; + } + } + + return $this->build($userId, $user, $requestOverrides, $activeContext); + } + + /** @param array $requestOverrides */ + private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveContext($activeContext); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $context; + } +} diff --git a/lib/Service/Policy/Runtime/PolicyRegistry.php b/lib/Service/Policy/Runtime/PolicyRegistry.php new file mode 100644 index 0000000000..3dff68ceed --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyRegistry.php @@ -0,0 +1,57 @@ + */ + private array $definitions = []; + + public function __construct( + private ContainerInterface $container, + ) { + } + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition { + $policyKeyValue = $this->normalizePolicyKey($policyKey); + $definition = $this->definitions[$policyKeyValue] ?? null; + if ($definition instanceof IPolicyDefinition) { + return $definition; + } + + $providerClass = PolicyProviders::BY_KEY[$policyKeyValue] ?? null; + if (!is_string($providerClass) || $providerClass === '') { + throw new \InvalidArgumentException('Unknown policy key: ' . $policyKeyValue); + } + + $provider = $this->container->get($providerClass); + if (!$provider instanceof IPolicyDefinitionProvider) { + throw new \UnexpectedValueException('Invalid policy provider: ' . $providerClass); + } + + $definition = $provider->get($policyKeyValue); + if ($definition->key() !== $policyKeyValue) { + throw new \InvalidArgumentException('Policy provider returned mismatched key: ' . $definition->key()); + } + + return $this->definitions[$policyKeyValue] = $definition; + } + + private function normalizePolicyKey(string|\BackedEnum $policyKey): string { + if ($policyKey instanceof \BackedEnum) { + return (string)$policyKey->value; + } + + return $policyKey; + } +} diff --git a/lib/Service/Policy/Runtime/PolicySource.php b/lib/Service/Policy/Runtime/PolicySource.php new file mode 100644 index 0000000000..b07500c471 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,158 @@ +registry->get($policyKey); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $value = $this->appConfig->getAppValueString($definition->getAppConfigKey(), (string)$defaultValue); + $value = $definition->normalizeValue($value); + + $layer = (new PolicyLayer()) + ->setScope('system') + ->setValue($value) + ->setVisibleToChild(true); + + if ($value === $defaultValue) { + return $layer->setAllowChildOverride(true); + } + + return $layer + ->setAllowChildOverride(false) + ->setAllowedValues([$value]); + } + + #[\Override] + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array { + $groupIds = $this->resolveGroupIds($context); + if ($groupIds === []) { + return []; + } + + $bindingsByTargetId = []; + foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) { + $bindingsByTargetId[$binding->getTargetId()] = $binding; + } + + $permissionSetIds = []; + foreach ($bindingsByTargetId as $binding) { + $permissionSetIds[] = $binding->getPermissionSetId(); + } + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds(array_values(array_unique($permissionSetIds))) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + $layers = []; + + foreach ($groupIds as $groupId) { + $binding = $bindingsByTargetId[$groupId] ?? null; + if (!$binding instanceof PermissionSetBinding) { + continue; + } + + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyConfig = $permissionSet->getPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $layers[] = (new PolicyLayer()) + ->setScope('group') + ->setValue($policyConfig['defaultValue'] ?? null) + ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false)) + ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true)) + ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []); + } + + return $layers; + } + + #[\Override] + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { + return []; + } + + #[\Override] + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + $definition = $this->registry->get($policyKey); + $value = $this->appConfig->getUserValue($userId, $definition->getUserPreferenceKey(), ''); + if ($value === '') { + return null; + } + + return (new PolicyLayer()) + ->setScope('user') + ->setValue($definition->normalizeValue($value)); + } + + #[\Override] + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer { + $requestOverrides = $context->getRequestOverrides(); + if (!array_key_exists($policyKey, $requestOverrides)) { + return null; + } + + $definition = $this->registry->get($policyKey); + + return (new PolicyLayer()) + ->setScope('request') + ->setValue($definition->normalizeValue($requestOverrides[$policyKey])); + } + + #[\Override] + public function clearUserPreference(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $definition = $this->registry->get($policyKey); + $this->appConfig->deleteUserValue($userId, $definition->getUserPreferenceKey()); + } + + /** @return list */ + private function resolveGroupIds(PolicyContext $context): array { + $activeContext = $context->getActiveContext(); + if (($activeContext['type'] ?? null) === 'group' && is_string($activeContext['id'] ?? null)) { + return [$activeContext['id']]; + } + + return $context->getGroups(); + } +} diff --git a/lib/Service/Policy/SignatureFlowPolicyDefinition.php b/lib/Service/Policy/SignatureFlowPolicyDefinition.php deleted file mode 100644 index de56d7c86c..0000000000 --- a/lib/Service/Policy/SignatureFlowPolicyDefinition.php +++ /dev/null @@ -1,52 +0,0 @@ -value; - } - - if ($rawValue instanceof SignatureFlow) { - return $rawValue->value; - } - - return $rawValue; - } - - #[\Override] - public function validateValue(mixed $value): void { - if (!is_string($value) || !in_array($value, $this->allowedValues(new PolicyContext()), true)) { - throw new \InvalidArgumentException(sprintf('Invalid value for %s', $this->key())); - } - } - - #[\Override] - public function allowedValues(PolicyContext $context): array { - return [ - SignatureFlow::NONE->value, - SignatureFlow::PARALLEL->value, - SignatureFlow::ORDERED_NUMERIC->value, - ]; - } - - #[\Override] - public function defaultSystemValue(): mixed { - return SignatureFlow::NONE->value; - } -} diff --git a/lib/Service/Policy/SignatureFlowPolicyService.php b/lib/Service/Policy/SignatureFlowPolicyService.php deleted file mode 100644 index 5e2cc82f7d..0000000000 --- a/lib/Service/Policy/SignatureFlowPolicyService.php +++ /dev/null @@ -1,50 +0,0 @@ -resolver = new DefaultPolicyResolver($this->source, [new SignatureFlowPolicyDefinition()]); - } - - /** @param array $requestOverrides */ - public function resolveForUserId(?string $userId, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { - $context = new PolicyContext(); - $context->setRequestOverrides($requestOverrides); - - if ($activeContext !== null) { - $context->setActiveContext($activeContext); - } - - if ($userId !== null && $userId !== '') { - $context->setUserId($userId); - $user = $this->userManager->get($userId); - if ($user instanceof IUser) { - $context->setGroups($this->groupManager->getUserGroupIds($user)); - } - } - - return $this->resolver->resolve('signature_flow', $context); - } - - /** @param array $requestOverrides */ - public function resolveForUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): ResolvedPolicy { - return $this->resolveForUserId($user?->getUID(), $requestOverrides, $activeContext); - } -} diff --git a/lib/Service/Policy/SignatureFlowPolicySource.php b/lib/Service/Policy/SignatureFlowPolicySource.php deleted file mode 100644 index 480dec3c05..0000000000 --- a/lib/Service/Policy/SignatureFlowPolicySource.php +++ /dev/null @@ -1,126 +0,0 @@ -appConfig->getAppValueString($policyKey, SignatureFlow::NONE->value); - - $layer = (new PolicyLayer()) - ->setScope('system') - ->setValue($value) - ->setVisibleToChild(true); - - if ($value === SignatureFlow::NONE->value) { - return $layer->setAllowChildOverride(true); - } - - return $layer - ->setAllowChildOverride(false) - ->setAllowedValues([$value]); - } - - #[\Override] - public function loadGroupPolicies(string $policyKey, PolicyContext $context): array { - $groupIds = $this->resolveGroupIds($context); - $layers = []; - - foreach ($groupIds as $groupId) { - try { - $binding = $this->bindingMapper->getByTarget('group', $groupId); - $permissionSet = $this->permissionSetMapper->getById($binding->getPermissionSetId()); - $policyConfig = $permissionSet->getPolicyJson()[$policyKey] ?? null; - if (!is_array($policyConfig)) { - continue; - } - - $layers[] = (new PolicyLayer()) - ->setScope('group') - ->setValue($policyConfig['defaultValue'] ?? null) - ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false)) - ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true)) - ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []); - } catch (DoesNotExistException) { - continue; - } - } - - return $layers; - } - - #[\Override] - public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { - return []; - } - - #[\Override] - public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer { - $userId = $context->getUserId(); - if ($userId === null || $userId === '') { - return null; - } - - $value = $this->appConfig->getUserValue($userId, self::USER_PREFERENCE_KEY, ''); - if ($value === '') { - return null; - } - - return (new PolicyLayer()) - ->setScope('user') - ->setValue($value); - } - - #[\Override] - public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer { - $requestOverrides = $context->getRequestOverrides(); - if (!array_key_exists($policyKey, $requestOverrides)) { - return null; - } - - return (new PolicyLayer()) - ->setScope('request') - ->setValue($requestOverrides[$policyKey]); - } - - #[\Override] - public function clearUserPreference(string $policyKey, PolicyContext $context): void { - $userId = $context->getUserId(); - if ($userId === null || $userId === '') { - return; - } - - $this->appConfig->deleteUserValue($userId, self::USER_PREFERENCE_KEY); - } - - /** @return list */ - private function resolveGroupIds(PolicyContext $context): array { - $activeContext = $context->getActiveContext(); - if (($activeContext['type'] ?? null) === 'group' && is_string($activeContext['id'] ?? null)) { - return [$activeContext['id']]; - } - - return $context->getGroups(); - } -} From 87e69f9962649731230aa412186ddc88fccdc481 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:51:11 -0300 Subject: [PATCH 036/417] fix(policy): enforce request signature flow resolution Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/RequestSignatureService.php | 44 +++++++++++++++++++++---- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 36119a1de4..7e495951cc 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -26,7 +26,9 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; -use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,7 +69,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, - protected SignatureFlowPolicyService $signatureFlowPolicyService, + protected PolicyService $policyService, ) { } @@ -382,10 +384,13 @@ public function saveFile(array $data): FileEntity { } private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): void { - $resolvedPolicy = $this->signatureFlowPolicyService->resolveForUserId( + $requestOverrides = $this->getSignatureFlowRequestOverrides($data); + $resolvedPolicy = $this->policyService->resolveForUserId( + SignatureFlowPolicy::KEY, $file->getUserId(), - $this->getSignatureFlowRequestOverrides($data), + $requestOverrides, ); + $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy); $newFlow = SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue()); if ($file->getSignatureFlowEnum() !== $newFlow) { @@ -396,11 +401,15 @@ private function updateSignatureFlowIfAllowed(FileEntity $file, array $data): vo private function setSignatureFlow(FileEntity $file, array $data): void { $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; - $resolvedPolicy = $this->signatureFlowPolicyService->resolveForUser( + $requestOverrides = $this->getSignatureFlowRequestOverrides($data); + $resolvedPolicy = $this->policyService->resolveForUser( + SignatureFlowPolicy::KEY, $user, - $this->getSignatureFlowRequestOverrides($data), + $requestOverrides, ); + $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy); $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + $this->storePolicySnapshot($file, $resolvedPolicy); } /** @return array */ @@ -409,7 +418,28 @@ private function getSignatureFlowRequestOverrides(array $data): array { return []; } - return ['signature_flow' => (string)$data['signatureFlow']]; + return [SignatureFlowPolicy::KEY => (string)$data['signatureFlow']]; + } + + /** @param array $requestOverrides */ + private function assertSignatureFlowOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Signature flow override is blocked by %s.', [$blockedBy]), 422); + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); } private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void { From 104c533dd62bb5a77c7c1c696110e2cb9e5aaa5e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:51:20 -0300 Subject: [PATCH 037/417] test(policy): realign policy runtime coverage Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Files/TemplateLoaderTest.php | 23 ++- .../Policy/{ => Model}/PolicyContextTest.php | 4 +- .../Policy/{ => Model}/PolicyLayerTest.php | 4 +- .../Service/Policy/Model/PolicySpecTest.php | 66 ++++++ .../Policy/{ => Model}/ResolvedPolicyTest.php | 4 +- .../Unit/Service/Policy/PolicyServiceTest.php | 191 ++++++++++++++++++ .../Signature/SignatureFlowPolicyTest.php | 26 +++ .../DefaultPolicyResolverTest.php | 65 +++--- .../Runtime/PolicyContextFactoryTest.php | 73 +++++++ .../Policy/Runtime/PolicyRegistryTest.php | 75 +++++++ .../PolicySourceTest.php} | 38 ++-- .../SignatureFlowPolicyDefinitionTest.php | 39 ---- .../Policy/SignatureFlowPolicyServiceTest.php | 126 ------------ .../Service/RequestSignatureServiceTest.php | 129 ++++++++++-- 14 files changed, 604 insertions(+), 259 deletions(-) rename tests/php/Unit/Service/Policy/{ => Model}/PolicyContextTest.php (93%) rename tests/php/Unit/Service/Policy/{ => Model}/PolicyLayerTest.php (92%) create mode 100644 tests/php/Unit/Service/Policy/Model/PolicySpecTest.php rename tests/php/Unit/Service/Policy/{ => Model}/ResolvedPolicyTest.php (96%) create mode 100644 tests/php/Unit/Service/Policy/PolicyServiceTest.php create mode 100644 tests/php/Unit/Service/Policy/Provider/Signature/SignatureFlowPolicyTest.php rename tests/php/Unit/Service/Policy/{ => Runtime}/DefaultPolicyResolverTest.php (71%) create mode 100644 tests/php/Unit/Service/Policy/Runtime/PolicyContextFactoryTest.php create mode 100644 tests/php/Unit/Service/Policy/Runtime/PolicyRegistryTest.php rename tests/php/Unit/Service/Policy/{SignatureFlowPolicySourceTest.php => Runtime/PolicySourceTest.php} (82%) delete mode 100644 tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php delete mode 100644 tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php diff --git a/tests/php/Unit/Files/TemplateLoaderTest.php b/tests/php/Unit/Files/TemplateLoaderTest.php index eeb88e4b0e..bf9d80aa90 100644 --- a/tests/php/Unit/Files/TemplateLoaderTest.php +++ b/tests/php/Unit/Files/TemplateLoaderTest.php @@ -15,8 +15,9 @@ use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\IdentifyMethodService; -use OCA\Libresign\Service\Policy\ResolvedPolicy; -use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Tests\Unit\TestCase; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; @@ -33,7 +34,7 @@ final class TemplateLoaderTest extends TestCase { private ValidateHelper&MockObject $validateHelper; private IdentifyMethodService&MockObject $identifyMethodService; private CertificateEngineFactory&MockObject $certificateEngineFactory; - private SignatureFlowPolicyService&MockObject $signatureFlowPolicyService; + private PolicyService&MockObject $policyService; private IAppManager&MockObject $appManager; private ConfigService&MockObject $docMdpConfigService; @@ -45,7 +46,7 @@ public function setUp(): void { $this->validateHelper = $this->createMock(ValidateHelper::class); $this->identifyMethodService = $this->createMock(IdentifyMethodService::class); $this->certificateEngineFactory = $this->createMock(CertificateEngineFactory::class); - $this->signatureFlowPolicyService = $this->createMock(SignatureFlowPolicyService::class); + $this->policyService = $this->createMock(PolicyService::class); $this->appManager = $this->createMock(IAppManager::class); $this->docMdpConfigService = $this->createMock(ConfigService::class); } @@ -69,9 +70,9 @@ public function testGetInitialStatePayload(): void { ->method('getUser') ->willReturn($user); - $this->signatureFlowPolicyService - ->method('resolveForUser') - ->with($user) + $this->policyService + ->method('resolve') + ->with(SignatureFlowPolicy::KEY) ->willReturn( (new ResolvedPolicy()) ->setPolicyKey('signature_flow') @@ -136,9 +137,9 @@ public function testGetInitialStatePayloadWhenCannotRequestSign(): void { ->method('getUser') ->willReturn($user); - $this->signatureFlowPolicyService - ->method('resolveForUser') - ->with($user) + $this->policyService + ->method('resolve') + ->with(SignatureFlowPolicy::KEY) ->willReturn( (new ResolvedPolicy()) ->setPolicyKey('signature_flow') @@ -170,7 +171,7 @@ private function getLoader(): TemplateLoader { $this->validateHelper, $this->identifyMethodService, $this->certificateEngineFactory, - $this->signatureFlowPolicyService, + $this->policyService, $this->appManager, $this->docMdpConfigService, ); diff --git a/tests/php/Unit/Service/Policy/PolicyContextTest.php b/tests/php/Unit/Service/Policy/Model/PolicyContextTest.php similarity index 93% rename from tests/php/Unit/Service/Policy/PolicyContextTest.php rename to tests/php/Unit/Service/Policy/Model/PolicyContextTest.php index 82077999ec..e0b629f88e 100644 --- a/tests/php/Unit/Service/Policy/PolicyContextTest.php +++ b/tests/php/Unit/Service/Policy/Model/PolicyContextTest.php @@ -6,9 +6,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Service\Policy; +namespace OCA\Libresign\Tests\Unit\Service\Policy\Model; -use OCA\Libresign\Service\Policy\PolicyContext; +use OCA\Libresign\Service\Policy\Model\PolicyContext; use PHPUnit\Framework\TestCase; final class PolicyContextTest extends TestCase { diff --git a/tests/php/Unit/Service/Policy/PolicyLayerTest.php b/tests/php/Unit/Service/Policy/Model/PolicyLayerTest.php similarity index 92% rename from tests/php/Unit/Service/Policy/PolicyLayerTest.php rename to tests/php/Unit/Service/Policy/Model/PolicyLayerTest.php index 1ac22f7c53..601a8ea035 100644 --- a/tests/php/Unit/Service/Policy/PolicyLayerTest.php +++ b/tests/php/Unit/Service/Policy/Model/PolicyLayerTest.php @@ -6,9 +6,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Service\Policy; +namespace OCA\Libresign\Tests\Unit\Service\Policy\Model; -use OCA\Libresign\Service\Policy\PolicyLayer; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; use PHPUnit\Framework\TestCase; final class PolicyLayerTest extends TestCase { diff --git a/tests/php/Unit/Service/Policy/Model/PolicySpecTest.php b/tests/php/Unit/Service/Policy/Model/PolicySpecTest.php new file mode 100644 index 0000000000..0ee643f495 --- /dev/null +++ b/tests/php/Unit/Service/Policy/Model/PolicySpecTest.php @@ -0,0 +1,66 @@ +assertSame('signature_flow', $spec->getAppConfigKey()); + $this->assertSame('policy.signature_flow', $spec->getUserPreferenceKey()); + } + + public function testNormalizerAndValidatorAreApplied(): void { + $spec = new PolicySpec( + key: 'signature_flow', + defaultSystemValue: 'none', + allowedValues: ['none', 'parallel', 'ordered_numeric'], + normalizer: static fn (mixed $value): mixed => $value === 2 ? 'ordered_numeric' : $value, + ); + + $this->assertSame('ordered_numeric', $spec->normalizeValue(2)); + $spec->validateValue('parallel', new PolicyContext()); + $this->expectException(\InvalidArgumentException::class); + $spec->validateValue('invalid', new PolicyContext()); + } + + public function testAllowedValuesMayDependOnContext(): void { + $spec = new PolicySpec( + key: 'signature_flow', + defaultSystemValue: 'none', + allowedValues: static fn (PolicyContext $context): array => $context->getUserId() === 'john' + ? ['parallel'] + : ['none'], + ); + + $this->assertSame(['parallel'], $spec->allowedValues(PolicyContext::fromUserId('john'))); + $this->assertSame(['none'], $spec->allowedValues(new PolicyContext())); + } + + public function testValidationUsesProvidedContext(): void { + $spec = new PolicySpec( + key: 'signature_flow', + defaultSystemValue: 'none', + allowedValues: static fn (PolicyContext $context): array => $context->getUserId() === 'john' ? ['parallel'] : ['none'], + ); + + $spec->validateValue('parallel', PolicyContext::fromUserId('john')); + + $this->expectException(\InvalidArgumentException::class); + $spec->validateValue('parallel', new PolicyContext()); + } +} diff --git a/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php b/tests/php/Unit/Service/Policy/Model/ResolvedPolicyTest.php similarity index 96% rename from tests/php/Unit/Service/Policy/ResolvedPolicyTest.php rename to tests/php/Unit/Service/Policy/Model/ResolvedPolicyTest.php index 29a77bd6b2..d9d9f992c6 100644 --- a/tests/php/Unit/Service/Policy/ResolvedPolicyTest.php +++ b/tests/php/Unit/Service/Policy/Model/ResolvedPolicyTest.php @@ -6,9 +6,9 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Service\Policy; +namespace OCA\Libresign\Tests\Unit\Service\Policy\Model; -use OCA\Libresign\Service\Policy\ResolvedPolicy; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use PHPUnit\Framework\TestCase; final class ResolvedPolicyTest extends TestCase { diff --git a/tests/php/Unit/Service/Policy/PolicyServiceTest.php b/tests/php/Unit/Service/Policy/PolicyServiceTest.php new file mode 100644 index 0000000000..db2d14193a --- /dev/null +++ b/tests/php/Unit/Service/Policy/PolicyServiceTest.php @@ -0,0 +1,191 @@ +userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->source = $this->createMock(PolicySource::class); + $container = $this->createMock(ContainerInterface::class); + $container + ->method('get') + ->with(SignatureFlowPolicy::class) + ->willReturn(new SignatureFlowPolicy()); + $this->registry = new PolicyRegistry($container); + $this->contextFactory = new PolicyContextFactory($this->userManager, $this->groupManager, $this->userSession); + } + + public function testResolveForUserIdBuildsContextWithGroupsAndRequestOverride(): void { + $user = $this->createMock(IUser::class); + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('john') + ->willReturn($user); + + $this->groupManager + ->expects($this->once()) + ->method('getUserGroupIds') + ->with($user) + ->willReturn(['finance']); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true)); + + $this->source + ->method('loadGroupPolicies') + ->willReturn([(new PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel', 'ordered_numeric'])]); + + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source->method('loadUserPreference')->willReturn(null); + $this->source + ->method('loadRequestOverride') + ->willReturn((new PolicyLayer()) + ->setScope('request') + ->setValue('ordered_numeric')); + + $service = new PolicyService( + $this->contextFactory, + $this->source, + $this->registry, + ); + + $resolved = $service->resolveForUserId(SignatureFlowPolicy::KEY, 'john', [SignatureFlowPolicy::KEY => 'ordered_numeric']); + + $this->assertInstanceOf(ResolvedPolicy::class, $resolved); + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('request', $resolved->getSourceScope()); + } + + public function testResolveForUserIdWithoutUserFallsBackToSystem(): void { + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('ghost') + ->willReturn(null); + + $this->groupManager + ->expects($this->never()) + ->method('getUserGroupIds'); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new PolicyLayer()) + ->setScope('system') + ->setValue('parallel') + ->setAllowChildOverride(false) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel'])); + + $this->source->method('loadGroupPolicies')->willReturn([]); + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source->method('loadUserPreference')->willReturn(null); + $this->source->method('loadRequestOverride')->willReturn(null); + + $service = new PolicyService( + $this->contextFactory, + $this->source, + $this->registry, + ); + + $resolved = $service->resolveForUserId(SignatureFlowPolicy::KEY, 'ghost'); + + $this->assertSame('parallel', $resolved->getEffectiveValue()); + $this->assertSame('system', $resolved->getSourceScope()); + } + + public function testResolveUsesCurrentUserFromSession(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john'); + + $this->userSession + ->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->userManager + ->expects($this->never()) + ->method('get') + ->with('john'); + + $this->groupManager + ->expects($this->once()) + ->method('getUserGroupIds') + ->with($user) + ->willReturn(['finance']); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new PolicyLayer()) + ->setScope('system') + ->setValue('none') + ->setAllowChildOverride(true) + ->setVisibleToChild(true)); + + $this->source + ->method('loadGroupPolicies') + ->willReturn([(new PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues(['parallel', 'ordered_numeric'])]); + + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source->method('loadUserPreference')->willReturn(null); + $this->source->method('loadRequestOverride')->willReturn(null); + + $service = new PolicyService( + $this->contextFactory, + $this->source, + $this->registry, + ); + + $resolved = $service->resolve(SignatureFlowPolicy::KEY); + + $this->assertInstanceOf(ResolvedPolicy::class, $resolved); + $this->assertSame('parallel', $resolved->getEffectiveValue()); + $this->assertSame('group', $resolved->getSourceScope()); + } +} diff --git a/tests/php/Unit/Service/Policy/Provider/Signature/SignatureFlowPolicyTest.php b/tests/php/Unit/Service/Policy/Provider/Signature/SignatureFlowPolicyTest.php new file mode 100644 index 0000000000..b01331a37b --- /dev/null +++ b/tests/php/Unit/Service/Policy/Provider/Signature/SignatureFlowPolicyTest.php @@ -0,0 +1,26 @@ +assertSame([SignatureFlowPolicy::KEY], $provider->keys()); + $definition = $provider->get(SignatureFlowPolicy::KEY); + + $this->assertSame(SignatureFlowPolicy::KEY, $definition->key()); + $this->assertSame('none', $definition->defaultSystemValue()); + $this->assertSame(['none', 'parallel', 'ordered_numeric'], $definition->allowedValues(new PolicyContext())); + $this->assertSame('ordered_numeric', $definition->normalizeValue(2)); + } +} diff --git a/tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php b/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php similarity index 71% rename from tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php rename to tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php index bc2ec329d8..10971d87ba 100644 --- a/tests/php/Unit/Service/Policy/DefaultPolicyResolverTest.php +++ b/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php @@ -6,23 +6,20 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Service\Policy; +namespace OCA\Libresign\Tests\Unit\Service\Policy\Runtime; -use OCA\Libresign\Service\Policy\DefaultPolicyResolver; -use OCA\Libresign\Service\Policy\PolicyContext; -use OCA\Libresign\Service\Policy\PolicyDefinitionInterface; -use OCA\Libresign\Service\Policy\PolicyLayer; -use OCA\Libresign\Service\Policy\PolicySourceInterface; +use OCA\Libresign\Service\Policy\Contract\IPolicySource; +use OCA\Libresign\Service\Policy\Model\PolicyContext; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; +use OCA\Libresign\Service\Policy\Model\PolicySpec; +use OCA\Libresign\Service\Policy\Runtime\DefaultPolicyResolver; use PHPUnit\Framework\TestCase; final class DefaultPolicyResolverTest extends TestCase { public function testResolveUsesDefinitionDefaultWhenNoLayersExist(): void { - $resolver = new DefaultPolicyResolver( - new InMemoryPolicySource(), - [new TestPolicyDefinition()] - ); + $resolver = new DefaultPolicyResolver(new InMemoryPolicySource()); - $resolved = $resolver->resolve('signature_flow', new PolicyContext()); + $resolved = $resolver->resolve($this->getDefinition(), new PolicyContext()); $this->assertSame('none', $resolved->getEffectiveValue()); $this->assertSame('system', $resolved->getSourceScope()); @@ -46,8 +43,8 @@ public function testResolveAppliesGroupValueWhenSystemAllowsOverride(): void { ->setAllowedValues(['parallel', 'ordered_numeric']), ]; - $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); - $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + $resolver = new DefaultPolicyResolver($source); + $resolved = $resolver->resolve($this->getDefinition(), PolicyContext::fromUserId('john')); $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); $this->assertSame('group', $resolved->getSourceScope()); @@ -75,8 +72,8 @@ public function testResolveClearsInvalidUserPreferenceWhenGroupBlocksOverride(): ->setScope('user') ->setValue('ordered_numeric'); - $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); - $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + $resolver = new DefaultPolicyResolver($source); + $resolved = $resolver->resolve($this->getDefinition(), PolicyContext::fromUserId('john')); $this->assertSame('parallel', $resolved->getEffectiveValue()); $this->assertSame('group', $resolved->getSourceScope()); @@ -106,17 +103,25 @@ public function testResolveAppliesRequestOverrideWhenAllowed(): void { ->setScope('request') ->setValue('ordered_numeric'); - $resolver = new DefaultPolicyResolver($source, [new TestPolicyDefinition()]); - $resolved = $resolver->resolve('signature_flow', PolicyContext::fromUserId('john')); + $resolver = new DefaultPolicyResolver($source); + $resolved = $resolver->resolve($this->getDefinition(), PolicyContext::fromUserId('john')); $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); $this->assertSame('request', $resolved->getSourceScope()); $this->assertTrue($resolved->canUseAsRequestOverride()); $this->assertNull($resolved->getBlockedBy()); } + + private function getDefinition(): PolicySpec { + return new PolicySpec( + key: 'signature_flow', + defaultSystemValue: 'none', + allowedValues: ['none', 'parallel', 'ordered_numeric'], + ); + } } -final class InMemoryPolicySource implements PolicySourceInterface { +final class InMemoryPolicySource implements IPolicySource { public ?PolicyLayer $systemLayer = null; /** @var list */ public array $groupLayers = []; @@ -148,27 +153,3 @@ public function clearUserPreference(string $policyKey, PolicyContext $context): $this->userPreferenceCleared = true; } } - -final class TestPolicyDefinition implements PolicyDefinitionInterface { - public function key(): string { - return 'signature_flow'; - } - - public function normalizeValue(mixed $rawValue): mixed { - return $rawValue; - } - - public function validateValue(mixed $value): void { - if (!in_array($value, $this->allowedValues(new PolicyContext()), true)) { - throw new \InvalidArgumentException('Invalid value'); - } - } - - public function allowedValues(PolicyContext $context): array { - return ['none', 'parallel', 'ordered_numeric']; - } - - public function defaultSystemValue(): mixed { - return 'none'; - } -} diff --git a/tests/php/Unit/Service/Policy/Runtime/PolicyContextFactoryTest.php b/tests/php/Unit/Service/Policy/Runtime/PolicyContextFactoryTest.php new file mode 100644 index 0000000000..a556533c90 --- /dev/null +++ b/tests/php/Unit/Service/Policy/Runtime/PolicyContextFactoryTest.php @@ -0,0 +1,73 @@ +userManager = $this->createMock(IUserManager::class); + $this->groupManager = $this->createMock(IGroupManager::class); + $this->userSession = $this->createMock(IUserSession::class); + } + + public function testForCurrentUserUsesSessionUser(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('john'); + + $this->userSession->expects($this->once())->method('getUser')->willReturn($user); + $this->groupManager->expects($this->once())->method('getUserGroupIds')->with($user)->willReturn(['finance']); + + $factory = $this->getFactory(); + $context = $factory->forCurrentUser(['signature_flow' => 'parallel'], ['type' => 'group', 'id' => 'finance']); + + $this->assertSame('john', $context->getUserId()); + $this->assertSame(['finance'], $context->getGroups()); + $this->assertSame(['signature_flow' => 'parallel'], $context->getRequestOverrides()); + $this->assertSame(['type' => 'group', 'id' => 'finance'], $context->getActiveContext()); + } + + public function testForUserIdLoadsUserWhenAvailable(): void { + $user = $this->createMock(IUser::class); + $this->userManager->expects($this->once())->method('get')->with('john')->willReturn($user); + $this->groupManager->expects($this->once())->method('getUserGroupIds')->with($user)->willReturn(['finance']); + + $factory = $this->getFactory(); + $context = $factory->forUserId('john'); + + $this->assertSame('john', $context->getUserId()); + $this->assertSame(['finance'], $context->getGroups()); + } + + public function testForUserIdKeepsUserIdWithoutGroupsWhenUserDoesNotExist(): void { + $this->userManager->expects($this->once())->method('get')->with('ghost')->willReturn(null); + $this->groupManager->expects($this->never())->method('getUserGroupIds'); + + $factory = $this->getFactory(); + $context = $factory->forUserId('ghost'); + + $this->assertSame('ghost', $context->getUserId()); + $this->assertSame([], $context->getGroups()); + } + + private function getFactory(): PolicyContextFactory { + return new PolicyContextFactory($this->userManager, $this->groupManager, $this->userSession); + } +} diff --git a/tests/php/Unit/Service/Policy/Runtime/PolicyRegistryTest.php b/tests/php/Unit/Service/Policy/Runtime/PolicyRegistryTest.php new file mode 100644 index 0000000000..c2c20a921a --- /dev/null +++ b/tests/php/Unit/Service/Policy/Runtime/PolicyRegistryTest.php @@ -0,0 +1,75 @@ +createMock(ContainerInterface::class); + $container->method('get')->with(SignatureFlowPolicy::class)->willReturn(new SignatureFlowPolicy()); + $registry = new PolicyRegistry($container); + $definition = $registry->get(SignatureFlowPolicy::KEY); + + $this->assertSame(SignatureFlowPolicy::KEY, $definition->key()); + $this->assertSame('none', $definition->defaultSystemValue()); + $this->assertSame(['none', 'parallel', 'ordered_numeric'], $definition->allowedValues(new PolicyContext())); + $this->assertSame('ordered_numeric', $definition->normalizeValue(2)); + } + + public function testRegistryThrowsForUnknownPolicy(): void { + $this->expectException(\InvalidArgumentException::class); + + $container = $this->createMock(ContainerInterface::class); + $container->method('get')->with(SignatureFlowPolicy::class)->willReturn(new SignatureFlowPolicy()); + $registry = new PolicyRegistry($container); + $registry->get('unknown_policy'); + } + + public function testRegistryCachesDefinitionAfterFirstLookup(): void { + $provider = new CountingPolicyDefinitionProvider(); + $container = $this->createMock(ContainerInterface::class); + $container->expects($this->once()) + ->method('get') + ->with(SignatureFlowPolicy::class) + ->willReturn($provider); + $registry = new PolicyRegistry($container); + + $first = $registry->get(SignatureFlowPolicy::KEY); + $second = $registry->get(SignatureFlowPolicy::KEY); + + $this->assertSame($first, $second); + $this->assertSame(1, $provider->calls); + } +} + +final class CountingPolicyDefinitionProvider implements IPolicyDefinitionProvider { + public int $calls = 0; + + public function keys(): array { + return [SignatureFlowPolicy::KEY]; + } + + public function get(string|\BackedEnum $policyKey): IPolicyDefinition { + ++$this->calls; + + return new PolicySpec( + key: SignatureFlowPolicy::KEY, + defaultSystemValue: 'none', + allowedValues: ['none', 'parallel', 'ordered_numeric'], + ); + } +} diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php b/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php similarity index 82% rename from tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php rename to tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php index 2fc5e9b47b..63efe456bd 100644 --- a/tests/php/Unit/Service/Policy/SignatureFlowPolicySourceTest.php +++ b/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php @@ -6,28 +6,38 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -namespace OCA\Libresign\Tests\Unit\Service\Policy; +namespace OCA\Libresign\Tests\Unit\Service\Policy\Runtime; use OCA\Libresign\Db\PermissionSet; use OCA\Libresign\Db\PermissionSetBinding; use OCA\Libresign\Db\PermissionSetBindingMapper; use OCA\Libresign\Db\PermissionSetMapper; -use OCA\Libresign\Service\Policy\PolicyContext; -use OCA\Libresign\Service\Policy\SignatureFlowPolicySource; +use OCA\Libresign\Service\Policy\Model\PolicyContext; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; +use OCA\Libresign\Service\Policy\Runtime\PolicyRegistry; +use OCA\Libresign\Service\Policy\Runtime\PolicySource; use OCP\AppFramework\Services\IAppConfig; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; -final class SignatureFlowPolicySourceTest extends TestCase { +final class PolicySourceTest extends TestCase { private IAppConfig&MockObject $appConfig; private PermissionSetMapper&MockObject $permissionSetMapper; private PermissionSetBindingMapper&MockObject $bindingMapper; + private PolicyRegistry $registry; protected function setUp(): void { parent::setUp(); $this->appConfig = $this->createMock(IAppConfig::class); $this->permissionSetMapper = $this->createMock(PermissionSetMapper::class); $this->bindingMapper = $this->createMock(PermissionSetBindingMapper::class); + $container = $this->createMock(ContainerInterface::class); + $container + ->method('get') + ->with(SignatureFlowPolicy::class) + ->willReturn(new SignatureFlowPolicy()); + $this->registry = new PolicyRegistry($container); } public function testLoadSystemPolicyReturnsForcedLayerWhenAppConfigIsSet(): void { @@ -47,7 +57,7 @@ public function testLoadSystemPolicyReturnsForcedLayerWhenAppConfigIsSet(): void $this->assertSame(['ordered_numeric'], $layer->getAllowedValues()); } - public function testLoadSystemPolicyReturnsInheritableLayerWhenAppConfigIsNone(): void { + public function testLoadSystemPolicyReturnsInheritableLayerWhenAppConfigMatchesDefault(): void { $this->appConfig ->expects($this->once()) ->method('getAppValueString') @@ -70,6 +80,7 @@ public function testLoadGroupPoliciesReturnsBoundPermissionSetForActiveGroup(): $binding->setTargetId('finance'); $permissionSet = new PermissionSet(); + $permissionSet->setId(77); $permissionSet->setPolicyJson([ 'signature_flow' => [ 'defaultValue' => 'ordered_numeric', @@ -81,15 +92,15 @@ public function testLoadGroupPoliciesReturnsBoundPermissionSetForActiveGroup(): $this->bindingMapper ->expects($this->once()) - ->method('getByTarget') - ->with('group', 'finance') - ->willReturn($binding); + ->method('findByTargets') + ->with('group', ['finance']) + ->willReturn([$binding]); $this->permissionSetMapper ->expects($this->once()) - ->method('getById') - ->with(77) - ->willReturn($permissionSet); + ->method('findByIds') + ->with([77]) + ->willReturn([$permissionSet]); $context = PolicyContext::fromUserId('john') ->setGroups(['finance']) @@ -142,11 +153,12 @@ public function testLoadRequestOverrideReturnsLayerFromContext(): void { $this->assertSame('ordered_numeric', $layer->getValue()); } - private function getSource(): SignatureFlowPolicySource { - return new SignatureFlowPolicySource( + private function getSource(): PolicySource { + return new PolicySource( $this->appConfig, $this->permissionSetMapper, $this->bindingMapper, + $this->registry, ); } } diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php b/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php deleted file mode 100644 index 68da687fc1..0000000000 --- a/tests/php/Unit/Service/Policy/SignatureFlowPolicyDefinitionTest.php +++ /dev/null @@ -1,39 +0,0 @@ -assertSame('signature_flow', $definition->key()); - $this->assertSame('none', $definition->defaultSystemValue()); - $this->assertSame(['none', 'parallel', 'ordered_numeric'], $definition->allowedValues(new PolicyContext())); - } - - public function testNormalizeValueConvertsNumericValues(): void { - $definition = new SignatureFlowPolicyDefinition(); - - $this->assertSame('none', $definition->normalizeValue(0)); - $this->assertSame('parallel', $definition->normalizeValue(1)); - $this->assertSame('ordered_numeric', $definition->normalizeValue(2)); - $this->assertSame('parallel', $definition->normalizeValue('parallel')); - } - - public function testValidateValueRejectsUnexpectedValue(): void { - $this->expectException(\InvalidArgumentException::class); - - $definition = new SignatureFlowPolicyDefinition(); - $definition->validateValue('invalid'); - } -} diff --git a/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php b/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php deleted file mode 100644 index f0f4718b81..0000000000 --- a/tests/php/Unit/Service/Policy/SignatureFlowPolicyServiceTest.php +++ /dev/null @@ -1,126 +0,0 @@ -userManager = $this->createMock(IUserManager::class); - $this->groupManager = $this->createMock(IGroupManager::class); - $this->source = $this->createMock(SignatureFlowPolicySource::class); - } - - public function testResolveForUserIdBuildsContextWithGroupsAndRequestOverride(): void { - $user = $this->createMock(IUser::class); - $this->userManager - ->expects($this->once()) - ->method('get') - ->with('john') - ->willReturn($user); - - $this->groupManager - ->expects($this->once()) - ->method('getUserGroupIds') - ->with($user) - ->willReturn(['finance']); - - $this->source - ->method('loadSystemPolicy') - ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) - ->setScope('system') - ->setValue('none') - ->setAllowChildOverride(true) - ->setVisibleToChild(true)); - - $this->source - ->method('loadGroupPolicies') - ->willReturn([(new \OCA\Libresign\Service\Policy\PolicyLayer()) - ->setScope('group') - ->setValue('parallel') - ->setAllowChildOverride(true) - ->setVisibleToChild(true) - ->setAllowedValues(['parallel', 'ordered_numeric'])]); - - $this->source - ->method('loadCirclePolicies') - ->willReturn([]); - - $this->source - ->method('loadUserPreference') - ->willReturn(null); - - $this->source - ->method('loadRequestOverride') - ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) - ->setScope('request') - ->setValue('ordered_numeric')); - - $service = new SignatureFlowPolicyService( - $this->userManager, - $this->groupManager, - $this->source, - ); - - $resolved = $service->resolveForUserId('john', ['signature_flow' => 'ordered_numeric']); - - $this->assertInstanceOf(ResolvedPolicy::class, $resolved); - $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); - $this->assertSame('request', $resolved->getSourceScope()); - } - - public function testResolveForUserIdWithoutUserFallsBackToSystem(): void { - $this->userManager - ->expects($this->once()) - ->method('get') - ->with('ghost') - ->willReturn(null); - - $this->groupManager - ->expects($this->never()) - ->method('getUserGroupIds'); - - $this->source - ->method('loadSystemPolicy') - ->willReturn((new \OCA\Libresign\Service\Policy\PolicyLayer()) - ->setScope('system') - ->setValue('parallel') - ->setAllowChildOverride(false) - ->setVisibleToChild(true) - ->setAllowedValues(['parallel'])); - - $this->source->method('loadGroupPolicies')->willReturn([]); - $this->source->method('loadCirclePolicies')->willReturn([]); - $this->source->method('loadUserPreference')->willReturn(null); - $this->source->method('loadRequestOverride')->willReturn(null); - - $service = new SignatureFlowPolicyService( - $this->userManager, - $this->groupManager, - $this->source, - ); - - $resolved = $service->resolveForUserId('ghost'); - - $this->assertSame('parallel', $resolved->getEffectiveValue()); - $this->assertSame('system', $resolved->getSourceScope()); - } -} diff --git a/tests/php/Unit/Service/RequestSignatureServiceTest.php b/tests/php/Unit/Service/RequestSignatureServiceTest.php index 0b218e0764..43ebcc1459 100644 --- a/tests/php/Unit/Service/RequestSignatureServiceTest.php +++ b/tests/php/Unit/Service/RequestSignatureServiceTest.php @@ -31,8 +31,9 @@ use OCA\Libresign\Service\FileStatusService; use OCA\Libresign\Service\FolderService; use OCA\Libresign\Service\IdentifyMethodService; -use OCA\Libresign\Service\Policy\ResolvedPolicy; -use OCA\Libresign\Service\Policy\SignatureFlowPolicyService; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignRequest\SignRequestService; @@ -80,7 +81,7 @@ final class RequestSignatureServiceTest extends \OCA\Libresign\Tests\Unit\TestCa private EnvelopeFileRelocator&MockObject $envelopeFileRelocator; private FileUploadHelper&MockObject $uploadHelper; private SignRequestService&MockObject $signRequestService; - private SignatureFlowPolicyService&MockObject $signatureFlowPolicyService; + private PolicyService&MockObject $policyService; public function setUp(): void { parent::setUp(); @@ -115,7 +116,7 @@ public function setUp(): void { $this->envelopeFileRelocator = $this->createMock(EnvelopeFileRelocator::class); $this->uploadHelper = $this->createMock(FileUploadHelper::class); $this->signRequestService = $this->createMock(SignRequestService::class); - $this->signatureFlowPolicyService = $this->createMock(SignatureFlowPolicyService::class); + $this->policyService = $this->createMock(PolicyService::class); } private function getService(array $methods = []): RequestSignatureService|MockObject { @@ -147,7 +148,7 @@ private function getService(array $methods = []): RequestSignatureService|MockOb $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, - $this->signatureFlowPolicyService, + $this->policyService, ]) ->onlyMethods($methods) ->getMock(); @@ -179,7 +180,7 @@ private function getService(array $methods = []): RequestSignatureService|MockOb $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, - $this->signatureFlowPolicyService, + $this->policyService, ); } @@ -484,7 +485,7 @@ public function testDeleteIdentifyMethodIfNotExitsKeepsMatchingIdentifyMethods() $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, - $this->signatureFlowPolicyService, + $this->policyService, ]) ->onlyMethods(['unassociateToUser']) ->getMock(); @@ -557,7 +558,7 @@ public function testDeleteIdentifyMethodIfNotExitsRemovesMissingIdentifyMethods( $this->envelopeFileRelocator, $this->uploadHelper, $this->signRequestService, - $this->signatureFlowPolicyService, + $this->policyService, ]) ->onlyMethods(['unassociateToUser']) ->getMock(); @@ -845,10 +846,10 @@ public function testSaveEnvelopeExtractsFileDescriptorFromNestedFilesArrayItems( public function testSetSignatureFlowPrefersPayloadOverGlobalConfig(): void { $file = new \OCA\Libresign\Db\File(); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUser') - ->with(null, ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->with(SignatureFlowPolicy::KEY, null, [SignatureFlowPolicy::KEY => SignatureFlow::PARALLEL->value]) ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); self::invokePrivate($this->getService(), 'setSignatureFlow', [ @@ -861,10 +862,10 @@ public function testSetSignatureFlowPrefersPayloadOverGlobalConfig(): void { public function testSetSignatureFlowUsesGlobalConfigWhenPayloadMissing(): void { $file = new \OCA\Libresign\Db\File(); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUser') - ->with(null, []) + ->with(SignatureFlowPolicy::KEY, null, []) ->willReturn($this->createResolvedPolicy(SignatureFlow::ORDERED_NUMERIC->value)); self::invokePrivate($this->getService(), 'setSignatureFlow', [ @@ -877,10 +878,10 @@ public function testSetSignatureFlowUsesGlobalConfigWhenPayloadMissing(): void { public function testSetSignatureFlowDefaultsToNoneWithoutPayloadOrGlobalConfig(): void { $file = new \OCA\Libresign\Db\File(); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUser') - ->with(null, []) + ->with(SignatureFlowPolicy::KEY, null, []) ->willReturn($this->createResolvedPolicy(SignatureFlow::NONE->value)); self::invokePrivate($this->getService(), 'setSignatureFlow', [ @@ -891,14 +892,63 @@ public function testSetSignatureFlowDefaultsToNoneWithoutPayloadOrGlobalConfig() $this->assertSame(SignatureFlow::NONE, $file->getSignatureFlowEnum()); } + public function testSetSignatureFlowThrowsWhenRequestOverrideIsBlocked(): void { + $file = new \OCA\Libresign\Db\File(); + $this->policyService + ->expects($this->once()) + ->method('resolveForUser') + ->with(SignatureFlowPolicy::KEY, null, [SignatureFlowPolicy::KEY => SignatureFlow::PARALLEL->value]) + ->willReturn($this->createResolvedPolicy( + SignatureFlow::ORDERED_NUMERIC->value, + sourceScope: 'group', + canUseAsRequestOverride: false, + blockedBy: 'group', + )); + + $this->expectException(LibresignException::class); + $this->expectExceptionCode(422); + + self::invokePrivate($this->getService(), 'setSignatureFlow', [ + $file, + ['signatureFlow' => SignatureFlow::PARALLEL->value], + ]); + } + + public function testSetSignatureFlowStoresResolvedPolicySnapshotInMetadata(): void { + $file = new \OCA\Libresign\Db\File(); + $this->policyService + ->expects($this->once()) + ->method('resolveForUser') + ->with(SignatureFlowPolicy::KEY, null, []) + ->willReturn($this->createResolvedPolicy( + SignatureFlow::ORDERED_NUMERIC->value, + sourceScope: 'group', + )); + + self::invokePrivate($this->getService(), 'setSignatureFlow', [ + $file, + [], + ]); + + $this->assertSame(SignatureFlow::ORDERED_NUMERIC, $file->getSignatureFlowEnum()); + $this->assertSame([ + 'policy_snapshot' => [ + 'signature_flow' => [ + 'effectiveValue' => SignatureFlow::ORDERED_NUMERIC->value, + 'sourceScope' => 'group', + ], + ], + ], $file->getMetadata()); + } + public function testUpdateSignatureFlowIfAllowedForcesGlobalConfigOverFileValue(): void { $file = new \OCA\Libresign\Db\File(); $file->setUserId('john'); $file->setSignatureFlowEnum(SignatureFlow::PARALLEL); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUserId') - ->with('john', ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->with(SignatureFlowPolicy::KEY, 'john', [SignatureFlowPolicy::KEY => SignatureFlow::PARALLEL->value]) ->willReturn($this->createResolvedPolicy(SignatureFlow::ORDERED_NUMERIC->value)); $this->fileService @@ -918,10 +968,10 @@ public function testUpdateSignatureFlowIfAllowedUsesPayloadWhenGlobalConfigNotFo $file = new \OCA\Libresign\Db\File(); $file->setUserId('john'); $file->setSignatureFlowEnum(SignatureFlow::NONE); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUserId') - ->with('john', ['signature_flow' => SignatureFlow::PARALLEL->value]) + ->with(SignatureFlowPolicy::KEY, 'john', [SignatureFlowPolicy::KEY => SignatureFlow::PARALLEL->value]) ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); $this->fileService @@ -937,14 +987,42 @@ public function testUpdateSignatureFlowIfAllowedUsesPayloadWhenGlobalConfigNotFo $this->assertSame(SignatureFlow::PARALLEL, $file->getSignatureFlowEnum()); } + public function testUpdateSignatureFlowIfAllowedThrowsWhenRequestOverrideIsBlocked(): void { + $file = new \OCA\Libresign\Db\File(); + $file->setUserId('john'); + $file->setSignatureFlowEnum(SignatureFlow::NONE); + $this->policyService + ->expects($this->once()) + ->method('resolveForUserId') + ->with(SignatureFlowPolicy::KEY, 'john', [SignatureFlowPolicy::KEY => SignatureFlow::PARALLEL->value]) + ->willReturn($this->createResolvedPolicy( + SignatureFlow::ORDERED_NUMERIC->value, + sourceScope: 'group', + canUseAsRequestOverride: false, + blockedBy: 'group', + )); + + $this->fileService + ->expects($this->never()) + ->method('update'); + + $this->expectException(LibresignException::class); + $this->expectExceptionCode(422); + + self::invokePrivate($this->getService(), 'updateSignatureFlowIfAllowed', [ + $file, + ['signatureFlow' => SignatureFlow::PARALLEL->value], + ]); + } + public function testUpdateSignatureFlowIfAllowedKeepsCurrentValueWithoutPayloadOrForcedGlobal(): void { $file = new \OCA\Libresign\Db\File(); $file->setUserId('john'); $file->setSignatureFlowEnum(SignatureFlow::PARALLEL); - $this->signatureFlowPolicyService + $this->policyService ->expects($this->once()) ->method('resolveForUserId') - ->with('john', []) + ->with(SignatureFlowPolicy::KEY, 'john', []) ->willReturn($this->createResolvedPolicy(SignatureFlow::PARALLEL->value)); $this->fileService @@ -959,10 +1037,17 @@ public function testUpdateSignatureFlowIfAllowedKeepsCurrentValueWithoutPayloadO $this->assertSame(SignatureFlow::PARALLEL, $file->getSignatureFlowEnum()); } - private function createResolvedPolicy(string $effectiveValue): ResolvedPolicy { + private function createResolvedPolicy( + string $effectiveValue, + string $sourceScope = 'system', + bool $canUseAsRequestOverride = true, + ?string $blockedBy = null, + ): ResolvedPolicy { return (new ResolvedPolicy()) ->setPolicyKey('signature_flow') ->setEffectiveValue($effectiveValue) - ->setSourceScope('system'); + ->setSourceScope($sourceScope) + ->setCanUseAsRequestOverride($canUseAsRequestOverride) + ->setBlockedBy($blockedBy); } } From 9e49d768430f39016818d7122dafaec2e97d6875 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 03:58:54 -0300 Subject: [PATCH 038/417] fix(migration): reference permission set table in foreign key Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Version18000Date20260317000000.php | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/lib/Migration/Version18000Date20260317000000.php b/lib/Migration/Version18000Date20260317000000.php index fd6fde84c3..a06a974ee0 100644 --- a/lib/Migration/Version18000Date20260317000000.php +++ b/lib/Migration/Version18000Date20260317000000.php @@ -23,44 +23,46 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); - if (!$schema->hasTable('libresign_permission_set')) { - $table = $schema->createTable('libresign_permission_set'); - $table->addColumn('id', Types::INTEGER, [ + if ($schema->hasTable('libresign_permission_set')) { + $permissionSetTable = $schema->getTable('libresign_permission_set'); + } else { + $permissionSetTable = $schema->createTable('libresign_permission_set'); + $permissionSetTable->addColumn('id', Types::INTEGER, [ 'autoincrement' => true, 'notnull' => true, 'unsigned' => true, ]); - $table->addColumn('name', Types::STRING, [ + $permissionSetTable->addColumn('name', Types::STRING, [ 'notnull' => true, 'length' => 255, ]); - $table->addColumn('description', Types::TEXT, [ + $permissionSetTable->addColumn('description', Types::TEXT, [ 'notnull' => false, ]); - $table->addColumn('scope_type', Types::STRING, [ + $permissionSetTable->addColumn('scope_type', Types::STRING, [ 'notnull' => true, 'length' => 64, ]); - $table->addColumn('enabled', Types::SMALLINT, [ + $permissionSetTable->addColumn('enabled', Types::SMALLINT, [ 'notnull' => true, 'default' => 1, ]); - $table->addColumn('priority', Types::SMALLINT, [ + $permissionSetTable->addColumn('priority', Types::SMALLINT, [ 'notnull' => true, 'default' => 0, ]); - $table->addColumn('policy_json', Types::TEXT, [ + $permissionSetTable->addColumn('policy_json', Types::TEXT, [ 'notnull' => true, 'default' => '{}', ]); - $table->addColumn('created_at', Types::DATETIME, [ + $permissionSetTable->addColumn('created_at', Types::DATETIME, [ 'notnull' => true, ]); - $table->addColumn('updated_at', Types::DATETIME, [ + $permissionSetTable->addColumn('updated_at', Types::DATETIME, [ 'notnull' => true, ]); - $table->setPrimaryKey(['id']); - $table->addIndex(['scope_type'], 'ls_perm_set_scope_idx'); + $permissionSetTable->setPrimaryKey(['id']); + $permissionSetTable->addIndex(['scope_type'], 'ls_perm_set_scope_idx'); } if (!$schema->hasTable('libresign_permission_set_binding')) { @@ -88,7 +90,7 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $table->setPrimaryKey(['id']); $table->addIndex(['permission_set_id'], 'ls_perm_bind_set_idx'); $table->addUniqueIndex(['target_type', 'target_id'], 'ls_perm_bind_target_uidx'); - $table->addForeignKeyConstraint('libresign_permission_set', ['permission_set_id'], ['id'], [ + $table->addForeignKeyConstraint($permissionSetTable, ['permission_set_id'], ['id'], [ 'onDelete' => 'CASCADE', ]); } From 9cc42f7f14509fd67c282261745f5ad3168e231c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:19:29 -0300 Subject: [PATCH 039/417] fix(openapi): declare validate metadata policy snapshot Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cb386d9f76..8224cfd125 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -354,11 +354,19 @@ * * Validation and progress contracts * + * @psalm-type LibresignPolicySnapshotEntry = array{ + * effectiveValue: string, + * sourceScope: string, + * } + * @psalm-type LibresignValidatePolicySnapshot = array{ + * signature_flow?: LibresignPolicySnapshotEntry, + * } * @psalm-type LibresignValidateMetadata = array{ * extension: string, * p: int, * d?: list, * original_file_deleted?: bool, + * policy_snapshot?: LibresignValidatePolicySnapshot, * pdfVersion?: string, * status_changed_at?: string, * } From 621d973679142a4c55814f42db1a961bc194c1a4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:19:37 -0300 Subject: [PATCH 040/417] chore(openapi): regenerate validate metadata schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/openapi.json b/openapi.json index e3fac16bee..1176b09fef 100644 --- a/openapi.json +++ b/openapi.json @@ -1386,6 +1386,21 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2023,6 +2038,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2031,6 +2049,14 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ From 726bd7ca2555c83345dea9d3e321ae930508f477 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:19:45 -0300 Subject: [PATCH 041/417] chore(openapi): regenerate full validate metadata schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 157caffe52..8e686f3f90 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1862,6 +1862,21 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2635,6 +2650,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2643,6 +2661,14 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ From 7486f85459ff68b82cb9df3cefb9523870ebeda9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:19:53 -0300 Subject: [PATCH 042/417] chore(types): regenerate openapi validate metadata types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 83f883b799..6b9c2b4fe9 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -1426,6 +1426,10 @@ export type components = { last: string | null; first: string | null; }; + PolicySnapshotEntry: { + effectiveValue: string; + sourceScope: string; + }; ProgressError: { message: string; /** Format: int64 */ @@ -1615,9 +1619,13 @@ export type components = { h: number; }[]; original_file_deleted?: boolean; + policy_snapshot?: components["schemas"]["ValidatePolicySnapshot"]; pdfVersion?: string; status_changed_at?: string; }; + ValidatePolicySnapshot: { + signature_flow?: components["schemas"]["PolicySnapshotEntry"]; + }; ValidatedChildFile: { /** Format: int64 */ id: number; From 8bea66cb385cbd218d192a3bcada56c57b56730d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 04:19:59 -0300 Subject: [PATCH 043/417] chore(types): regenerate full openapi validate metadata types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 772db24646..a5e1489ccd 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -2034,6 +2034,10 @@ export type components = { OID: string; CPS: string; }; + PolicySnapshotEntry: { + effectiveValue: string; + sourceScope: string; + }; ProgressError: { message: string; /** Format: int64 */ @@ -2264,9 +2268,13 @@ export type components = { h: number; }[]; original_file_deleted?: boolean; + policy_snapshot?: components["schemas"]["ValidatePolicySnapshot"]; pdfVersion?: string; status_changed_at?: string; }; + ValidatePolicySnapshot: { + signature_flow?: components["schemas"]["PolicySnapshotEntry"]; + }; ValidatedChildFile: { /** Format: int64 */ id: number; From bf93f2e8af33c5ce67c1e91389e1d53a38c352d5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:46:52 -0300 Subject: [PATCH 044/417] feat(policy): add effective policies controller Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PolicyController.php | 51 +++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 lib/Controller/PolicyController.php diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..28cd553a37 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,51 @@ + + * + * 200: OK + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] + public function effective(): DataResponse { + $policies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $policies[$policyKey] = $resolvedPolicy->toArray(); + } + + return new DataResponse([ + 'policies' => $policies, + ]); + } +} From f3f064cdd98ef0862fbfd3ab90b121d64c940a10 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:47:15 -0300 Subject: [PATCH 045/417] feat(policy): resolve known policies bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyService.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php index c674e2151f..b701944448 100644 --- a/lib/Service/Policy/PolicyService.php +++ b/lib/Service/Policy/PolicyService.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Service\Policy; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\Provider\PolicyProviders; use OCA\Libresign\Service\Policy\Runtime\DefaultPolicyResolver; use OCA\Libresign\Service\Policy\Runtime\PolicyContextFactory; use OCA\Libresign\Service\Policy\Runtime\PolicyRegistry; @@ -49,4 +50,15 @@ public function resolveForUser(string|\BackedEnum $policyKey, ?IUser $user, arra $this->contextFactory->forUser($user, $requestOverrides, $activeContext), ); } + + /** @return array */ + public function resolveKnownPolicies(array $requestOverrides = [], ?array $activeContext = null): array { + $context = $this->contextFactory->forCurrentUser($requestOverrides, $activeContext); + $definitions = []; + foreach (array_keys(PolicyProviders::BY_KEY) as $policyKey) { + $definitions[] = $this->registry->get($policyKey); + } + + return $this->resolver->resolveMany($definitions, $context); + } } From 7fccac8ffccadccf3b2a5818a87ac377cf6f096b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:47:15 -0300 Subject: [PATCH 046/417] refactor(policy): type effective policy responses Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 8224cfd125..519156c5a8 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -354,6 +354,22 @@ * * Validation and progress contracts * + * @psalm-type LibresignEffectivePolicyValue = null|bool|int|float|string + * @psalm-type LibresignEffectivePolicyState = array{ + * policyKey: string, + * effectiveValue: LibresignEffectivePolicyValue, + * sourceScope: string, + * visible: bool, + * editableByCurrentActor: bool, + * allowedValues: list, + * canSaveAsUserDefault: bool, + * canUseAsRequestOverride: bool, + * preferenceWasCleared: bool, + * blockedBy: ?string, + * } + * @psalm-type LibresignEffectivePoliciesResponse = array{ + * policies: array, + * } * @psalm-type LibresignPolicySnapshotEntry = array{ * effectiveValue: string, * sourceScope: string, From 00fea26818ed21dd11daa2077be63d093212f0a2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:47:15 -0300 Subject: [PATCH 047/417] refactor(page): bootstrap effective policies state Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PageController.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index f25cfcf953..95fed81b0f 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -24,7 +24,6 @@ use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\TokenService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Policy\PolicyService; -use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\RequestSignatureService; use OCA\Libresign\Service\SessionService; use OCA\Libresign\Service\SignerElementsService; @@ -109,7 +108,13 @@ public function index(): TemplateResponse { $this->provideSignerSignatues(); $this->initialState->provideInitialState('identify_methods', $this->identifyMethodService->getIdentifyMethodsSettings()); - $this->initialState->provideInitialState('signature_flow_policy', $this->policyService->resolve(SignatureFlowPolicy::KEY)->toArray()); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); From 5dd18f14d9b1da39efcc833bb25292bb2a1c6566 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:47:16 -0300 Subject: [PATCH 048/417] refactor(files): load effective policies bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Files/TemplateLoader.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/Files/TemplateLoader.php b/lib/Files/TemplateLoader.php index 064155fd59..425ce46316 100644 --- a/lib/Files/TemplateLoader.php +++ b/lib/Files/TemplateLoader.php @@ -17,7 +17,6 @@ use OCA\Libresign\Service\DocMdp\ConfigService; use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Policy\PolicyService; -use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; use OCP\EventDispatcher\Event; @@ -64,10 +63,17 @@ public function handle(Event $event): void { } protected function getInitialStatePayload(): array { + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + return [ 'certificate_ok' => $this->certificateEngineFactory->getEngine()->isSetupOk(), 'identify_methods' => $this->identifyMethodService->getIdentifyMethodsSettings(), - 'signature_flow_policy' => $this->policyService->resolve(SignatureFlowPolicy::KEY)->toArray(), + 'effective_policies' => [ + 'policies' => $resolvedPolicies, + ], 'docmdp_config' => $this->docMdpConfigService->getConfig(), 'can_request_sign' => $this->canRequestSign(), ]; From bb7e6fbba24f7bbecfd280ca28940234a2e7cc8e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:14 -0300 Subject: [PATCH 049/417] test(policy): cover effective policies controller Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Controller/PolicyControllerTest.php | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 tests/php/Unit/Controller/PolicyControllerTest.php diff --git a/tests/php/Unit/Controller/PolicyControllerTest.php b/tests/php/Unit/Controller/PolicyControllerTest.php new file mode 100644 index 0000000000..6b59cb239a --- /dev/null +++ b/tests/php/Unit/Controller/PolicyControllerTest.php @@ -0,0 +1,74 @@ +request = $this->createMock(IRequest::class); + $this->policyService = $this->createMock(PolicyService::class); + + $this->controller = new PolicyController( + $this->request, + $this->policyService, + ); + } + + public function testEffectiveReturnsResolvedSignatureFlowPolicy(): void { + $resolvedPolicy = (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('ordered_numeric') + ->setSourceScope('group') + ->setVisible(true) + ->setEditableByCurrentActor(false) + ->setAllowedValues(['ordered_numeric']) + ->setCanSaveAsUserDefault(false) + ->setCanUseAsRequestOverride(false) + ->setPreferenceWasCleared(false) + ->setBlockedBy('group'); + + $this->policyService + ->expects($this->once()) + ->method('resolveKnownPolicies') + ->willReturn([ + 'signature_flow' => $resolvedPolicy, + ]); + + $response = $this->controller->effective(); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame([ + 'policies' => [ + 'signature_flow' => [ + 'policyKey' => 'signature_flow', + 'effectiveValue' => 'ordered_numeric', + 'sourceScope' => 'group', + 'visible' => true, + 'editableByCurrentActor' => false, + 'allowedValues' => ['ordered_numeric'], + 'canSaveAsUserDefault' => false, + 'canUseAsRequestOverride' => false, + 'preferenceWasCleared' => false, + 'blockedBy' => 'group', + ], + ], + ], $response->getData()); + } +} From 834dfbc59bcaf12884dd4c2c5209f57b578849e9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:14 -0300 Subject: [PATCH 050/417] test(files): cover effective policies bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- tests/php/Unit/Files/TemplateLoaderTest.php | 47 +++++++++++---------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/tests/php/Unit/Files/TemplateLoaderTest.php b/tests/php/Unit/Files/TemplateLoaderTest.php index bf9d80aa90..82c727f3bb 100644 --- a/tests/php/Unit/Files/TemplateLoaderTest.php +++ b/tests/php/Unit/Files/TemplateLoaderTest.php @@ -17,7 +17,6 @@ use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\PolicyService; -use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Tests\Unit\TestCase; use OCP\App\IAppManager; use OCP\AppFramework\Services\IInitialState; @@ -71,10 +70,10 @@ public function testGetInitialStatePayload(): void { ->willReturn($user); $this->policyService - ->method('resolve') - ->with(SignatureFlowPolicy::KEY) - ->willReturn( - (new ResolvedPolicy()) + ->method('resolveKnownPolicies') + ->willReturn([ + 'signature_flow' + => (new ResolvedPolicy()) ->setPolicyKey('signature_flow') ->setEffectiveValue('parallel') ->setSourceScope('group') @@ -83,7 +82,7 @@ public function testGetInitialStatePayload(): void { ->setAllowedValues(['parallel', 'ordered_numeric']) ->setCanSaveAsUserDefault(true) ->setCanUseAsRequestOverride(true) - ); + ]); $docMdpConfig = [ 'enabled' => true, @@ -100,17 +99,21 @@ public function testGetInitialStatePayload(): void { $this->assertSame([ 'certificate_ok' => true, 'identify_methods' => [], - 'signature_flow_policy' => [ - 'policyKey' => 'signature_flow', - 'effectiveValue' => 'parallel', - 'sourceScope' => 'group', - 'visible' => true, - 'editableByCurrentActor' => true, - 'allowedValues' => ['parallel', 'ordered_numeric'], - 'canSaveAsUserDefault' => true, - 'canUseAsRequestOverride' => true, - 'preferenceWasCleared' => false, - 'blockedBy' => null, + 'effective_policies' => [ + 'policies' => [ + 'signature_flow' => [ + 'policyKey' => 'signature_flow', + 'effectiveValue' => 'parallel', + 'sourceScope' => 'group', + 'visible' => true, + 'editableByCurrentActor' => true, + 'allowedValues' => ['parallel', 'ordered_numeric'], + 'canSaveAsUserDefault' => true, + 'canUseAsRequestOverride' => true, + 'preferenceWasCleared' => false, + 'blockedBy' => null, + ], + ], ], 'docmdp_config' => $docMdpConfig, 'can_request_sign' => true, @@ -138,10 +141,10 @@ public function testGetInitialStatePayloadWhenCannotRequestSign(): void { ->willReturn($user); $this->policyService - ->method('resolve') - ->with(SignatureFlowPolicy::KEY) - ->willReturn( - (new ResolvedPolicy()) + ->method('resolveKnownPolicies') + ->willReturn([ + 'signature_flow' + => (new ResolvedPolicy()) ->setPolicyKey('signature_flow') ->setEffectiveValue('none') ->setSourceScope('system') @@ -150,7 +153,7 @@ public function testGetInitialStatePayloadWhenCannotRequestSign(): void { ->setAllowedValues(['none', 'parallel', 'ordered_numeric']) ->setCanSaveAsUserDefault(true) ->setCanUseAsRequestOverride(true) - ); + ]); $this->docMdpConfigService ->method('getConfig') From 8f9c78d9e53dadccfdff064a3677e9b9597d795e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:15 -0300 Subject: [PATCH 051/417] docs(openapi): add policy bootstrap schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 157 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/openapi.json b/openapi.json index 1176b09fef..e26a8c96bd 100644 --- a/openapi.json +++ b/openapi.json @@ -620,6 +620,90 @@ } ] }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "ErrorItem": { "type": "object", "required": [ @@ -7433,6 +7517,79 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + "get": { + "operationId": "policy-effective", + "summary": "Effective policies bootstrap", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/EffectivePoliciesResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { "operationId": "request_signature-request", From 7a9cc8160888e7bbe0440ae2f30cd68d1322d5bb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:15 -0300 Subject: [PATCH 052/417] docs(openapi): add full policy bootstrap schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 157 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 8e686f3f90..25f7e9772e 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -947,6 +947,90 @@ } } }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -8045,6 +8129,79 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + "get": { + "operationId": "policy-effective", + "summary": "Effective policies bootstrap", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/EffectivePoliciesResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { "operationId": "request_signature-request", From 813b111f4e78e35c51c39013909a50b1b949a383 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:16 -0300 Subject: [PATCH 053/417] refactor(types): generate policy bootstrap api types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 65 ++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 6b9c2b4fe9..0fda1fcc02 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -852,6 +852,23 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Effective policies bootstrap */ + get: operations["policy-effective"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { parameters: { query?: never; @@ -1215,6 +1232,24 @@ export type components = { /** @enum {string} */ signatureFlow: "none" | "parallel" | "ordered_numeric"; }; + EffectivePoliciesResponse: { + policies: { + [key: string]: components["schemas"]["EffectivePolicyState"]; + }; + }; + EffectivePolicyState: { + policyKey: string; + effectiveValue: components["schemas"]["EffectivePolicyValue"]; + sourceScope: string; + visible: boolean; + editableByCurrentActor: boolean; + allowedValues: components["schemas"]["EffectivePolicyValue"][]; + canSaveAsUserDefault: boolean; + canUseAsRequestOverride: boolean; + preferenceWasCleared: boolean; + blockedBy: string | null; + }; + EffectivePolicyValue: (boolean | number | string) | null; ErrorItem: { message: string; title?: string; @@ -3936,6 +3971,36 @@ export interface operations { }; }; }; + "policy-effective": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["EffectivePoliciesResponse"]; + }; + }; + }; + }; + }; + }; "request_signature-request": { parameters: { query?: never; From 3c7ccc32e476ae61c9cfe4469bc53ecf6d523f4c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:48:16 -0300 Subject: [PATCH 054/417] refactor(types): generate full policy api types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 65 +++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index a5e1489ccd..0b6119cbf9 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -852,6 +852,23 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Effective policies bootstrap */ + get: operations["policy-effective"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { parameters: { query?: never; @@ -1782,6 +1799,24 @@ export type components = { label: string; description: string; }; + EffectivePoliciesResponse: { + policies: { + [key: string]: components["schemas"]["EffectivePolicyState"]; + }; + }; + EffectivePolicyState: { + policyKey: string; + effectiveValue: components["schemas"]["EffectivePolicyValue"]; + sourceScope: string; + visible: boolean; + editableByCurrentActor: boolean; + allowedValues: components["schemas"]["EffectivePolicyValue"][]; + canSaveAsUserDefault: boolean; + canUseAsRequestOverride: boolean; + preferenceWasCleared: boolean; + blockedBy: string | null; + }; + EffectivePolicyValue: (boolean | number | string) | null; EngineHandler: { configPath: string; cfsslUri?: string; @@ -4585,6 +4620,36 @@ export interface operations { }; }; }; + "policy-effective": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["EffectivePoliciesResponse"]; + }; + }; + }; + }; + }; + }; "request_signature-request": { parameters: { query?: never; From fd6dbbc8e5234c286fc8a63288e3bb30e175d812 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:49:14 -0300 Subject: [PATCH 055/417] refactor(types): use generated effective policy aliases Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/index.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index 1f70e25b6c..32cbd3e1fa 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,18 +46,10 @@ type ApiOcsResponseData +export type EffectivePoliciesState = EffectivePoliciesResponse['policies'] export type NewFilePayload = ApiComponents['schemas']['NewFile'] export type IdentifyMethodRecord = ApiComponents['schemas']['IdentifyMethod'] export type IdentifyAccountRecord = ApiComponents['schemas']['IdentifyAccount'] From 10cb2543492f7b5a14071ece19a8e8cbf9bd16cf Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:49:14 -0300 Subject: [PATCH 056/417] feat(store): add effective policies store Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/store/policies.ts | 98 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/store/policies.ts diff --git a/src/store/policies.ts b/src/store/policies.ts new file mode 100644 index 0000000000..81e1f99933 --- /dev/null +++ b/src/store/policies.ts @@ -0,0 +1,98 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' + +import type { + EffectivePolicyState, + EffectivePoliciesResponse, + EffectivePoliciesState, +} from '../types/index' + +function isEffectivePolicyState(value: unknown): value is EffectivePolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && Array.isArray(candidate.allowedValues) + && typeof candidate.sourceScope === 'string' + && typeof candidate.visible === 'boolean' + && typeof candidate.editableByCurrentActor === 'boolean' + && typeof candidate.canSaveAsUserDefault === 'boolean' + && typeof candidate.canUseAsRequestOverride === 'boolean' + && typeof candidate.preferenceWasCleared === 'boolean' + && (candidate.blockedBy === null || typeof candidate.blockedBy === 'string') +} + +function sanitizePolicies(rawPolicies: Record): EffectivePoliciesState { + const nextPolicies: EffectivePoliciesState = {} + + for (const [policyKey, candidate] of Object.entries(rawPolicies)) { + if (isEffectivePolicyState(candidate)) { + nextPolicies[policyKey] = candidate + } + } + + return nextPolicies +} + +const _policiesStore = defineStore('policies', () => { + const initialPolicies = loadState('libresign', 'effective_policies', { policies: {} }) + const policies = ref(sanitizePolicies(initialPolicies.policies ?? {})) + + const setPolicies = (nextPolicies: Record): void => { + policies.value = sanitizePolicies(nextPolicies) + } + + const fetchEffectivePolicies = async (): Promise => { + try { + const response = await axios.get<{ ocs?: { data?: EffectivePoliciesResponse } }>(generateOcsUrl('/apps/libresign/api/v1/policies/effective')) + setPolicies(response.data?.ocs?.data?.policies ?? {}) + } catch (error: unknown) { + console.error('Failed to load effective policies', error) + } + } + + const getPolicy = (policyKey: string): EffectivePolicyState | null => { + const policy = policies.value[policyKey] + if (!policy) { + return null + } + + return policy + } + + const getEffectiveValue = (policyKey: string): EffectivePolicyState['effectiveValue'] | null => { + return getPolicy(policyKey)?.effectiveValue ?? null + } + + const canUseRequestOverride = (policyKey: string): boolean => { + return getPolicy(policyKey)?.canUseAsRequestOverride ?? true + } + + return { + policies: computed(() => policies.value), + setPolicies, + fetchEffectivePolicies, + getPolicy, + getEffectiveValue, + canUseRequestOverride, + } +}) + +export const usePoliciesStore = function(...args: Parameters) { + return _policiesStore(...args) +} + +export { + isEffectivePolicyState, +} From f95faa396947c83af604bd5946f07578b21b09da Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:49:14 -0300 Subject: [PATCH 057/417] test(store): cover effective policies store Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/store/policies.spec.ts | 92 ++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/tests/store/policies.spec.ts diff --git a/src/tests/store/policies.spec.ts b/src/tests/store/policies.spec.ts new file mode 100644 index 0000000000..da85ef355d --- /dev/null +++ b/src/tests/store/policies.spec.ts @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createPinia, setActivePinia } from 'pinia' +import axios from '@nextcloud/axios' + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: vi.fn(), + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => `/ocs/v2.php${path}`), +})) + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: vi.fn((_app, _key, defaultValue) => defaultValue), +})) + +describe('policies store', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + }) + + it('stores backend policy payload as provided', async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + ocs: { + data: { + policies: { + signature_flow: { + policyKey: 'signature_flow', + effectiveValue: 'banana', + allowedValues: ['parallel'], + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + }, + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + await store.fetchEffectivePolicies() + + expect(store.getPolicy('signature_flow')?.effectiveValue).toBe('banana') + }) + + it('replaces a policy with the latest backend payload', async () => { + vi.mocked(axios.get).mockResolvedValue({ + data: { + ocs: { + data: { + policies: { + signature_flow: { + policyKey: 'signature_flow', + effectiveValue: 'ordered_numeric', + allowedValues: ['ordered_numeric'], + sourceScope: 'group', + visible: true, + editableByCurrentActor: false, + canSaveAsUserDefault: false, + canUseAsRequestOverride: false, + preferenceWasCleared: false, + blockedBy: 'group', + }, + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + await store.fetchEffectivePolicies() + + expect(store.getPolicy('signature_flow')?.effectiveValue).toBe('ordered_numeric') + expect(store.getPolicy('signature_flow')?.canUseAsRequestOverride).toBe(false) + }) +}) From db1c9db935f025c2595d2bab10790dff0e52fd07 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:49:15 -0300 Subject: [PATCH 058/417] refactor(sidebar): consume effective policies store Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.vue | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/src/components/RightSidebar/RequestSignatureTab.vue b/src/components/RightSidebar/RequestSignatureTab.vue index f5f77cabf1..00b0f5d0c6 100644 --- a/src/components/RightSidebar/RequestSignatureTab.vue +++ b/src/components/RightSidebar/RequestSignatureTab.vue @@ -318,6 +318,7 @@ import { getSigningRouteUuid, getValidationRouteUuid } from '../../utils/signReq import { openDocument } from '../../utils/viewer.js' import router from '../../router/router' import { useFilesStore } from '../../store/files.js' +import { usePoliciesStore } from '../../store/policies' import { useSidebarStore } from '../../store/sidebar.js' import { useSignStore } from '../../store/sign.js' import { useUserConfigStore } from '../../store/userconfig.js' @@ -328,8 +329,6 @@ import type { IdentifyMethodRecord, IdentifyMethodSetting as IdentifyMethodConfig, LibresignCapabilities as RequestSignatureTabCapabilities, - SignatureFlowMode, - SignatureFlowPolicyState, SignatureFlowValue, } from '../../types/index' @@ -376,6 +375,7 @@ const props = withDefaults(defineProps<{ }) const filesStore = useFilesStore() +const policiesStore = usePoliciesStore() const signStore = useSignStore() const sidebarStore = useSidebarStore() const userConfigStore = useUserConfigStore() as ReturnType & { @@ -399,24 +399,13 @@ const activeTab = ref('') const preserveOrder = ref(false) const showOrderDiagram = ref(false) const showEnvelopeFilesDialog = ref(false) -const DEFAULT_SIGNATURE_FLOW_POLICY: SignatureFlowPolicyState = { - policyKey: 'signature_flow', - effectiveValue: 'none', - sourceScope: 'system', - visible: true, - editableByCurrentActor: true, - allowedValues: ['none', 'parallel', 'ordered_numeric'], - canSaveAsUserDefault: true, - canUseAsRequestOverride: true, - preferenceWasCleared: false, - blockedBy: null, -} -const signatureFlowPolicy = ref(loadState('libresign', 'signature_flow_policy', DEFAULT_SIGNATURE_FLOW_POLICY)) const signingProgress = ref(null) const signingProgressStatus = ref(null) const signingProgressStatusText = ref('') const stopPollingFunction = ref void)>(null) +const signatureFlowPolicy = computed(() => policiesStore.getPolicy('signature_flow')) + const signatureFlow = computed(() => { const file = filesStore.getFile() let flow = file?.signatureFlow @@ -429,14 +418,14 @@ const signatureFlow = computed(() => { if (flow && flow !== 'none') { return flow } - const resolvedFlow = normalizeSignatureFlow(signatureFlowPolicy.value.effectiveValue) + const resolvedFlow = normalizeSignatureFlow(signatureFlowPolicy.value?.effectiveValue) if (resolvedFlow && resolvedFlow !== 'none') { return resolvedFlow } return 'parallel' }) -const isAdminFlowForced = computed(() => !signatureFlowPolicy.value.canUseAsRequestOverride) +const isAdminFlowForced = computed(() => !policiesStore.canUseRequestOverride('signature_flow')) const isOrderedNumeric = computed(() => signatureFlow.value === 'ordered_numeric') const hasSigners = computed(() => filesStore.hasSigners(filesStore.getFile())) const totalSigners = computed(() => Number(filesStore.getFile()?.signersCount || filesStore.getFile()?.signers?.length || 0)) @@ -1225,6 +1214,7 @@ onMounted(() => { subscribe('libresign:edit-signer', handleEditSigner) filesStore.disableIdentifySigner() activeTab.value = userConfigStore.files_list_signer_identify_tab || '' + void policiesStore.fetchEffectivePolicies() syncPreserveOrderWithFile() void ensureCurrentFileDetail() }) From d939ee32e1b674b0b62a78b245971ded85173779 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:49:15 -0300 Subject: [PATCH 059/417] test(sidebar): cover effective policy bootstrap Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../RightSidebar/RequestSignatureTab.spec.ts | 198 +++++++++++------- 1 file changed, 127 insertions(+), 71 deletions(-) diff --git a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts index 35ebd3a160..5fdf6c2e2c 100644 --- a/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts +++ b/src/tests/components/RightSidebar/RequestSignatureTab.spec.ts @@ -4,12 +4,12 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { shallowMount } from '@vue/test-utils' +import { flushPromises, shallowMount } from '@vue/test-utils' import type { VueWrapper } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import axios from '@nextcloud/axios' -import { loadState } from '@nextcloud/initial-state' import type { useFilesStore as useFilesStoreType } from '../../../store/files.js' +import { usePoliciesStore } from '../../../store/policies' import RequestSignatureTab from '../../../components/RightSidebar/RequestSignatureTab.vue' import { useFilesStore } from '../../../store/files.js' import { FILE_STATUS } from '../../../constants.js' @@ -38,18 +38,22 @@ vi.mock('@nextcloud/initial-state', () => ({ } } if (key === 'can_request_sign') return true - if (key === 'signature_flow_policy') { + if (key === 'effective_policies') { return { - policyKey: 'signature_flow', - effectiveValue: 'none', - sourceScope: 'system', - visible: true, - editableByCurrentActor: true, - allowedValues: ['none', 'parallel', 'ordered_numeric'], - canSaveAsUserDefault: true, - canUseAsRequestOverride: true, - preferenceWasCleared: false, - blockedBy: null, + policies: { + signature_flow: { + policyKey: 'signature_flow', + effectiveValue: 'none', + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + allowedValues: ['none', 'parallel', 'ordered_numeric'], + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + }, + }, } } return defaultValue @@ -88,13 +92,41 @@ vi.mock('@nextcloud/router', () => ({ })) vi.mock('@libresign/pdf-elements', () => ({ - ensureWorkerReady: vi.fn(), + setWorkerPath: vi.fn(), })) describe('RequestSignatureTab - Critical Business Rules', () => { let wrapper: VueWrapper let filesStore: ReturnType + const createEffectivePoliciesResponse = (policyOverrides: Record = {}) => ({ + data: { + ocs: { + data: { + policies: { + signature_flow: { + policyKey: 'signature_flow', + effectiveValue: 'none', + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + allowedValues: ['none', 'parallel', 'ordered_numeric'], + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + ...policyOverrides, + }, + }, + }, + }, + }, + }) + + const createSignatureFlowPolicy = (policyOverrides: Record = {}) => { + return createEffectivePoliciesResponse(policyOverrides).data.ocs.data.policies.signature_flow + } + const updateFile = async (patch: Record) => { const current = filesStore.files[1] || { id: 1 } const hasSigners = Object.prototype.hasOwnProperty.call(patch, 'signers') @@ -115,21 +147,24 @@ describe('RequestSignatureTab - Critical Business Rules', () => { const updateMethods = async (methods: unknown[]) => { await setVmState({ methods }) } + const updatePolicies = async (policyOverrides: Record) => { + const policiesStore = usePoliciesStore() + policiesStore.setPolicies({ + signature_flow: createSignatureFlowPolicy(policyOverrides), + }) + await wrapper.vm.$nextTick() + } beforeEach(async () => { setActivePinia(createPinia()) generateUrlMock.mockClear() - vi.mocked(loadState).mockImplementation((app, key, defaultValue) => { - if (key === 'config') { - return { - 'sign-elements': { 'is-available': true }, - 'identification_documents': { enabled: false }, - } + vi.mocked(axios.get).mockImplementation(async (url: string) => { + if (url.includes('/apps/libresign/api/v1/policies/effective')) { + return createEffectivePoliciesResponse() as Awaited> } - if (key === 'can_request_sign') return true - return defaultValue + + return { data: { ocs: { data: null } } } as Awaited> }) - vi.mocked(axios.get).mockResolvedValue({ data: { ocs: { data: null } } } as Awaited>) filesStore = useFilesStore() await filesStore.addFile({ @@ -172,6 +207,7 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }, }, }) as VueWrapper + await flushPromises() }) describe('RULE: showDocMdpWarning when DocMDP level prevents changes', () => { @@ -706,6 +742,58 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) describe('RULE: signatureFlow calculation with effective policy bootstrap', () => { + it('refreshes policy state from effective policies endpoint on mount', async () => { + wrapper.unmount() + vi.mocked(axios.get).mockImplementation(async (url: string) => { + if (url.includes('/apps/libresign/api/v1/policies/effective')) { + return createEffectivePoliciesResponse({ + effectiveValue: 'ordered_numeric', + sourceScope: 'group', + canUseAsRequestOverride: false, + blockedBy: 'group', + }) as Awaited> + } + + return { data: { ocs: { data: null } } } as Awaited> + }) + + wrapper = shallowMount(RequestSignatureTab, { + mocks: { + t: (_app: string, text: string) => text, + }, + global: { + stubs: { + EnvelopeFilesList: { name: 'EnvelopeFilesList', template: '
' }, + NcButton: true, + NcCheckboxRadioSwitch: true, + NcNoteCard: true, + NcActionInput: true, + NcActionButton: true, + NcFormBox: true, + NcLoadingIcon: true, + Signers: true, + SigningProgress: true, + AccountPlus: true, + ChartGantt: true, + FileMultiple: true, + Send: true, + Delete: true, + Bell: true, + Draw: true, + Pencil: true, + MessageText: true, + OrderNumericAscending: true, + }, + }, + }) as VueWrapper + + await flushPromises() + + expect(wrapper.vm.signatureFlowPolicy.effectiveValue).toBe('ordered_numeric') + expect(wrapper.vm.signatureFlowPolicy.sourceScope).toBe('group') + expect(wrapper.vm.signatureFlowPolicy.canUseAsRequestOverride).toBe(false) + }) + it('returns ordered_numeric when file flow is 2', async () => { await updateFile({ signatureFlow: 2 }) expect(wrapper.vm.signatureFlow).toBe('ordered_numeric') @@ -722,23 +810,13 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('uses effective policy when file flow is none', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - effectiveValue: 'ordered_numeric', - }, - }) + await updatePolicies({ effectiveValue: 'ordered_numeric' }) await updateFile({ signatureFlow: 'none' }) expect(wrapper.vm.signatureFlow).toBe('ordered_numeric') }) it('defaults to parallel when both file and policy are none', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - effectiveValue: 'none', - }, - }) + await updatePolicies({ effectiveValue: 'none' }) await updateFile({ signatureFlow: 'none' }) expect(wrapper.vm.signatureFlow).toBe('parallel') }) @@ -746,32 +824,19 @@ describe('RequestSignatureTab - Critical Business Rules', () => { describe('RULE: isAdminFlowForced detection', () => { it('returns true when policy blocks request overrides', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - canUseAsRequestOverride: false, - }, - }) + await updatePolicies({ canUseAsRequestOverride: false }) expect(wrapper.vm.isAdminFlowForced).toBe(true) }) it('returns false when policy allows request overrides', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - canUseAsRequestOverride: true, - }, - }) + await updatePolicies({ canUseAsRequestOverride: true }) expect(wrapper.vm.isAdminFlowForced).toBe(false) }) it('hides preserve order switch when policy forces flow', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - canUseAsRequestOverride: false, - effectiveValue: 'ordered_numeric', - }, + await updatePolicies({ + canUseAsRequestOverride: false, + effectiveValue: 'ordered_numeric', }) await updateFile({ signers: [ @@ -971,12 +1036,9 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('reverts to parallel when disabling', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - effectiveValue: 'none', - canUseAsRequestOverride: true, - }, + await updatePolicies({ + effectiveValue: 'none', + canUseAsRequestOverride: true, }) await updateFile({ signatureFlow: 'ordered_numeric', @@ -990,12 +1052,9 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('preserves admin flow when disabling user preference', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - effectiveValue: 'ordered_numeric', - canUseAsRequestOverride: false, - }, + await updatePolicies({ + effectiveValue: 'ordered_numeric', + canUseAsRequestOverride: false, }) await updateFile({ signatureFlow: 'ordered_numeric', @@ -1029,12 +1088,9 @@ describe('RequestSignatureTab - Critical Business Rules', () => { }) it('disables preserve order when admin forces flow', async () => { - await setVmState({ - signatureFlowPolicy: { - ...wrapper.vm.signatureFlowPolicy, - effectiveValue: 'ordered_numeric', - canUseAsRequestOverride: false, - }, + await updatePolicies({ + effectiveValue: 'ordered_numeric', + canUseAsRequestOverride: false, }) await updateFile({ signatureFlow: 'ordered_numeric' }) wrapper.vm.syncPreserveOrderWithFile() From 9bd26c03fb705ed69665fa5bdc2e3a2832c38247 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:30 -0300 Subject: [PATCH 060/417] refactor(policy): add persistence hooks to policy source Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/Contract/IPolicySource.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/lib/Service/Policy/Contract/IPolicySource.php b/lib/Service/Policy/Contract/IPolicySource.php index 0665ffa478..ccfde96a9a 100644 --- a/lib/Service/Policy/Contract/IPolicySource.php +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -24,5 +24,9 @@ public function loadUserPreference(string $policyKey, PolicyContext $context): ? public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer; + public function saveSystemPolicy(string $policyKey, mixed $value): void; + + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void; + public function clearUserPreference(string $policyKey, PolicyContext $context): void; } From d17fe9ea18fa5a228a560ef5ef4cf4b1ea989359 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:31 -0300 Subject: [PATCH 061/417] feat(policy): persist system and user policy values Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/Runtime/PolicySource.php | 26 +++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/lib/Service/Policy/Runtime/PolicySource.php b/lib/Service/Policy/Runtime/PolicySource.php index b07500c471..3a4f0c8e66 100644 --- a/lib/Service/Policy/Runtime/PolicySource.php +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -135,6 +135,32 @@ public function loadRequestOverride(string $policyKey, PolicyContext $context): ->setValue($definition->normalizeValue($requestOverrides[$policyKey])); } + #[\Override] + public function saveSystemPolicy(string $policyKey, mixed $value): void { + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + + if ($normalizedValue === $defaultValue) { + $this->appConfig->deleteAppValue($definition->getAppConfigKey()); + return; + } + + $this->appConfig->setAppValueString($definition->getAppConfigKey(), (string)$normalizedValue); + } + + #[\Override] + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException('A signed-in user is required to save a policy preference.'); + } + + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $this->appConfig->setUserValue($userId, $definition->getUserPreferenceKey(), (string)$normalizedValue); + } + #[\Override] public function clearUserPreference(string $policyKey, PolicyContext $context): void { $userId = $context->getUserId(); From 12ad810c4f23495dff8f1b7ef903821c160bb72d Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:31 -0300 Subject: [PATCH 062/417] feat(policy): add policy save operations Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyService.php | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php index b701944448..3c0b54f293 100644 --- a/lib/Service/Policy/PolicyService.php +++ b/lib/Service/Policy/PolicyService.php @@ -61,4 +61,40 @@ public function resolveKnownPolicies(array $requestOverrides = [], ?array $activ return $this->resolver->resolveMany($definitions, $context); } + + public function saveSystem(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $normalizedValue = $value === null + ? $definition->normalizeValue($definition->defaultSystemValue()) + : $definition->normalizeValue($value); + + $definition->validateValue($normalizedValue, $context); + $this->source->saveSystemPolicy($definition->key(), $normalizedValue); + + return $this->resolver->resolve($definition, $context); + } + + public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $resolved = $this->resolver->resolve($definition, $context); + if (!$resolved->canSaveAsUserDefault()) { + throw new \InvalidArgumentException('Saving a user preference is not allowed for ' . $definition->key()); + } + + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveUserPreference($definition->key(), $context, $normalizedValue); + + return $this->resolver->resolve($definition, $context); + } + + public function clearUserPreference(string|\BackedEnum $policyKey): ResolvedPolicy { + $context = $this->contextFactory->forCurrentUser(); + $definition = $this->registry->get($policyKey); + $this->source->clearUserPreference($definition->key(), $context); + + return $this->resolver->resolve($definition, $context); + } } From 05b4f2298c12bae3a6d76e604affdcfc1ac428a6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:31 -0300 Subject: [PATCH 063/417] refactor(policy): add policy write response types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 519156c5a8..57f83294c8 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -367,9 +367,16 @@ * preferenceWasCleared: bool, * blockedBy: ?string, * } + * @psalm-type LibresignEffectivePolicyResponse = array{ + * policy: LibresignEffectivePolicyState, + * } * @psalm-type LibresignEffectivePoliciesResponse = array{ * policies: array, * } + * @psalm-type LibresignSystemPolicyWriteRequest = array{ + * value: LibresignEffectivePolicyValue, + * } + * @psalm-type LibresignSystemPolicyWriteResponse = LibresignMessageResponse&LibresignEffectivePolicyResponse * @psalm-type LibresignPolicySnapshotEntry = array{ * effectiveValue: string, * sourceScope: string, From 67ac841982f643d535d15b1fa5872e4da3ec8568 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:32 -0300 Subject: [PATCH 064/417] feat(policy): add policy write endpoints Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PolicyController.php | 127 +++++++++++++++++++++++++++- 1 file changed, 124 insertions(+), 3 deletions(-) diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php index 28cd553a37..0b6b643ada 100644 --- a/lib/Controller/PolicyController.php +++ b/lib/Controller/PolicyController.php @@ -15,14 +15,19 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\DataResponse; +use OCP\IL10N; use OCP\IRequest; /** + * @psalm-import-type LibresignErrorResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePoliciesResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignSystemPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions */ final class PolicyController extends AEnvironmentAwareController { public function __construct( IRequest $request, + private IL10N $l10n, private PolicyService $policyService, ) { parent::__construct(Application::APP_ID, $request); @@ -39,13 +44,129 @@ public function __construct( #[NoCSRFRequired] #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] public function effective(): DataResponse { + /** @var array $policies */ $policies = []; foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { - $policies[$policyKey] = $resolvedPolicy->toArray(); + /** @var LibresignEffectivePolicyState $policyState */ + $policyState = $resolvedPolicy->toArray(); + $policies[$policyKey] = $policyState; } - return new DataResponse([ + /** @var LibresignEffectivePoliciesResponse $data */ + $data = [ 'policies' => $policies, - ]); + ]; + + return new DataResponse($data); + } + + /** + * Save a system-level policy value + * + * @param string $policyKey Policy identifier to persist at the system layer. + * @param null|bool|int|float|string $value Policy value to persist. Null resets the policy to its default system value. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 500: Internal server error + */ + #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function setSystem(string $policyKey, null|bool|int|float|string $value = null): DataResponse { + try { + $policy = $this->policyService->saveSystem($policyKey, $value); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t($exception->getMessage()), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Save a user policy preference + * + * @param string $policyKey Policy identifier to persist for the current user. + * @param null|bool|int|float|string $value Policy value to persist as the current user's default. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 500: Internal server error + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function setUserPreference(string $policyKey, null|bool|int|float|string $value = null): DataResponse { + try { + $policy = $this->policyService->saveUserPreference($policyKey, $value); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t($exception->getMessage()), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Clear a user policy preference + * + * @param string $policyKey Policy identifier to clear for the current user. + * @return DataResponse|DataResponse + * + * 200: OK + * 500: Internal server error + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPreference(string $policyKey): DataResponse { + try { + $policy = $this->policyService->clearUserPreference($policyKey); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } } } From bef5051d6fdda921d4881178ba715343c33c6b8c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:32 -0300 Subject: [PATCH 065/417] refactor(admin): route signature flow config through policy service Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/AdminController.php | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 34c2458980..7e3f9ebcf7 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -24,6 +24,7 @@ use OCA\Libresign\Service\IdentifyMethodService; use OCA\Libresign\Service\Install\ConfigureCheckService; use OCA\Libresign\Service\Install\InstallService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\ReminderService; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; @@ -83,6 +84,7 @@ public function __construct( private ReminderService $reminderService, private FooterService $footerService, private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, private IdentifyMethodService $identifyMethodService, private FileMapper $fileMapper, ) { @@ -974,36 +976,21 @@ private function saveOrDeleteConfig(string $key, ?string $value, string $default #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/signature-flow/config', requirements: ['apiVersion' => '(v1)'])] public function setSignatureFlowConfig(bool $enabled, ?string $mode = null): DataResponse { try { - if (!$enabled) { - $this->appConfig->deleteKey(Application::APP_ID, 'signature_flow'); - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } - - if ($mode === null) { + if ($enabled && $mode === null) { return new DataResponse([ 'error' => $this->l10n->t('Mode is required when signature flow is enabled.'), ], Http::STATUS_BAD_REQUEST); } - try { - $signatureFlow = \OCA\Libresign\Enum\SignatureFlow::from($mode); - } catch (\ValueError) { - return new DataResponse([ - 'error' => $this->l10n->t('Invalid signature flow mode. Use "parallel" or "ordered_numeric".'), - ], Http::STATUS_BAD_REQUEST); - } - - $this->appConfig->setValueString( - Application::APP_ID, - 'signature_flow', - $signatureFlow->value - ); + $this->policyService->saveSystem('signature_flow', $enabled ? $mode : null); return new DataResponse([ 'message' => $this->l10n->t('Settings saved'), ]); + } catch (\InvalidArgumentException) { + return new DataResponse([ + 'error' => $this->l10n->t('Invalid signature flow mode. Use "parallel" or "ordered_numeric".'), + ], Http::STATUS_BAD_REQUEST); } catch (\Exception $e) { return new DataResponse([ 'error' => $e->getMessage(), From 158ad5cbeb306003ac326b98c1387370ec0dfa04 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:33 -0300 Subject: [PATCH 066/417] refactor(settings): bootstrap effective policies in admin Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Settings/Admin.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 411b28d16a..a6efa1274e 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -15,6 +15,7 @@ use OCA\Libresign\Service\DocMdp\ConfigService as DocMdpConfigService; use OCA\Libresign\Service\FooterService; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\PolicyService; use OCA\Libresign\Service\SignatureBackgroundService; use OCA\Libresign\Service\SignatureTextService; use OCP\AppFramework\Http\TemplateResponse; @@ -41,6 +42,7 @@ public function __construct( private SignatureBackgroundService $signatureBackgroundService, private FooterService $footerService, private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, ) { } #[\Override] @@ -87,7 +89,13 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('tsa_username', $this->appConfig->getValueString(Application::APP_ID, 'tsa_username', '')); $this->initialState->provideInitialState('tsa_password', $this->appConfig->getValueString(Application::APP_ID, 'tsa_password', self::PASSWORD_PLACEHOLDER)); $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); - $this->initialState->provideInitialState('signature_flow', $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', \OCA\Libresign\Enum\SignatureFlow::NONE->value)); + $resolvedPolicies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + $resolvedPolicies[$policyKey] = $resolvedPolicy->toArray(); + } + $this->initialState->provideInitialState('effective_policies', [ + 'policies' => $resolvedPolicies, + ]); $this->initialState->provideInitialState('signing_mode', $this->getSigningModeInitialState()); $this->initialState->provideInitialState('worker_type', $this->getWorkerTypeInitialState()); $this->initialState->provideInitialState('identification_documents', $this->appConfig->getValueBool(Application::APP_ID, 'identification_documents', false)); From 2204f7a37b99acc7e499383dd19177cfd64f941b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:33 -0300 Subject: [PATCH 067/417] docs(openapi): add admin policy write schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-administration.json | 267 ++++++++++++++++++++++++++++++++++++ 1 file changed, 267 insertions(+) diff --git a/openapi-administration.json b/openapi-administration.json index 849c63dc64..70fc1059f9 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -374,6 +374,87 @@ } } }, + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, + "EffectivePolicyState": { + "type": "object", + "required": [ + "policyKey", + "effectiveValue", + "sourceScope", + "visible", + "editableByCurrentActor", + "allowedValues", + "canSaveAsUserDefault", + "canUseAsRequestOverride", + "preferenceWasCleared", + "blockedBy" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "effectiveValue": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "sourceScope": { + "type": "string" + }, + "visible": { + "type": "boolean" + }, + "editableByCurrentActor": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + }, + "canSaveAsUserDefault": { + "type": "boolean" + }, + "canUseAsRequestOverride": { + "type": "boolean" + }, + "preferenceWasCleared": { + "type": "boolean" + }, + "blockedBy": { + "type": "string", + "nullable": true + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -799,6 +880,16 @@ ] } } + }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] } } }, @@ -4076,6 +4167,182 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist. Null resets the policy to its default system value.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", From 1b60969b571d96ddf3acc2e209a7d48fa7e5d3d8 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:33 -0300 Subject: [PATCH 068/417] docs(openapi): add user policy preference schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi.json | 307 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) diff --git a/openapi.json b/openapi.json index e26a8c96bd..c139ddd77f 100644 --- a/openapi.json +++ b/openapi.json @@ -634,6 +634,17 @@ } } }, + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, "EffectivePolicyState": { "type": "object", "required": [ @@ -2003,6 +2014,16 @@ } } }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -7590,6 +7611,292 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist as the current user's default.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the current user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the current user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { "operationId": "request_signature-request", From f782dc262c3e7f602542a0db6ab38419aeb8bcc2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:34 -0300 Subject: [PATCH 069/417] docs(openapi): add full policy preference schema Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 494 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 494 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 25f7e9772e..5a57fa9235 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -961,6 +961,17 @@ } } }, + "EffectivePolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + }, "EffectivePolicyState": { "type": "object", "required": [ @@ -2615,6 +2626,27 @@ } } }, + "SystemPolicyWriteRequest": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -8202,6 +8234,292 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist as the current user's default.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the current user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the current user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { "operationId": "request_signature-request", @@ -13504,6 +13822,182 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist. Null resets the policy to its default system value.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the system layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SystemPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { "get": { "operationId": "setting-has-root-cert", From 87c43508a97fe07ca693b63e2b2291578bb36ef7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:34 -0300 Subject: [PATCH 070/417] refactor(types): add policy write api aliases Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/index.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/types/index.ts b/src/types/index.ts index 32cbd3e1fa..a9cf601c4e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -6,6 +6,7 @@ import type { components as ApiComponents } from './openapi/openapi' import type { operations as ApiOperations } from './openapi/openapi' import type { components as AdminComponents } from './openapi/openapi-administration' +import type { operations as AdminOperations } from './openapi/openapi-administration' type ApiJsonBody = TRequestBody extends { content: { @@ -50,6 +51,10 @@ export type EffectivePolicyValue = ApiComponents['schemas']['EffectivePolicyValu export type EffectivePolicyState = ApiComponents['schemas']['EffectivePolicyState'] export type EffectivePoliciesResponse = ApiOcsResponseData export type EffectivePoliciesState = EffectivePoliciesResponse['policies'] +export type SystemPolicyWritePayload = ApiRequestJsonBody +export type SystemPolicyWriteResponse = ApiOcsResponseData +export type SystemPolicyWriteErrorResponse = ApiOcsResponseData + | ApiOcsResponseData export type NewFilePayload = ApiComponents['schemas']['NewFile'] export type IdentifyMethodRecord = ApiComponents['schemas']['IdentifyMethod'] export type IdentifyAccountRecord = ApiComponents['schemas']['IdentifyAccount'] From c0e404e28255475725090c25fbe8d03fc424fecb Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:34 -0300 Subject: [PATCH 071/417] refactor(types): generate admin policy write types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-administration.ts | 104 ++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index 1038d0e772..b7117c4d62 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -465,6 +465,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Save a system-level policy value + * @description This endpoint requires admin access + */ + post: operations["policy-set-system"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { parameters: { query?: never; @@ -583,6 +603,22 @@ export type components = { success: boolean; message: string; }; + EffectivePolicyResponse: { + policy: components["schemas"]["EffectivePolicyState"]; + }; + EffectivePolicyState: { + policyKey: string; + effectiveValue: components["schemas"]["EffectivePolicyValue"]; + sourceScope: string; + visible: boolean; + editableByCurrentActor: boolean; + allowedValues: components["schemas"]["EffectivePolicyValue"][]; + canSaveAsUserDefault: boolean; + canUseAsRequestOverride: boolean; + preferenceWasCleared: boolean; + blockedBy: string | null; + }; + EffectivePolicyValue: (boolean | number | string) | null; EngineHandler: { configPath: string; cfsslUri?: string; @@ -705,6 +741,7 @@ export type components = { /** @enum {string} */ status: "success"; }; + SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"]; }; responses: never; parameters: never; @@ -2087,6 +2124,73 @@ export interface operations { }; }; }; + "policy-set-system": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to persist at the system layer. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist. Null resets the policy to its default system value. */ + value?: (boolean | number | string) | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "setting-has-root-cert": { parameters: { query?: never; From aca5cdf4ac28e3736ced43dae4a14e836a357e95 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:34 -0300 Subject: [PATCH 072/417] refactor(types): generate policy preference types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi.ts | 135 +++++++++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/src/types/openapi/openapi.ts b/src/types/openapi/openapi.ts index 0fda1fcc02..495a750d33 100644 --- a/src/types/openapi/openapi.ts +++ b/src/types/openapi/openapi.ts @@ -869,6 +869,24 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Save a user policy preference */ + put: operations["policy-set-user-preference"]; + post?: never; + /** Clear a user policy preference */ + delete: operations["policy-clear-user-preference"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { parameters: { query?: never; @@ -1237,6 +1255,9 @@ export type components = { [key: string]: components["schemas"]["EffectivePolicyState"]; }; }; + EffectivePolicyResponse: { + policy: components["schemas"]["EffectivePolicyState"]; + }; EffectivePolicyState: { policyKey: string; effectiveValue: components["schemas"]["EffectivePolicyValue"]; @@ -1619,6 +1640,7 @@ export type components = { message: string; status: string; }; + SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"]; UserElement: { /** Format: int64 */ id: number; @@ -4001,6 +4023,119 @@ export interface operations { }; }; }; + "policy-set-user-preference": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to persist for the current user. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist as the current user's default. */ + value?: (boolean | number | string) | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; + "policy-clear-user-preference": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to clear for the current user. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "request_signature-request": { parameters: { query?: never; From 26abf2f928b5cac5f0402fad0ce5c4aa029104f9 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:35 -0300 Subject: [PATCH 073/417] refactor(types): generate full policy preference types Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 225 ++++++++++++++++++++++++++++++ 1 file changed, 225 insertions(+) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 0b6119cbf9..2ca6a252ec 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -869,6 +869,24 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Save a user policy preference */ + put: operations["policy-set-user-preference"]; + post?: never; + /** Clear a user policy preference */ + delete: operations["policy-clear-user-preference"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { parameters: { query?: never; @@ -1517,6 +1535,26 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Save a system-level policy value + * @description This endpoint requires admin access + */ + post: operations["policy-set-system"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/setting/has-root-cert": { parameters: { query?: never; @@ -1804,6 +1842,9 @@ export type components = { [key: string]: components["schemas"]["EffectivePolicyState"]; }; }; + EffectivePolicyResponse: { + policy: components["schemas"]["EffectivePolicyState"]; + }; EffectivePolicyState: { policyKey: string; effectiveValue: components["schemas"]["EffectivePolicyValue"]; @@ -2268,6 +2309,10 @@ export type components = { /** @enum {string} */ status: "success"; }; + SystemPolicyWriteRequest: { + value: components["schemas"]["EffectivePolicyValue"]; + }; + SystemPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["EffectivePolicyResponse"]; UserElement: { /** Format: int64 */ id: number; @@ -4650,6 +4695,119 @@ export interface operations { }; }; }; + "policy-set-user-preference": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to persist for the current user. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist as the current user's default. */ + value?: (boolean | number | string) | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; + "policy-clear-user-preference": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to clear for the current user. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "request_signature-request": { parameters: { query?: never; @@ -6907,6 +7065,73 @@ export interface operations { }; }; }; + "policy-set-system": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Policy identifier to persist at the system layer. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist. Null resets the policy to its default system value. */ + value?: (boolean | number | string) | null; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["SystemPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "setting-has-root-cert": { parameters: { query?: never; From 8613bd3b40480ac1fa85027ae3032480640cc758 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:35 -0300 Subject: [PATCH 074/417] feat(store): persist policy preferences Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/store/policies.ts | 64 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/store/policies.ts b/src/store/policies.ts index 81e1f99933..365e2ded38 100644 --- a/src/store/policies.ts +++ b/src/store/policies.ts @@ -12,8 +12,11 @@ import { generateOcsUrl } from '@nextcloud/router' import type { EffectivePolicyState, + EffectivePolicyValue, EffectivePoliciesResponse, EffectivePoliciesState, + SystemPolicyWritePayload, + SystemPolicyWriteResponse, } from '../types/index' function isEffectivePolicyState(value: unknown): value is EffectivePolicyState { @@ -62,6 +65,64 @@ const _policiesStore = defineStore('policies', () => { } } + const saveSystemPolicy = async (policyKey: string, value: EffectivePolicyValue): Promise => { + const payload: SystemPolicyWritePayload = { value } + const response = await axios.post<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + + const saveUserPreference = async (policyKey: string, value: EffectivePolicyValue): Promise => { + const payload: SystemPolicyWritePayload = { value } + const response = await axios.put<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + + const clearUserPreference = async (policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`), + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + const getPolicy = (policyKey: string): EffectivePolicyState | null => { const policy = policies.value[policyKey] if (!policy) { @@ -83,6 +144,9 @@ const _policiesStore = defineStore('policies', () => { policies: computed(() => policies.value), setPolicies, fetchEffectivePolicies, + saveSystemPolicy, + saveUserPreference, + clearUserPreference, getPolicy, getEffectiveValue, canUseRequestOverride, From c69fdecb228794218043793b8fb08b8b6425d67e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:05:35 -0300 Subject: [PATCH 075/417] refactor(settings): use policy store in signature flow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/Settings/SignatureFlow.vue | 56 ++++++++++++++-------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/views/Settings/SignatureFlow.vue b/src/views/Settings/SignatureFlow.vue index 585dce1946..ba08ce7b6e 100644 --- a/src/views/Settings/SignatureFlow.vue +++ b/src/views/Settings/SignatureFlow.vue @@ -54,10 +54,7 @@ From 09a65758bbf81bb81695eddfb1d3a4d4d9359e36 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:31:18 -0300 Subject: [PATCH 087/417] test(policies): add comprehensive test for RealPolicyWorkbench component - Create test validating sophisticated visual interface elements - Verify search functionality with live settings count display - Test card/list layout toggle button presence - Validate signing order policy appears with proper configuration - Confirm absence of POC settings (Confetti, Identification factors) - Ensure proper scope display for group and user rules - Mock real policies store with proper EffectivePolicyValue structure Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Settings/SettingsPolicyWorkbench.spec.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts diff --git a/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts new file mode 100644 index 0000000000..926815d2c5 --- /dev/null +++ b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import { createL10nMock } from '../../testHelpers/l10n.js' + +vi.mock('@nextcloud/l10n', () => createL10nMock()) +vi.mock('../../../store/policies', () => ({ + usePoliciesStore: () => ({ + getPolicy: (key: string) => { + if (key === 'signature_flow') { + return { effectiveValue: { flow: 'ordered_numeric' } } + } + return null + }, + fetchEffectivePolicies: vi.fn().mockResolvedValue(undefined), + saveSystemPolicy: vi.fn().mockResolvedValue(undefined), + saveGroupPolicy: vi.fn().mockResolvedValue(undefined), + saveUserPreference: vi.fn().mockResolvedValue(undefined), + }), +})) + +import RealPolicyWorkbench from '../../../views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue' + +describe('RealPolicyWorkbench.vue', () => { + it('shows signing order with sophisticated visual interface: filter, toggle, counts, and scopes', () => { + const wrapper = mount(RealPolicyWorkbench, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcTextField: { template: '
' }, + NcButton: { template: '' }, + NcIconSvgWrapper: { template: '' }, + NcNoteCard: { template: '
' }, + NcDialog: { template: '
' }, + NcCheckboxRadioSwitch: { template: '' }, + NcSelect: { template: '' }, + PolicyRuleCard: { template: '
' }, + SignatureFlow: { template: '
' }, + }, + }, + }) + + const text = wrapper.text() + + // Validate search/filter UI exists + expect(wrapper.find('input[type="text"]').exists()).toBe(true) + expect(text).toContain('Find setting') + + // Validate settings count display + expect(text).toContain('1 of 1 settings visible') + + // Validate toggle button exists for card/list view + expect(wrapper.find('.policy-workbench__catalog-view-button').exists()).toBe(true) + + // Validate signing order is displayed + expect(text).toContain('Signing order') + expect(text).toContain('Define whether signers work in parallel or in a sequential order') + expect(text).toContain('Control the overall signature flow model for documents') + + // Validate default value is shown + expect(text).toContain('Sequential') + expect(text).toContain('Default:') + + // Validate counts shown + expect(text).toContain('Group rules: 1') + expect(text).toContain('User rules: 0') + + // Validate POC settings are NOT present + expect(text).not.toContain('Confetti') + expect(text).not.toContain('Identification factors') + }) +}) + From e2046c2b950cbe25f61273645218f229d568faf0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:30:45 -0300 Subject: [PATCH 088/417] fix(policy-workbench): close editor when removing edited rule Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PolicyWorkbench/useRealPolicyWorkbench.ts | 391 +++++++++++++++--- 1 file changed, 324 insertions(+), 67 deletions(-) diff --git a/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts index 3997e57aef..3d37375bde 100644 --- a/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts +++ b/src/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.ts @@ -3,25 +3,30 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ +import axios from '@nextcloud/axios' +import { generateOcsUrl } from '@nextcloud/router' import { computed, reactive, ref } from 'vue' import { t } from '@nextcloud/l10n' -import SignatureFlow from '../SignatureFlow.vue' +import SignatureFlowScalarRuleEditor from './settings/signature-flow/SignatureFlowScalarRuleEditor.vue' import { usePoliciesStore } from '../../../store/policies' import type { EffectivePolicyValue } from '../../../types/index' +import logger from '../../../logger.js' + +type PolicyScope = 'system' | 'group' | 'user' interface PolicyRuleRecord { id: string - scope: 'system' | 'group' | 'user' + scope: PolicyScope targetId: string | null allowChildOverride: boolean value: EffectivePolicyValue } interface PolicyEditorDraft { - scope: 'system' | 'group' | 'user' + scope: PolicyScope ruleId: string | null - targetId: string | null + targetIds: string[] value: EffectivePolicyValue allowChildOverride: boolean } @@ -49,8 +54,37 @@ interface PolicySettingSummary { interface PolicyTargetOption { id: string - label: string - groupId?: string + displayName: string + subname?: string + user?: string + isNoUser?: boolean +} + +interface GroupDetailsResponse { + ocs?: { + data?: { + groups?: Array<{ + id: string + displayname?: string + usercount?: number + }> + } + } +} + +interface UserDetailsRecord { + id: string + displayname?: string + 'display-name'?: string + email?: string +} + +interface UserDetailsResponse { + ocs?: { + data?: { + users?: Record + } + } } const realDefinitions = { @@ -58,11 +92,11 @@ const realDefinitions = { key: 'signature_flow', title: t('libresign', 'Signing order'), description: t('libresign', 'Define whether signers work in parallel or in a sequential order.'), - menuHint: t('libresign', 'Control the overall signature flow model for documents.'), - editor: SignatureFlow, - createEmptyValue: () => ({ flow: 'parallel' } as unknown as EffectivePolicyValue), + menuHint: t('libresign', 'Define the default signing flow and where overrides are allowed.'), + editor: SignatureFlowScalarRuleEditor, + createEmptyValue: () => 'parallel' as EffectivePolicyValue, summarizeValue: (value: EffectivePolicyValue) => { - const flowValue = (value as any)?.flow + const flowValue = resolveSignatureFlowMode(value) switch (flowValue) { case 'parallel': return t('libresign', 'Simultaneous (Parallel)') @@ -77,10 +111,32 @@ const realDefinitions = { formatAllowOverride: (allowChildOverride: boolean) => allowChildOverride ? t('libresign', 'Lower layers may override this rule') - : t('libresign', 'Lower layers must inherit this rule'), + : t('libresign', 'Lower layers must inherit this value'), }, } +function resolveSignatureFlowMode(value: EffectivePolicyValue): string | null { + if (typeof value === 'string') { + return value + } + + if (value && typeof value === 'object' && 'flow' in (value as Record)) { + const candidate = (value as { flow?: unknown }).flow + return typeof candidate === 'string' ? candidate : null + } + + return null +} + +function inferSystemAllowOverride(policy: { allowedValues?: unknown[] } | null): boolean { + if (!policy || !Array.isArray(policy.allowedValues)) { + return true + } + + // When lower layers are locked, backend narrows allowedValues to a single value. + return policy.allowedValues.length !== 1 +} + export function createRealPolicyWorkbenchState() { const policiesStore = usePoliciesStore() const viewMode = ref<'system-admin' | 'group-admin'>('system-admin') @@ -88,26 +144,19 @@ export function createRealPolicyWorkbenchState() { const editorDraft = ref(null) const editorMode = ref<'create' | 'edit' | null>(null) const highlightedRuleId = ref(null) + const duplicateMessage = ref(null) + const nextRuleNumber = ref(1) - // Mock groups and users for now (will be fetched from API later) - const groups = ref([ - { id: 'finance', label: t('libresign', 'Finance') }, - { id: 'legal', label: t('libresign', 'Legal') }, - { id: 'sales', label: t('libresign', 'Sales') }, - ]) + const groupRules = ref([]) + const userRules = ref([]) - const users = ref([ - { id: 'user1', label: t('libresign', 'User 1'), groupId: 'finance' }, - { id: 'user2', label: t('libresign', 'User 2'), groupId: 'finance' }, - { id: 'user3', label: t('libresign', 'User 3'), groupId: 'legal' }, - { id: 'user4', label: t('libresign', 'User 4'), groupId: 'sales' }, - ]) + const groups = ref([]) + const users = ref([]) + const loadingTargets = ref(false) const visibleSettingSummaries = computed(() => { return Object.values(realDefinitions).map((definition) => { const policy = policiesStore.getPolicy(definition.key) - const groupCount = 1 // Placeholder - const userCount = 0 // Placeholder return { key: definition.key, @@ -115,8 +164,8 @@ export function createRealPolicyWorkbenchState() { description: definition.description, menuHint: definition.menuHint, defaultSummary: policy?.effectiveValue ? definition.summarizeValue(policy.effectiveValue) : t('libresign', 'Not configured'), - groupCount, - userCount, + groupCount: groupRules.value.length, + userCount: userRules.value.length, } }) }) @@ -139,24 +188,25 @@ export function createRealPolicyWorkbenchState() { return null } + // A baseline "none" value means there is no explicit global rule persisted. + if (activeDefinition.value.key === 'signature_flow') { + const mode = resolveSignatureFlowMode(policy.effectiveValue) + if (mode === 'none') { + return null + } + } + return { id: 'system-default', scope: 'system', targetId: null, - allowChildOverride: true, + allowChildOverride: inferSystemAllowOverride(policy), value: policy.effectiveValue, } }) - const visibleGroupRules = computed(() => { - // Placeholder for now - return [] - }) - - const visibleUserRules = computed(() => { - // Placeholder for now - return [] - }) + const visibleGroupRules = computed(() => groupRules.value) + const visibleUserRules = computed(() => userRules.value) const availableTargets = computed(() => { if (!editorDraft.value) { @@ -175,12 +225,18 @@ export function createRealPolicyWorkbenchState() { }) const draftTargetLabel = computed(() => { - if (!editorDraft.value?.targetId) { + if (!editorDraft.value || editorDraft.value.targetIds.length === 0) { return null } - const target = availableTargets.value.find(t => t.id === editorDraft.value?.targetId) - return target?.label || null + const labels = editorDraft.value.targetIds + .map((targetId) => availableTargets.value.find((target) => target.id === targetId)?.displayName ?? targetId) + + if (labels.length === 1) { + return labels[0] + } + + return t('libresign', '{count} targets selected', { count: String(labels.length) }) }) const canSaveDraft = computed(() => { @@ -188,44 +244,156 @@ export function createRealPolicyWorkbenchState() { return false } - if (editorDraft.value.scope !== 'system' && !editorDraft.value.targetId) { + if (editorDraft.value.scope !== 'system' && editorDraft.value.targetIds.length === 0) { return false } return true }) - const duplicateMessage = ref(null) - function openSetting(key: string) { activeSettingKey.value = key } + function mergeSelectedTargets(scope: 'group' | 'user', fetchedTargets: PolicyTargetOption[]) { + const existingTargets = scope === 'group' ? groups.value : users.value + const selectedIds = editorDraft.value?.scope === scope ? editorDraft.value.targetIds : [] + const selectedTargets = existingTargets.filter((target) => { + return selectedIds.includes(target.id) && !fetchedTargets.some((option) => option.id === target.id) + }) + + return [...selectedTargets, ...fetchedTargets] + } + + async function fetchGroups(query = ''): Promise { + const { data } = await axios.get(generateOcsUrl('cloud/groups/details'), { + params: { + search: query, + limit: 20, + offset: 0, + }, + }) + + return (data.ocs?.data?.groups ?? []) + .map((group) => ({ + id: group.id, + displayName: group.displayname || group.id, + subname: typeof group.usercount === 'number' + ? t('libresign', '{count} members', { count: String(group.usercount) }) + : undefined, + isNoUser: true, + })) + .sort((left, right) => left.displayName.localeCompare(right.displayName)) + } + + async function fetchUsers(query = ''): Promise { + const { data } = await axios.get(generateOcsUrl('cloud/users/details'), { + params: { + search: query, + limit: 20, + offset: 0, + }, + }) + + return Object.values(data.ocs?.data?.users ?? {}) + .map((user) => ({ + id: user.id, + displayName: user['display-name'] || user.displayname || user.id, + subname: user.email, + user: user.id, + })) + .sort((left, right) => left.displayName.localeCompare(right.displayName)) + } + + async function loadTargets(scope: 'group' | 'user', query = '') { + loadingTargets.value = true + try { + if (scope === 'group') { + groups.value = mergeSelectedTargets('group', await fetchGroups(query)) + return + } + + users.value = mergeSelectedTargets('user', await fetchUsers(query)) + } catch (error) { + logger.debug('Could not load policy workbench targets', { + error, + scope, + query, + }) + } finally { + loadingTargets.value = false + } + } + + function searchAvailableTargets(query: string) { + const scope = editorDraft.value?.scope + if (scope !== 'group' && scope !== 'user') { + return + } + + void loadTargets(scope, query) + } + function closeSetting() { activeSettingKey.value = null editorDraft.value = null editorMode.value = null + duplicateMessage.value = null + } + + function resolveTargetLabel(scope: 'group' | 'user', targetId: string): string { + const targets = scope === 'group' ? groups.value : users.value + return targets.find((target) => target.id === targetId)?.displayName || targetId } - function startEditor({ scope, ruleId }: { scope: 'system' | 'group' | 'user', ruleId?: string }) { + function findRuleById(scope: PolicyScope, ruleId: string): PolicyRuleRecord | null { + if (scope === 'group') { + return groupRules.value.find((rule) => rule.id === ruleId) ?? null + } + + if (scope === 'user') { + return userRules.value.find((rule) => rule.id === ruleId) ?? null + } + + return inheritedSystemRule.value?.id === ruleId ? inheritedSystemRule.value : null + } + + function startEditor({ scope, ruleId }: { scope: PolicyScope, ruleId?: string }) { if (!activeDefinition.value) { return } const isEdit = !!ruleId editorMode.value = isEdit ? 'edit' : 'create' + duplicateMessage.value = null let value: EffectivePolicyValue = activeDefinition.value.createEmptyValue() - if (isEdit && scope === 'system' && inheritedSystemRule.value) { - value = inheritedSystemRule.value.value + let targetIds: string[] = [] + let allowChildOverride = true + + if (isEdit && ruleId) { + const rule = findRuleById(scope, ruleId) + if (rule) { + value = rule.value + allowChildOverride = rule.allowChildOverride + targetIds = rule.targetId ? [rule.targetId] : [] + } + } else if (scope === 'group') { + targetIds = [] + } else if (scope === 'user') { + targetIds = [] } editorDraft.value = { scope, ruleId: ruleId || null, - targetId: null, + targetIds, value, - allowChildOverride: true, + allowChildOverride, + } + + if (scope === 'group' || scope === 'user') { + void loadTargets(scope) } } @@ -241,10 +409,16 @@ export function createRealPolicyWorkbenchState() { } } - function updateDraftTarget(targetId: string | null) { - if (editorDraft.value) { - editorDraft.value.targetId = targetId + function updateDraftTargets(targetIds: string[]) { + if (!editorDraft.value) { + return } + + editorDraft.value.targetIds = Array.from(new Set(targetIds.filter(Boolean))) + } + + function updateDraftTarget(targetId: string | null) { + updateDraftTargets(targetId ? [targetId] : []) } function updateDraftAllowOverride(allowChildOverride: boolean) { @@ -253,23 +427,63 @@ export function createRealPolicyWorkbenchState() { } } + function upsertRule(ruleList: PolicyRuleRecord[], scope: 'group' | 'user', targetId: string, value: EffectivePolicyValue, allowChildOverride: boolean) { + const existingRule = ruleList.find((rule) => rule.targetId === targetId) + if (existingRule) { + existingRule.value = value + existingRule.allowChildOverride = allowChildOverride + highlightedRuleId.value = existingRule.id + return + } + + const id = `${scope}-${targetId}-${String(nextRuleNumber.value)}` + nextRuleNumber.value += 1 + + ruleList.push({ + id, + scope, + targetId, + allowChildOverride, + value, + }) + + highlightedRuleId.value = id + } + async function saveDraft() { if (!editorDraft.value || !activeDefinition.value) { return } - const { scope, value, allowChildOverride, targetId } = editorDraft.value + const { scope, value, allowChildOverride, targetIds } = editorDraft.value + const policyKey = activeDefinition.value.key try { if (scope === 'system') { - await policiesStore.saveSystemPolicy(activeDefinition.value.key, value) - } else if (scope === 'group' && targetId) { - await policiesStore.saveGroupPolicy( - targetId, - activeDefinition.value.key, - value, - allowChildOverride, - ) + await policiesStore.saveSystemPolicy(policyKey, value, allowChildOverride) + cancelEditor() + return + } + + if (scope === 'group') { + await Promise.all(targetIds.map((targetId) => { + return policiesStore.saveGroupPolicy(targetId, policyKey, value, allowChildOverride) + })) + + for (const targetId of targetIds) { + upsertRule(groupRules.value, 'group', targetId, value, allowChildOverride) + } + + cancelEditor() + return + } + + await Promise.all(targetIds.map((targetId) => { + return policiesStore.saveUserPolicyForUser(targetId, policyKey, value) + })) + + for (const targetId of targetIds) { + upsertRule(userRules.value, 'user', targetId, value, true) } cancelEditor() @@ -278,18 +492,58 @@ export function createRealPolicyWorkbenchState() { } } - function removeRule(ruleId: string) { + async function removeRule(ruleId: string) { if (!activeDefinition.value) { return } - // Placeholder for now - console.log('Remove rule:', ruleId) - } + const policyKey = activeDefinition.value.key + const inheritedSystemRuleId = inheritedSystemRule.value?.id + const isEditingMode = editorMode.value === 'edit' + + const shouldCloseSystemEditor = isEditingMode && editorDraft.value?.scope === 'system' + const shouldCloseGroupEditor = isEditingMode + && editorDraft.value?.scope === 'group' + && editorDraft.value.ruleId === ruleId + const shouldCloseUserEditor = isEditingMode + && editorDraft.value?.scope === 'user' + && editorDraft.value.ruleId === ruleId + + if (ruleId === 'system-default' || (inheritedSystemRuleId !== null && ruleId === inheritedSystemRuleId)) { + await policiesStore.saveSystemPolicy(policyKey, null as unknown as EffectivePolicyValue) + highlightedRuleId.value = null + if (shouldCloseSystemEditor) { + cancelEditor() + } + return + } - function resolveTargetLabel(scope: 'group' | 'user', targetId: string): string { - const targets = scope === 'group' ? groups.value : users.value - return targets.find(t => t.id === targetId)?.label || targetId + const groupIndex = groupRules.value.findIndex((rule) => rule.id === ruleId) + if (groupIndex >= 0) { + const targetId = groupRules.value[groupIndex]?.targetId + if (targetId) { + await policiesStore.clearGroupPolicy(targetId, policyKey) + } + groupRules.value.splice(groupIndex, 1) + highlightedRuleId.value = null + if (shouldCloseGroupEditor) { + cancelEditor() + } + return + } + + const userIndex = userRules.value.findIndex((rule) => rule.id === ruleId) + if (userIndex >= 0) { + const targetId = userRules.value[userIndex]?.targetId + if (targetId) { + await policiesStore.clearUserPolicyForUser(targetId, policyKey) + } + userRules.value.splice(userIndex, 1) + highlightedRuleId.value = null + if (shouldCloseUserEditor) { + cancelEditor() + } + } } return reactive({ @@ -303,6 +557,7 @@ export function createRealPolicyWorkbenchState() { highlightedRuleId: highlightedRuleId as any, viewMode: viewMode as any, availableTargets, + loadingTargets, draftTargetLabel, duplicateMessage: duplicateMessage as any, canSaveDraft, @@ -312,7 +567,9 @@ export function createRealPolicyWorkbenchState() { cancelEditor, updateDraftValue, updateDraftTarget, + updateDraftTargets, updateDraftAllowOverride, + searchAvailableTargets, saveDraft, removeRule, resolveTargetLabel, From e4d5363f93a94ec572463dba89891c5db23af219 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:31:04 -0300 Subject: [PATCH 089/417] feat(policy-workbench): add reset feedback and post-remove UX flow Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PolicyWorkbench/RealPolicyWorkbench.vue | 367 +++++++++++------- 1 file changed, 219 insertions(+), 148 deletions(-) diff --git a/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue b/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue index 79f7f669dd..05d07f427d 100644 --- a/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue +++ b/src/views/Settings/PolicyWorkbench/RealPolicyWorkbench.vue @@ -6,14 +6,14 @@ @@ -476,7 +492,7 @@ import NcCheckboxRadioSwitch from '@nextcloud/vue/components/NcCheckboxRadioSwit import NcDialog from '@nextcloud/vue/components/NcDialog' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' -import NcSelect from '@nextcloud/vue/components/NcSelect' +import NcSelectUsers from '@nextcloud/vue/components/NcSelectUsers' import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection' import NcTextField from '@nextcloud/vue/components/NcTextField' @@ -495,7 +511,10 @@ const isSmallViewport = ref(false) const catalogLayout = ref<'cards' | 'compact'>('cards') const saveStatus = ref<'idle' | 'saving' | 'saved'>('idle') const saveFeedbackTimeout = ref(null) -const pendingRemoval = ref<{ ruleId: string, targetLabel: string, help: string } | null>(null) +const pendingRemoval = ref<{ ruleId: string, scope: 'system' | 'group' | 'user', targetLabel: string, help: string } | null>(null) +const isRemovingRule = ref(false) +const removalFeedback = ref(null) +const removalFeedbackTimeout = ref(null) const lastPress = ref<{ surface: 'cards' | 'list', key: string, x: number, y: number } | null>(null) const recentSelectionGesture = ref<{ surface: 'cards' | 'list', key: string, at: number } | null>(null) @@ -521,15 +540,15 @@ const hasActiveFilter = computed(() => settingsFilter.value.trim().length > 0) const catalogViewButtonLabel = computed(() => { return effectiveCatalogLayout.value === 'cards' ? t('libresign', 'Switch to compact view') - : t('libresign', 'Switch to cards view') + : t('libresign', 'Switch to card view') }) -const selectedTargetOption = computed(() => { +const selectedTargetOptions = computed(() => { if (!state.editorDraft) { - return null + return [] } - return state.availableTargets.find(option => option.id === state.editorDraft?.targetId) ?? null + return state.availableTargets.filter((option) => state.editorDraft?.targetIds.includes(option.id)) }) const systemRuleBadges = computed(() => { @@ -548,10 +567,10 @@ const editorTitle = computed(() => { } if (state.editorDraft.scope === 'system') { - return t('libresign', 'Default rule') + return t('libresign', 'Global default rule') } - return state.draftTargetLabel || t('libresign', 'Select target') + return state.draftTargetLabel || t('libresign', 'Select one or more targets') }) const editorHelp = computed(() => { @@ -560,14 +579,44 @@ const editorHelp = computed(() => { } if (state.editorDraft.scope === 'system') { - return t('libresign', 'This rule becomes the baseline inherited by groups and users unless another rule overrides it.') + return t('libresign', 'This rule becomes the baseline inherited by groups and users unless another override is set.') } if (state.editorDraft.scope === 'group') { - return t('libresign', 'A group rule overrides the default and can still allow lower layers to diverge.') + return t('libresign', 'A group override replaces the global default and can still allow lower layers to diverge.') } - return t('libresign', 'A user rule is the most specific layer and wins over inherited defaults.') + return t('libresign', 'A user override is the most specific layer and takes priority over inherited defaults.') +}) + +const pendingRemovalMessage = computed(() => { + if (!pendingRemoval.value) { + return '' + } + + return t('libresign', 'You are about to remove the rule for {target}. {help}', { + target: pendingRemoval.value.targetLabel, + help: pendingRemoval.value.help, + }) +}) + +const removalDialogButtons = computed(() => { + return [ + { + label: t('libresign', 'Cancel'), + variant: 'secondary' as const, + disabled: isRemovingRule.value, + callback: () => cancelRuleRemoval(), + }, + { + label: isRemovingRule.value ? t('libresign', 'Removing rule...') : t('libresign', 'Remove rule'), + variant: 'error' as const, + disabled: isRemovingRule.value, + callback: () => { + void confirmRuleRemoval() + }, + }, + ] }) function groupRuleBadges(allowChildOverride: boolean) { @@ -579,8 +628,13 @@ function groupRuleBadges(allowChildOverride: boolean) { return allowOverrideBadge ? [allowOverrideBadge] : [] } -function onTargetChange(option: { id: string } | null) { - state.updateDraftTarget(option?.id ?? null) +function onTargetChange(option: { id: string } | Array<{ id: string }> | null) { + if (Array.isArray(option)) { + state.updateDraftTargets(option.map(({ id }) => id)) + return + } + + state.updateDraftTargets(option?.id ? [option.id] : []) } function summarizeRuleValue(value: unknown) { @@ -712,7 +766,7 @@ function resolveSettingOrigin(groupCount: number, userCount: number) { return t('libresign', 'Group overrides active') } - return t('libresign', 'Inherited default only') + return t('libresign', 'Using global default only') } function toggleCatalogLayout() { @@ -750,25 +804,46 @@ async function handleSaveDraft() { function promptRuleRemoval(ruleId: string, scope: 'system' | 'group' | 'user', targetLabel: string) { const help = scope === 'system' - ? t('libresign', 'Removing this rule will restore inherited behavior for all groups and users.') + ? t('libresign', 'Removing this rule will make all groups and users inherit the platform default.') : scope === 'group' - ? t('libresign', 'Removing this rule will restore the global default for this group.') + ? t('libresign', 'Removing this rule will restore inherited behavior from the global default for this group.') : t('libresign', 'Removing this rule will restore inherited behavior for this user.') - pendingRemoval.value = { ruleId, targetLabel, help } + pendingRemoval.value = { ruleId, scope, targetLabel, help } } function cancelRuleRemoval() { pendingRemoval.value = null } -function confirmRuleRemoval() { +async function confirmRuleRemoval() { if (!pendingRemoval.value) { return } - state.removeRule(pendingRemoval.value.ruleId) - pendingRemoval.value = null + isRemovingRule.value = true + try { + const scope = pendingRemoval.value.scope + await state.removeRule(pendingRemoval.value.ruleId) + removalFeedback.value = scope === 'system' + ? t('libresign', 'Global default reset. Inherited behavior is now active.') + : scope === 'group' + ? t('libresign', 'Group override removed. Inherited behavior from the global default is now active.') + : t('libresign', 'User override removed. Inherited behavior is now active.') + + if (removalFeedbackTimeout.value !== null) { + window.clearTimeout(removalFeedbackTimeout.value) + } + + removalFeedbackTimeout.value = window.setTimeout(() => { + removalFeedback.value = null + removalFeedbackTimeout.value = null + }, 2200) + + pendingRemoval.value = null + } finally { + isRemovingRule.value = false + } } onMounted(async () => { @@ -782,6 +857,10 @@ onBeforeUnmount(() => { if (saveFeedbackTimeout.value !== null) { window.clearTimeout(saveFeedbackTimeout.value) } + + if (removalFeedbackTimeout.value !== null) { + window.clearTimeout(removalFeedbackTimeout.value) + } }) @@ -1221,6 +1300,22 @@ onBeforeUnmount(() => { gap: 0.45rem; } + &__inline-note-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + + p { + margin: 0; + } + + :deep(.button-vue) { + max-width: 100%; + } + } + &__setting-surface { display: flex; flex-direction: column; @@ -1284,39 +1379,6 @@ onBeforeUnmount(() => { animation: policy-workbench-spin 0.8s linear infinite; } - &__confirm-dialog { - display: flex; - flex-direction: column; - gap: 0.75rem; - width: min(540px, calc(100vw - 2rem)); - max-width: 100%; - box-sizing: border-box; - - p { - margin: 0; - } - } - - &__confirm-target { - font-weight: 700; - } - - &__confirm-help { - color: var(--color-text-maxcontrast); - } - - &__confirm-actions { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 0.6rem; - align-items: stretch; - - :deep(.button-vue) { - width: 100%; - justify-content: center; - } - } - :deep(mark) { background: color-mix(in srgb, var(--color-warning) 35%, transparent); color: inherit; @@ -1377,6 +1439,15 @@ onBeforeUnmount(() => { flex-direction: column; } + &__removal-feedback { + margin: 0 0 0.8rem; + padding: 0.65rem 0.8rem; + border: 1px solid color-mix(in srgb, var(--color-success) 36%, transparent); + border-radius: 10px; + background: color-mix(in srgb, var(--color-success) 12%, var(--color-main-background)); + color: var(--color-main-text); + } + &__settings-row { grid-template-columns: 1fr; align-items: stretch; @@ -1407,7 +1478,10 @@ onBeforeUnmount(() => { } } - &__confirm-actions { + &__inline-note-actions { + align-items: stretch; + justify-content: flex-start; + :deep(.button-vue) { width: 100%; justify-content: center; @@ -1478,9 +1552,6 @@ onBeforeUnmount(() => { grid-template-columns: 1fr; } - &__confirm-actions { - grid-template-columns: 1fr; - } } } From d8d3c52570c93e0d6b2007493e7b73ce13f5e1af Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:31:11 -0300 Subject: [PATCH 090/417] test(policy-workbench): cover editor close on rule reset and removal Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../useRealPolicyWorkbench.spec.ts | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts diff --git a/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts b/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts new file mode 100644 index 0000000000..aef4dd48a6 --- /dev/null +++ b/src/tests/views/Settings/PolicyWorkbench/useRealPolicyWorkbench.spec.ts @@ -0,0 +1,277 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createL10nMock } from '../../../testHelpers/l10n.js' + +vi.mock('@nextcloud/l10n', () => createL10nMock()) + +const { axiosGet } = vi.hoisted(() => ({ + axiosGet: vi.fn(), +})) + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: axiosGet, + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: vi.fn((path: string) => path), +})) + +const saveSystemPolicy = vi.fn() +const saveGroupPolicy = vi.fn() +const saveUserPolicyForUser = vi.fn() +const clearGroupPolicy = vi.fn() +const clearUserPolicyForUser = vi.fn() +const getPolicy = vi.fn() + +vi.mock('../../../../store/policies', () => ({ + usePoliciesStore: () => ({ + saveSystemPolicy, + saveGroupPolicy, + saveUserPolicyForUser, + clearGroupPolicy, + clearUserPolicyForUser, + getPolicy, + }), +})) + +import { createRealPolicyWorkbenchState } from '../../../../views/Settings/PolicyWorkbench/useRealPolicyWorkbench' + +describe('useRealPolicyWorkbench', () => { + beforeEach(() => { + axiosGet.mockReset() + saveSystemPolicy.mockReset() + saveGroupPolicy.mockReset() + saveUserPolicyForUser.mockReset() + clearGroupPolicy.mockReset() + clearUserPolicyForUser.mockReset() + getPolicy.mockReset() + getPolicy.mockReturnValue({ effectiveValue: 'parallel' }) + axiosGet.mockImplementation((url: string) => { + if (url === 'cloud/groups/details') { + return Promise.resolve({ + data: { + ocs: { + data: { + groups: [ + { id: 'finance', displayname: 'Finance', usercount: 3 }, + { id: 'legal', displayname: 'Legal', usercount: 2 }, + ], + }, + }, + }, + }) + } + + if (url === 'cloud/users/details') { + return Promise.resolve({ + data: { + ocs: { + data: { + users: { + user1: { id: 'user1', displayname: 'User One', email: 'user1@example.com' }, + user3: { id: 'user3', 'display-name': 'User Three', email: 'user3@example.com' }, + }, + }, + }, + }, + }) + } + + return Promise.resolve({ data: { ocs: { data: {} } } }) + }) + }) + + it('loads real group targets from OCS when opening the group editor', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'group' }) + + await Promise.resolve() + await Promise.resolve() + + expect(axiosGet).toHaveBeenCalledWith('cloud/groups/details', { + params: { + search: '', + limit: 20, + offset: 0, + }, + }) + expect(state.availableTargets).toEqual([ + { id: 'finance', displayName: 'Finance', subname: '3 members', isNoUser: true }, + { id: 'legal', displayName: 'Legal', subname: '2 members', isNoUser: true }, + ]) + }) + + it('loads real user targets from OCS when searching the user editor', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'user' }) + + await Promise.resolve() + state.searchAvailableTargets('user') + await Promise.resolve() + await Promise.resolve() + + expect(axiosGet).toHaveBeenLastCalledWith('cloud/users/details', { + params: { + search: 'user', + limit: 20, + offset: 0, + }, + }) + expect(state.availableTargets).toEqual([ + { id: 'user1', displayName: 'User One', subname: 'user1@example.com', user: 'user1' }, + { id: 'user3', displayName: 'User Three', subname: 'user3@example.com', user: 'user3' }, + ]) + }) + + it('saves system signature_flow rule', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'system' }) + state.updateDraftValue('ordered_numeric' as never) + + await state.saveDraft() + + expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', 'ordered_numeric', true) + }) + + it('supports multi-target group save for signature_flow', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'group' }) + state.updateDraftTargets(['finance', 'legal']) + state.updateDraftValue('ordered_numeric' as never) + state.updateDraftAllowOverride(false) + + await state.saveDraft() + + expect(saveGroupPolicy).toHaveBeenCalledTimes(2) + expect(saveGroupPolicy).toHaveBeenNthCalledWith(1, 'finance', 'signature_flow', 'ordered_numeric', false) + expect(saveGroupPolicy).toHaveBeenNthCalledWith(2, 'legal', 'signature_flow', 'ordered_numeric', false) + }) + + it('supports multi-target user save for signature_flow', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'user' }) + state.updateDraftTargets(['user1', 'user3']) + state.updateDraftValue('parallel' as never) + + await state.saveDraft() + + expect(saveUserPolicyForUser).toHaveBeenCalledTimes(2) + expect(saveUserPolicyForUser).toHaveBeenNthCalledWith(1, 'user1', 'signature_flow', 'parallel') + expect(saveUserPolicyForUser).toHaveBeenNthCalledWith(2, 'user3', 'signature_flow', 'parallel') + }) + + it('removes persisted group and user rules', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + + state.startEditor({ scope: 'group' }) + state.updateDraftTargets(['finance']) + await state.saveDraft() + + state.startEditor({ scope: 'user' }) + state.updateDraftTargets(['user1']) + await state.saveDraft() + + const groupRuleId = state.visibleGroupRules[0]?.id + const userRuleId = state.visibleUserRules[0]?.id + expect(groupRuleId).toBeTruthy() + expect(userRuleId).toBeTruthy() + + if (!groupRuleId || !userRuleId) { + throw new Error('Expected created group and user rules') + } + + await state.removeRule(groupRuleId) + await state.removeRule(userRuleId) + + expect(clearGroupPolicy).toHaveBeenCalledTimes(1) + expect(clearGroupPolicy).toHaveBeenCalledWith('finance', 'signature_flow') + expect(clearUserPolicyForUser).toHaveBeenCalledTimes(1) + expect(clearUserPolicyForUser).toHaveBeenCalledWith('user1', 'signature_flow') + expect(state.visibleGroupRules).toHaveLength(0) + expect(state.visibleUserRules).toHaveLength(0) + }) + + it('resets system default rule through backend request', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + + await state.removeRule('system-default') + + expect(saveSystemPolicy).toHaveBeenCalledTimes(1) + expect(saveSystemPolicy).toHaveBeenCalledWith('signature_flow', null) + }) + + it('closes editor when the edited system rule is reset', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + state.startEditor({ scope: 'system', ruleId: 'system-default' }) + + expect(state.editorDraft).not.toBeNull() + expect(state.editorMode).toBe('edit') + + await state.removeRule('system-default') + + expect(state.editorDraft).toBeNull() + expect(state.editorMode).toBeNull() + }) + + it('closes editor when the edited group rule is removed', async () => { + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + + state.startEditor({ scope: 'group' }) + state.updateDraftTargets(['finance']) + await state.saveDraft() + + const groupRuleId = state.visibleGroupRules[0]?.id + expect(groupRuleId).toBeTruthy() + + if (!groupRuleId) { + throw new Error('Expected a created group rule') + } + + state.startEditor({ scope: 'group', ruleId: groupRuleId }) + expect(state.editorMode).toBe('edit') + + await state.removeRule(groupRuleId) + + expect(state.editorDraft).toBeNull() + expect(state.editorMode).toBeNull() + }) + + it('does not render a system default rule when effective value is baseline none', () => { + getPolicy.mockReturnValue({ effectiveValue: 'none' }) + + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + + expect(state.inheritedSystemRule).toBeNull() + }) + + it('hydrates system rule override toggle from backend allowed values', () => { + getPolicy.mockReturnValue({ + effectiveValue: 'parallel', + allowedValues: ['parallel'], + }) + + const state = createRealPolicyWorkbenchState() + state.openSetting('signature_flow') + + expect(state.inheritedSystemRule?.allowChildOverride).toBe(false) + + state.startEditor({ scope: 'system', ruleId: 'system-default' }) + expect(state.editorDraft?.allowChildOverride).toBe(false) + }) +}) From a0ae5b14badd7809d84b2264c1e6752d9fca5462 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:39 -0300 Subject: [PATCH 091/417] feat(Controller): implement PolicyController behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Controller/PolicyController.php | 186 +++++++++++++++++++++++++++- 1 file changed, 184 insertions(+), 2 deletions(-) diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php index 0b6b643ada..7b734ea31f 100644 --- a/lib/Controller/PolicyController.php +++ b/lib/Controller/PolicyController.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Controller; use OCA\Libresign\AppInfo\Application; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; use OCA\Libresign\Service\Policy\PolicyService; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; @@ -22,6 +23,9 @@ * @psalm-import-type LibresignErrorResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePolicyState from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignEffectivePoliciesResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignGroupPolicyResponse from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignGroupPolicyState from \OCA\Libresign\ResponseDefinitions + * @psalm-import-type LibresignGroupPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions * @psalm-import-type LibresignSystemPolicyWriteResponse from \OCA\Libresign\ResponseDefinitions */ final class PolicyController extends AEnvironmentAwareController { @@ -60,11 +64,33 @@ public function effective(): DataResponse { return new DataResponse($data); } + /** + * Read a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to read for the selected group. + * @return DataResponse + * + * 200: OK + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function getGroup(string $groupId, string $policyKey): DataResponse { + $policy = $this->policyService->getGroupPolicy($policyKey, $groupId); + + /** @var LibresignGroupPolicyResponse $data */ + $data = [ + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } + /** * Save a system-level policy value * * @param string $policyKey Policy identifier to persist at the system layer. * @param null|bool|int|float|string $value Policy value to persist. Null resets the policy to its default system value. + * @param bool $allowChildOverride Whether lower layers may override this system default. * @return DataResponse|DataResponse|DataResponse * * 200: OK @@ -72,9 +98,9 @@ public function effective(): DataResponse { * 500: Internal server error */ #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] - public function setSystem(string $policyKey, null|bool|int|float|string $value = null): DataResponse { + public function setSystem(string $policyKey, null|bool|int|float|string $value = null, bool $allowChildOverride = false): DataResponse { try { - $policy = $this->policyService->saveSystem($policyKey, $value); + $policy = $this->policyService->saveSystem($policyKey, $value, $allowChildOverride); /** @var LibresignSystemPolicyWriteResponse $data */ $data = [ 'message' => $this->l10n->t('Settings saved'), @@ -99,6 +125,78 @@ public function setSystem(string $policyKey, null|bool|int|float|string $value = } } + /** + * Save a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to persist at the group layer. + * @param null|bool|int|float|string $value Policy value to persist for the group. + * @param bool $allowChildOverride Whether users and requests below this group may override the group default. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 500: Internal server error + */ + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function setGroup(string $groupId, string $policyKey, null|bool|int|float|string $value = null, bool $allowChildOverride = false): DataResponse { + try { + $policy = $this->policyService->saveGroupPolicy($policyKey, $groupId, $value, $allowChildOverride); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t($exception->getMessage()), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** + * Clear a group-level policy value + * + * @param string $groupId Group identifier that receives the policy binding. + * @param string $policyKey Policy identifier to clear for the selected group. + * @return DataResponse|DataResponse + * + * 200: OK + * 500: Internal server error + */ + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/group/{groupId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'groupId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function clearGroup(string $groupId, string $policyKey): DataResponse { + try { + $policy = $this->policyService->clearGroupPolicy($policyKey, $groupId); + /** @var LibresignGroupPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Save a user policy preference * @@ -139,6 +237,46 @@ public function setUserPreference(string $policyKey, null|bool|int|float|string } } + /** + * Save a user policy preference for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy preference. + * @param string $policyKey Policy identifier to persist for the target user. + * @param null|bool|int|float|string $value Policy value to persist as target user preference. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 500: Internal server error + */ + #[ApiRoute(verb: 'PUT', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function setUserPolicyForUser(string $userId, string $policyKey, null|bool|int|float|string $value = null): DataResponse { + try { + $policy = $this->policyService->saveUserPreferenceForUserId($policyKey, $userId, $value); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t($exception->getMessage()), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + /** * Clear a user policy preference * @@ -169,4 +307,48 @@ public function clearUserPreference(string $policyKey): DataResponse { return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); } } + + /** + * Clear a user policy preference for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy preference removal. + * @param string $policyKey Policy identifier to clear for the target user. + * @return DataResponse|DataResponse + * + * 200: OK + * 500: Internal server error + */ + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPolicyForUser(string $userId, string $policyKey): DataResponse { + try { + $policy = $this->policyService->clearUserPreferenceForUserId($policyKey, $userId); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } catch (\Throwable $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_INTERNAL_SERVER_ERROR); + } + } + + /** @return LibresignGroupPolicyState */ + private function serializeGroupPolicy(string $groupId, string $policyKey, ?PolicyLayer $policy): array { + return [ + 'policyKey' => $policyKey, + 'scope' => 'group', + 'targetId' => $groupId, + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + 'visibleToChild' => $policy?->isVisibleToChild() ?? true, + 'allowedValues' => $policy?->getAllowedValues() ?? [], + ]; + } } From 952d9241f71a50484fa886c1985046b04e46326e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:39 -0300 Subject: [PATCH 092/417] feat(lib): implement ResponseDefinitions behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/ResponseDefinitions.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index 57f83294c8..9b96993284 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -376,6 +376,23 @@ * @psalm-type LibresignSystemPolicyWriteRequest = array{ * value: LibresignEffectivePolicyValue, * } + * @psalm-type LibresignGroupPolicyState = array{ + * policyKey: string, + * scope: 'group', + * targetId: string, + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * visibleToChild: bool, + * allowedValues: list, + * } + * @psalm-type LibresignGroupPolicyResponse = array{ + * policy: LibresignGroupPolicyState, + * } + * @psalm-type LibresignGroupPolicyWriteRequest = array{ + * value: LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * } + * @psalm-type LibresignGroupPolicyWriteResponse = LibresignMessageResponse&LibresignGroupPolicyResponse * @psalm-type LibresignSystemPolicyWriteResponse = LibresignMessageResponse&LibresignEffectivePolicyResponse * @psalm-type LibresignPolicySnapshotEntry = array{ * effectiveValue: string, From b724104427a80b48081faa413892ecbc681c6b84 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:39 -0300 Subject: [PATCH 093/417] feat(Contract): implement IPolicySource behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/Contract/IPolicySource.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/Service/Policy/Contract/IPolicySource.php b/lib/Service/Policy/Contract/IPolicySource.php index ccfde96a9a..4d51761585 100644 --- a/lib/Service/Policy/Contract/IPolicySource.php +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -24,7 +24,13 @@ public function loadUserPreference(string $policyKey, PolicyContext $context): ? public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer; - public function saveSystemPolicy(string $policyKey, mixed $value): void; + public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer; + + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void; + + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void; + + public function clearGroupPolicy(string $policyKey, string $groupId): void; public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void; From 51beb9574df3435e25bde385d6d25940fcdbf6b5 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:39 -0300 Subject: [PATCH 094/417] feat(Policy): implement PolicyService behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/PolicyService.php | 55 +++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php index 3c0b54f293..09d30e226b 100644 --- a/lib/Service/Policy/PolicyService.php +++ b/lib/Service/Policy/PolicyService.php @@ -8,6 +8,7 @@ namespace OCA\Libresign\Service\Policy; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\Provider\PolicyProviders; use OCA\Libresign\Service\Policy\Runtime\DefaultPolicyResolver; @@ -62,7 +63,7 @@ public function resolveKnownPolicies(array $requestOverrides = [], ?array $activ return $this->resolver->resolveMany($definitions, $context); } - public function saveSystem(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { + public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $allowChildOverride = false): ResolvedPolicy { $context = $this->contextFactory->forCurrentUser(); $definition = $this->registry->get($policyKey); $normalizedValue = $value === null @@ -70,11 +71,38 @@ public function saveSystem(string|\BackedEnum $policyKey, mixed $value): Resolve : $definition->normalizeValue($value); $definition->validateValue($normalizedValue, $context); - $this->source->saveSystemPolicy($definition->key(), $normalizedValue); + $this->source->saveSystemPolicy($definition->key(), $normalizedValue, $allowChildOverride); return $this->resolver->resolve($definition, $context); } + public function getGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + public function saveGroupPolicy(string|\BackedEnum $policyKey, string $groupId, mixed $value, bool $allowChildOverride): PolicyLayer { + $definition = $this->registry->get($policyKey); + $context = $this->contextFactory->forCurrentUser(); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveGroupPolicy($definition->key(), $groupId, $normalizedValue, $allowChildOverride); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId) + ?? (new PolicyLayer()) + ->setScope('group') + ->setVisibleToChild(true) + ->setAllowChildOverride(true) + ->setAllowedValues([]); + } + + public function clearGroupPolicy(string|\BackedEnum $policyKey, string $groupId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + $this->source->clearGroupPolicy($definition->key(), $groupId); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + public function saveUserPreference(string|\BackedEnum $policyKey, mixed $value): ResolvedPolicy { $context = $this->contextFactory->forCurrentUser(); $definition = $this->registry->get($policyKey); @@ -97,4 +125,27 @@ public function clearUserPreference(string|\BackedEnum $policyKey): ResolvedPoli return $this->resolver->resolve($definition, $context); } + + public function saveUserPreferenceForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value): ResolvedPolicy { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $resolved = $this->resolver->resolve($definition, $context); + if (!$resolved->canSaveAsUserDefault()) { + throw new \InvalidArgumentException('Saving a user preference is not allowed for ' . $definition->key()); + } + + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveUserPreference($definition->key(), $context, $normalizedValue); + + return $this->resolver->resolve($definition, $context); + } + + public function clearUserPreferenceForUserId(string|\BackedEnum $policyKey, string $userId): ResolvedPolicy { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $this->source->clearUserPreference($definition->key(), $context); + + return $this->resolver->resolve($definition, $context); + } } From 6258585393130091340b989d22f23b355e7591a2 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 095/417] feat(Runtime): implement PolicySource behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/Policy/Runtime/PolicySource.php | 146 +++++++++++++++++++- 1 file changed, 140 insertions(+), 6 deletions(-) diff --git a/lib/Service/Policy/Runtime/PolicySource.php b/lib/Service/Policy/Runtime/PolicySource.php index 3a4f0c8e66..a3baaadf00 100644 --- a/lib/Service/Policy/Runtime/PolicySource.php +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -15,6 +15,7 @@ use OCA\Libresign\Service\Policy\Contract\IPolicySource; use OCA\Libresign\Service\Policy\Model\PolicyContext; use OCA\Libresign\Service\Policy\Model\PolicyLayer; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Services\IAppConfig; class PolicySource implements IPolicySource { @@ -30,21 +31,29 @@ public function __construct( public function loadSystemPolicy(string $policyKey): ?PolicyLayer { $definition = $this->registry->get($policyKey); $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); - $value = $this->appConfig->getAppValueString($definition->getAppConfigKey(), (string)$defaultValue); - $value = $definition->normalizeValue($value); + $storedValue = $this->appConfig->getAppValueString($definition->getAppConfigKey(), ''); + $hasExplicitSystemValue = $storedValue !== ''; + $value = $hasExplicitSystemValue + ? $definition->normalizeValue($storedValue) + : $defaultValue; $layer = (new PolicyLayer()) ->setScope('system') ->setValue($value) ->setVisibleToChild(true); - if ($value === $defaultValue) { + if (!$hasExplicitSystemValue || $value === $defaultValue) { return $layer->setAllowChildOverride(true); } + $allowChildOverride = $this->appConfig->getAppValueString( + $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()), + '0', + ) === '1'; + return $layer - ->setAllowChildOverride(false) - ->setAllowedValues([$value]); + ->setAllowChildOverride($allowChildOverride) + ->setAllowedValues($allowChildOverride ? [] : [$value]); } #[\Override] @@ -136,17 +145,107 @@ public function loadRequestOverride(string $policyKey, PolicyContext $context): } #[\Override] - public function saveSystemPolicy(string $policyKey, mixed $value): void { + public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer { + $permissionSet = $this->findPermissionSetByGroupId($groupId); + if (!$permissionSet instanceof PermissionSet) { + return null; + } + + $policyConfig = $permissionSet->getPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + return null; + } + + return $this->createGroupPolicyLayer($policyConfig); + } + + #[\Override] + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void { $definition = $this->registry->get($policyKey); $normalizedValue = $definition->normalizeValue($value); $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $allowOverrideConfigKey = $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()); if ($normalizedValue === $defaultValue) { $this->appConfig->deleteAppValue($definition->getAppConfigKey()); + $this->appConfig->deleteAppValue($allowOverrideConfigKey); return; } $this->appConfig->setAppValueString($definition->getAppConfigKey(), (string)$normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, $allowChildOverride ? '1' : '0'); + } + + private function getSystemAllowOverrideConfigKey(string $policyConfigKey): string { + return $policyConfigKey . '.allow_child_override'; + } + + #[\Override] + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void { + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $permissionSet = $this->findPermissionSetByGroupId($groupId); + $now = new \DateTime('now', new \DateTimeZone('UTC')); + + if (!$permissionSet instanceof PermissionSet) { + $permissionSet = new PermissionSet(); + $permissionSet->setName('group:' . $groupId); + $permissionSet->setScopeType('group'); + $permissionSet->setCreatedAt($now); + } + + $policyJson = $permissionSet->getPolicyJson(); + $policyJson[$policyKey] = [ + 'defaultValue' => $normalizedValue, + 'allowChildOverride' => $allowChildOverride, + 'visibleToChild' => true, + 'allowedValues' => $allowChildOverride ? [] : [$normalizedValue], + ]; + + $permissionSet->setPolicyJson($policyJson); + $permissionSet->setUpdatedAt($now); + + if ($permissionSet->getId() > 0) { + $this->permissionSetMapper->update($permissionSet); + return; + } + + /** @var PermissionSet $permissionSet */ + $permissionSet = $this->permissionSetMapper->insert($permissionSet); + + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId($permissionSet->getId()); + $binding->setTargetType('group'); + $binding->setTargetId($groupId); + $binding->setCreatedAt($now); + + $this->bindingMapper->insert($binding); + } + + #[\Override] + public function clearGroupPolicy(string $policyKey, string $groupId): void { + $binding = $this->findBindingByGroupId($groupId); + if (!$binding instanceof PermissionSetBinding) { + return; + } + + $permissionSet = $this->findPermissionSetByBinding($binding); + if (!$permissionSet instanceof PermissionSet) { + return; + } + + $policyJson = $permissionSet->getPolicyJson(); + unset($policyJson[$policyKey]); + + if ($policyJson === []) { + $this->bindingMapper->delete($binding); + $this->permissionSetMapper->delete($permissionSet); + return; + } + + $permissionSet->setPolicyJson($policyJson); + $permissionSet->setUpdatedAt(new \DateTime('now', new \DateTimeZone('UTC'))); + $this->permissionSetMapper->update($permissionSet); } #[\Override] @@ -181,4 +280,39 @@ private function resolveGroupIds(PolicyContext $context): array { return $context->getGroups(); } + + /** @param array $policyConfig */ + private function createGroupPolicyLayer(array $policyConfig): PolicyLayer { + return (new PolicyLayer()) + ->setScope('group') + ->setValue($policyConfig['defaultValue'] ?? null) + ->setAllowChildOverride((bool)($policyConfig['allowChildOverride'] ?? false)) + ->setVisibleToChild((bool)($policyConfig['visibleToChild'] ?? true)) + ->setAllowedValues(is_array($policyConfig['allowedValues'] ?? null) ? $policyConfig['allowedValues'] : []); + } + + private function findBindingByGroupId(string $groupId): ?PermissionSetBinding { + try { + return $this->bindingMapper->getByTarget('group', $groupId); + } catch (DoesNotExistException) { + return null; + } + } + + private function findPermissionSetByBinding(PermissionSetBinding $binding): ?PermissionSet { + try { + return $this->permissionSetMapper->getById($binding->getPermissionSetId()); + } catch (DoesNotExistException) { + return null; + } + } + + private function findPermissionSetByGroupId(string $groupId): ?PermissionSet { + $binding = $this->findBindingByGroupId($groupId); + if (!$binding instanceof PermissionSetBinding) { + return null; + } + + return $this->findPermissionSetByBinding($binding); + } } From f51bffd270efcd58d677e72d42aac35a0d9f5943 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 096/417] chore(repo): refresh openapi-administration behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-administration.json | 468 ++++++++++++++++++++++++++++++++++++ 1 file changed, 468 insertions(+) diff --git a/openapi-administration.json b/openapi-administration.json index 70fc1059f9..115b7fbd0c 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -563,6 +563,69 @@ } } }, + "GroupPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + }, + "GroupPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "GroupPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + ] + }, "HasRootCertResponse": { "type": "object", "required": [ @@ -4167,6 +4230,411 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist for the group.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether users and requests below this group may override the group default." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the group layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { "post": { "operationId": "policy-set-system", From 026bc6e6d2a7e5d85982c33fe17f79a6f097d60e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 097/417] chore(repo): refresh openapi-full behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- openapi-full.json | 483 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 483 insertions(+) diff --git a/openapi-full.json b/openapi-full.json index 5a57fa9235..c44673d2fa 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -1542,6 +1542,84 @@ } } }, + "GroupPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/GroupPolicyState" + } + } + }, + "GroupPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "group" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "GroupPolicyWriteRequest": { + "type": "object", + "required": [ + "value", + "allowChildOverride" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue" + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "GroupPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + ] + }, "HasRootCertResponse": { "type": "object", "required": [ @@ -13822,6 +13900,411 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyResponse" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "value": { + "nullable": true, + "description": "Policy value to persist for the group.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether users and requests below this group may override the group default." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the group layer.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid policy value", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "groupId", + "in": "path", + "description": "Group identifier that receives the policy binding.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the selected group.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/GroupPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { "post": { "operationId": "policy-set-system", From d46338b34a3d0a896de3c1097980e455217886d6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 098/417] feat(store): implement policies behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/store/policies.ts | 109 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 2 deletions(-) diff --git a/src/store/policies.ts b/src/store/policies.ts index 365e2ded38..d2ea2892f6 100644 --- a/src/store/policies.ts +++ b/src/store/policies.ts @@ -15,6 +15,10 @@ import type { EffectivePolicyValue, EffectivePoliciesResponse, EffectivePoliciesState, + GroupPolicyResponse, + GroupPolicyState, + GroupPolicyWritePayload, + GroupPolicyWriteResponse, SystemPolicyWritePayload, SystemPolicyWriteResponse, } from '../types/index' @@ -36,6 +40,20 @@ function isEffectivePolicyState(value: unknown): value is EffectivePolicyState { && (candidate.blockedBy === null || typeof candidate.blockedBy === 'string') } +function isGroupPolicyState(value: unknown): value is GroupPolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && candidate.scope === 'group' + && typeof candidate.targetId === 'string' + && typeof candidate.allowChildOverride === 'boolean' + && typeof candidate.visibleToChild === 'boolean' + && Array.isArray(candidate.allowedValues) +} + function sanitizePolicies(rawPolicies: Record): EffectivePoliciesState { const nextPolicies: EffectivePoliciesState = {} @@ -65,8 +83,15 @@ const _policiesStore = defineStore('policies', () => { } } - const saveSystemPolicy = async (policyKey: string, value: EffectivePolicyValue): Promise => { - const payload: SystemPolicyWritePayload = { value } + const saveSystemPolicy = async ( + policyKey: string, + value: EffectivePolicyValue, + allowChildOverride?: boolean, + ): Promise => { + const payload: SystemPolicyWritePayload & { allowChildOverride?: boolean } = { value } + if (typeof allowChildOverride === 'boolean') { + payload.allowChildOverride = allowChildOverride + } const response = await axios.post<{ ocs?: { data?: SystemPolicyWriteResponse } }>( generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`), payload, @@ -85,6 +110,52 @@ const _policiesStore = defineStore('policies', () => { return savedPolicy } + const fetchGroupPolicy = async (groupId: string, policyKey: string): Promise => { + const response = await axios.get<{ ocs?: { data?: GroupPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + + const saveGroupPolicy = async ( + groupId: string, + policyKey: string, + value: EffectivePolicyValue, + allowChildOverride: boolean, + ): Promise => { + const payload: GroupPolicyWritePayload = { value, allowChildOverride } + const response = await axios.put<{ ocs?: { data?: GroupPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + payload, + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + + const clearGroupPolicy = async (groupId: string, policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: GroupPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + const saveUserPreference = async (policyKey: string, value: EffectivePolicyValue): Promise => { const payload: SystemPolicyWritePayload = { value } const response = await axios.put<{ ocs?: { data?: SystemPolicyWriteResponse } }>( @@ -123,6 +194,34 @@ const _policiesStore = defineStore('policies', () => { return savedPolicy } + const saveUserPolicyForUser = async (userId: string, policyKey: string, value: EffectivePolicyValue): Promise => { + const payload: SystemPolicyWritePayload = { value } + const response = await axios.put<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + return savedPolicy + } + + const clearUserPolicyForUser = async (userId: string, policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`), + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + return savedPolicy + } + const getPolicy = (policyKey: string): EffectivePolicyState | null => { const policy = policies.value[policyKey] if (!policy) { @@ -144,9 +243,14 @@ const _policiesStore = defineStore('policies', () => { policies: computed(() => policies.value), setPolicies, fetchEffectivePolicies, + fetchGroupPolicy, saveSystemPolicy, + saveGroupPolicy, + clearGroupPolicy, saveUserPreference, clearUserPreference, + saveUserPolicyForUser, + clearUserPolicyForUser, getPolicy, getEffectiveValue, canUseRequestOverride, @@ -159,4 +263,5 @@ export const usePoliciesStore = function(...args: Parameters Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 099/417] test(tests): cover setup behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/setup.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/tests/setup.js b/src/tests/setup.js index 70b3c40f0c..7abdba9656 100644 --- a/src/tests/setup.js +++ b/src/tests/setup.js @@ -57,6 +57,13 @@ vi.mock('@nextcloud/vue/components/NcSelect', () => ({ }, })) +vi.mock('@nextcloud/vue/components/NcSelectUsers', () => ({ + default: { + name: 'NcSelectUsers', + template: '
', + }, +})) + vi.mock('@nextcloud/vue/components/NcRichText', () => ({ default: { From b91758c158de97d6837ab6a5935732e1bc7927dc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 100/417] test(store): cover policies.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/store/policies.spec.ts | 154 +++++++++++++++++++++++++++++++ 1 file changed, 154 insertions(+) diff --git a/src/tests/store/policies.spec.ts b/src/tests/store/policies.spec.ts index 84c229d172..d0beee0985 100644 --- a/src/tests/store/policies.spec.ts +++ b/src/tests/store/policies.spec.ts @@ -127,6 +127,38 @@ describe('policies store', () => { expect(store.getPolicy('signature_flow')?.sourceScope).toBe('system') }) + it('saves system allowChildOverride when provided', async () => { + vi.mocked(axios.post).mockResolvedValue({ + data: { + ocs: { + data: { + policy: { + policyKey: 'signature_flow', + effectiveValue: 'ordered_numeric', + allowedValues: [], + sourceScope: 'system', + visible: true, + editableByCurrentActor: true, + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + await store.saveSystemPolicy('signature_flow', 'ordered_numeric', true) + + expect(axios.post).toHaveBeenCalledWith( + '/ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', + { value: 'ordered_numeric', allowChildOverride: true }, + ) + }) + it('saves a user preference through the generic endpoint', async () => { vi.mocked(axios.put).mockResolvedValue({ data: { @@ -159,4 +191,126 @@ describe('policies store', () => { ) expect(policy?.sourceScope).toBe('user') }) + + it('loads a group policy through the generic endpoint', async () => { + vi.mocked(axios.get).mockResolvedValueOnce({ + data: { + ocs: { + data: { + policy: { + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: 'parallel', + allowChildOverride: true, + visibleToChild: true, + allowedValues: [], + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + const policy = await store.fetchGroupPolicy('finance', 'signature_flow') + + expect(axios.get).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/group/finance/signature_flow') + expect(policy?.targetId).toBe('finance') + expect(policy?.value).toBe('parallel') + }) + + it('saves a group policy through the generic endpoint', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ + data: { + ocs: { + data: { + policy: { + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: 'ordered_numeric', + allowChildOverride: false, + visibleToChild: true, + allowedValues: ['ordered_numeric'], + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + const policy = await store.saveGroupPolicy('finance', 'signature_flow', 'ordered_numeric', false) + + expect(axios.put).toHaveBeenCalledWith( + '/ocs/v2.php/apps/libresign/api/v1/policies/group/finance/signature_flow', + { value: 'ordered_numeric', allowChildOverride: false }, + ) + expect(policy?.value).toBe('ordered_numeric') + expect(policy?.allowChildOverride).toBe(false) + }) + + it('saves a user policy for a target user through the admin endpoint', async () => { + vi.mocked(axios.put).mockResolvedValueOnce({ + data: { + ocs: { + data: { + policy: { + policyKey: 'signature_flow', + effectiveValue: 'ordered_numeric', + allowedValues: ['none', 'parallel', 'ordered_numeric'], + sourceScope: 'user', + visible: true, + editableByCurrentActor: true, + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: false, + blockedBy: null, + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + const policy = await store.saveUserPolicyForUser('user1', 'signature_flow', 'ordered_numeric') + + expect(axios.put).toHaveBeenCalledWith( + '/ocs/v2.php/apps/libresign/api/v1/policies/user/user1/signature_flow', + { value: 'ordered_numeric' }, + ) + expect(policy?.sourceScope).toBe('user') + }) + + it('clears a user policy for a target user through the admin endpoint', async () => { + vi.mocked(axios.delete).mockResolvedValueOnce({ + data: { + ocs: { + data: { + policy: { + policyKey: 'signature_flow', + effectiveValue: 'parallel', + allowedValues: ['none', 'parallel', 'ordered_numeric'], + sourceScope: 'group', + visible: true, + editableByCurrentActor: true, + canSaveAsUserDefault: true, + canUseAsRequestOverride: true, + preferenceWasCleared: true, + blockedBy: null, + }, + }, + }, + }, + }) + + const { usePoliciesStore } = await import('../../store/policies') + const store = usePoliciesStore() + const policy = await store.clearUserPolicyForUser('user1', 'signature_flow') + + expect(axios.delete).toHaveBeenCalledWith('/ocs/v2.php/apps/libresign/api/v1/policies/user/user1/signature_flow') + expect(policy?.preferenceWasCleared).toBe(true) + }) }) From c572337876326d46bb7295c99c421716aa689570 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 101/417] test(Settings): cover Settings.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/tests/views/Settings/Settings.spec.ts | 56 +++++++++++++++++++++-- 1 file changed, 51 insertions(+), 5 deletions(-) diff --git a/src/tests/views/Settings/Settings.spec.ts b/src/tests/views/Settings/Settings.spec.ts index 54e19825f1..4833bfb768 100644 --- a/src/tests/views/Settings/Settings.spec.ts +++ b/src/tests/views/Settings/Settings.spec.ts @@ -3,8 +3,11 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' +import { createL10nMock } from '../../testHelpers/l10n.js' + +vi.mock('@nextcloud/l10n', () => createL10nMock()) import Settings from '../../../views/Settings/Settings.vue' @@ -16,6 +19,7 @@ describe('Settings.vue', () => { SupportProject: { template: '
' }, CertificateEngine: true, SignatureEngine: true, + SettingsPolicyWorkbench: true, DownloadBinaries: true, ConfigureCheck: true, RootCertificateCfssl: true, @@ -25,7 +29,6 @@ describe('Settings.vue', () => { Validation: true, CrlValidation: true, DocMDP: true, - SignatureFlow: true, SigningMode: true, AllowedGroups: true, LegalInformation: true, @@ -37,7 +40,6 @@ describe('Settings.vue', () => { Envelope: true, Reminders: true, TSA: true, - Confetti: true, }, }, }) @@ -54,6 +56,7 @@ describe('Settings.vue', () => { SupportProject: true, CertificateEngine: true, SignatureEngine: true, + SettingsPolicyWorkbench: true, DownloadBinaries: true, ConfigureCheck: true, RootCertificateCfssl: true, @@ -63,7 +66,6 @@ describe('Settings.vue', () => { Validation: true, CrlValidation: true, DocMDP: true, - SignatureFlow: true, SigningMode: { name: 'SigningMode', template: '
' }, AllowedGroups: true, LegalInformation: true, @@ -75,11 +77,55 @@ describe('Settings.vue', () => { Envelope: true, Reminders: true, TSA: true, - Confetti: true, }, }, }) expect(wrapper.find('.signing-mode-stub').exists()).toBe(false) }) + + it('toggles frozen preview visibility', async () => { + const wrapper = mount(Settings, { + global: { + stubs: { + SupportProject: true, + CertificateEngine: true, + SignatureEngine: true, + SettingsPolicyWorkbench: true, + FrozenSettingsPolicyWorkbench: { template: '
' }, + DownloadBinaries: true, + ConfigureCheck: true, + RootCertificateCfssl: true, + RootCertificateOpenSsl: true, + IdentificationFactors: true, + ExpirationRules: true, + Validation: true, + CrlValidation: true, + DocMDP: true, + SigningMode: true, + AllowedGroups: true, + LegalInformation: true, + IdentificationDocuments: true, + CollectMetadata: true, + SignatureStamp: true, + SignatureHashAlgorithm: true, + DefaultUserFolder: true, + Envelope: true, + Reminders: true, + TSA: true, + }, + }, + }) + + expect(wrapper.find('.frozen-stub').exists()).toBe(false) + + const toggle = wrapper.find('[data-testid="toggle-frozen-preview"]') + expect(toggle.exists()).toBe(true) + + await toggle.trigger('click') + expect(wrapper.find('.frozen-stub').exists()).toBe(true) + + await toggle.trigger('click') + expect(wrapper.find('.frozen-stub').exists()).toBe(false) + }) }) From 5f73d9348ad96bca3266fa6401a15da536b398f6 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 102/417] test(Settings): cover SettingsPolicyWorkbench.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../views/Settings/SettingsPolicyWorkbench.spec.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts index 926815d2c5..93ddf1c80a 100644 --- a/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts +++ b/src/tests/views/Settings/SettingsPolicyWorkbench.spec.ts @@ -13,7 +13,7 @@ vi.mock('../../../store/policies', () => ({ usePoliciesStore: () => ({ getPolicy: (key: string) => { if (key === 'signature_flow') { - return { effectiveValue: { flow: 'ordered_numeric' } } + return { effectiveValue: 'ordered_numeric' } } return null }, @@ -38,7 +38,7 @@ describe('RealPolicyWorkbench.vue', () => { NcNoteCard: { template: '
' }, NcDialog: { template: '
' }, NcCheckboxRadioSwitch: { template: '' }, - NcSelect: { template: '' }, + NcSelectUsers: { template: '
' }, PolicyRuleCard: { template: '
' }, SignatureFlow: { template: '
' }, }, @@ -60,15 +60,15 @@ describe('RealPolicyWorkbench.vue', () => { // Validate signing order is displayed expect(text).toContain('Signing order') expect(text).toContain('Define whether signers work in parallel or in a sequential order') - expect(text).toContain('Control the overall signature flow model for documents') + expect(text).toContain('Define the default signing flow and where overrides are allowed') // Validate default value is shown expect(text).toContain('Sequential') - expect(text).toContain('Default:') + expect(text).toContain('Global default:') // Validate counts shown - expect(text).toContain('Group rules: 1') - expect(text).toContain('User rules: 0') + expect(text).toContain('Group overrides: 0') + expect(text).toContain('User overrides: 0') // Validate POC settings are NOT present expect(text).not.toContain('Confetti') From f3582b508e165a84acae8eec2e5545227a5d501c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 103/417] feat(types): implement index behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/index.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/types/index.ts b/src/types/index.ts index a9cf601c4e..0d889ac26b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -45,12 +45,20 @@ type ApiRequestJsonBody = ApiJsonBody> = ApiOcsJsonData[TStatusCode]> +type ApiRecordValue = TRecord extends Record + ? TValue + : never + export type SignatureFlowMode = ApiComponents['schemas']['DetailedFileResponse']['signatureFlow'] export type SignatureFlowValue = SignatureFlowMode | 0 | 1 | 2 -export type EffectivePolicyValue = ApiComponents['schemas']['EffectivePolicyValue'] -export type EffectivePolicyState = ApiComponents['schemas']['EffectivePolicyState'] export type EffectivePoliciesResponse = ApiOcsResponseData export type EffectivePoliciesState = EffectivePoliciesResponse['policies'] +export type EffectivePolicyState = ApiRecordValue +export type EffectivePolicyValue = Exclude['value'], undefined> +export type GroupPolicyResponse = ApiOcsResponseData +export type GroupPolicyState = GroupPolicyResponse['policy'] +export type GroupPolicyWritePayload = ApiRequestJsonBody +export type GroupPolicyWriteResponse = ApiOcsResponseData export type SystemPolicyWritePayload = ApiRequestJsonBody export type SystemPolicyWriteResponse = ApiOcsResponseData export type SystemPolicyWriteErrorResponse = ApiOcsResponseData From de71af97c5f7c4d8bbb98ae0488ae0aa2f789c9e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 104/417] feat(openapi): implement openapi-administration behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-administration.ts | 198 ++++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/src/types/openapi/openapi-administration.ts b/src/types/openapi/openapi-administration.ts index b7117c4d62..c7c48b675f 100644 --- a/src/types/openapi/openapi-administration.ts +++ b/src/types/openapi/openapi-administration.ts @@ -465,6 +465,34 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read a group-level policy value + * @description This endpoint requires admin access + */ + get: operations["policy-get-group"]; + /** + * Save a group-level policy value + * @description This endpoint requires admin access + */ + put: operations["policy-set-group"]; + post?: never; + /** + * Clear a group-level policy value + * @description This endpoint requires admin access + */ + delete: operations["policy-clear-group"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { parameters: { query?: never; @@ -649,6 +677,20 @@ export type components = { /** Format: int64 */ preview_height: number; }; + GroupPolicyResponse: { + policy: components["schemas"]["GroupPolicyState"]; + }; + GroupPolicyState: { + policyKey: string; + /** @enum {string} */ + scope: "group"; + targetId: string; + value: components["schemas"]["EffectivePolicyValue"]; + allowChildOverride: boolean; + visibleToChild: boolean; + allowedValues: components["schemas"]["EffectivePolicyValue"][]; + }; + GroupPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["GroupPolicyResponse"]; HasRootCertResponse: { hasRootCert: boolean; }; @@ -2124,6 +2166,162 @@ export interface operations { }; }; }; + "policy-get-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to read for the selected group. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyResponse"]; + }; + }; + }; + }; + }; + }; + "policy-set-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to persist at the group layer. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist for the group. */ + value?: (boolean | number | string) | null; + /** + * @description Whether users and requests below this group may override the group default. + * @default false + */ + allowChildOverride?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; + "policy-clear-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to clear for the selected group. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "policy-set-system": { parameters: { query?: never; From f13a44f65071be02b3f2ebd02a9f734fb9bfc56b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 105/417] feat(openapi): implement openapi-full behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/types/openapi/openapi-full.ts | 202 ++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) diff --git a/src/types/openapi/openapi-full.ts b/src/types/openapi/openapi-full.ts index 2ca6a252ec..75e87ad7e8 100644 --- a/src/types/openapi/openapi-full.ts +++ b/src/types/openapi/openapi-full.ts @@ -1535,6 +1535,34 @@ export type paths = { patch?: never; trace?: never; }; + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read a group-level policy value + * @description This endpoint requires admin access + */ + get: operations["policy-get-group"]; + /** + * Save a group-level policy value + * @description This endpoint requires admin access + */ + put: operations["policy-set-group"]; + post?: never; + /** + * Clear a group-level policy value + * @description This endpoint requires admin access + */ + delete: operations["policy-clear-group"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { parameters: { query?: never; @@ -1998,6 +2026,24 @@ export type components = { /** Format: int64 */ preview_height: number; }; + GroupPolicyResponse: { + policy: components["schemas"]["GroupPolicyState"]; + }; + GroupPolicyState: { + policyKey: string; + /** @enum {string} */ + scope: "group"; + targetId: string; + value: components["schemas"]["EffectivePolicyValue"]; + allowChildOverride: boolean; + visibleToChild: boolean; + allowedValues: components["schemas"]["EffectivePolicyValue"][]; + }; + GroupPolicyWriteRequest: { + value: components["schemas"]["EffectivePolicyValue"]; + allowChildOverride: boolean; + }; + GroupPolicyWriteResponse: components["schemas"]["MessageResponse"] & components["schemas"]["GroupPolicyResponse"]; HasRootCertResponse: { hasRootCert: boolean; }; @@ -7065,6 +7111,162 @@ export interface operations { }; }; }; + "policy-get-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to read for the selected group. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyResponse"]; + }; + }; + }; + }; + }; + }; + "policy-set-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to persist at the group layer. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": { + /** @description Policy value to persist for the group. */ + value?: (boolean | number | string) | null; + /** + * @description Whether users and requests below this group may override the group default. + * @default false + */ + allowChildOverride?: boolean; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Invalid policy value */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; + "policy-clear-group": { + parameters: { + query?: never; + header: { + /** @description Required to be true for the API request to pass */ + "OCS-APIRequest": boolean; + }; + path: { + apiVersion: "v1"; + /** @description Group identifier that receives the policy binding. */ + groupId: string; + /** @description Policy identifier to clear for the selected group. */ + policyKey: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["GroupPolicyWriteResponse"]; + }; + }; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + ocs: { + meta: components["schemas"]["OCSMeta"]; + data: components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + }; + }; "policy-set-system": { parameters: { query?: never; From 50a0047eeddcc9cb0e17d0c36017aacc04b2697a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:40 -0300 Subject: [PATCH 106/417] feat(Settings): implement Settings behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- src/views/Settings/Settings.vue | 62 +++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/views/Settings/Settings.vue b/src/views/Settings/Settings.vue index 8b498b2556..b399636e85 100644 --- a/src/views/Settings/Settings.vue +++ b/src/views/Settings/Settings.vue @@ -18,6 +18,22 @@ +
+
+
+

{{ t('libresign', 'Frozen workbench reference') }}

+

{{ t('libresign', 'Comparison reference rendered from the frozen implementation.') }}

+
+ +
+ +
@@ -33,6 +49,9 @@ + + From 17c1e2a7d261ea73235fcaa604ffc441445415d0 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 107/417] test(Controller): cover PolicyControllerTest behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Controller/PolicyControllerTest.php | 120 +++++++++++++++++- 1 file changed, 118 insertions(+), 2 deletions(-) diff --git a/tests/php/Unit/Controller/PolicyControllerTest.php b/tests/php/Unit/Controller/PolicyControllerTest.php index 110bfb03f0..9c0b75e61c 100644 --- a/tests/php/Unit/Controller/PolicyControllerTest.php +++ b/tests/php/Unit/Controller/PolicyControllerTest.php @@ -9,6 +9,7 @@ namespace OCA\Libresign\Tests\Unit\Controller; use OCA\Libresign\Controller\PolicyController; +use OCA\Libresign\Service\Policy\Model\PolicyLayer; use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; use OCA\Libresign\Service\Policy\PolicyService; use OCP\AppFramework\Http; @@ -98,7 +99,7 @@ public function testSetSystemReturnsSavedResolvedPolicy(): void { $this->policyService ->expects($this->once()) ->method('saveSystem') - ->with('signature_flow', 'ordered_numeric') + ->with('signature_flow', 'ordered_numeric', false) ->willReturn($resolvedPolicy); $response = $this->controller->setSystem('signature_flow', 'ordered_numeric'); @@ -121,6 +122,36 @@ public function testSetSystemReturnsSavedResolvedPolicy(): void { ], $response->getData()); } + public function testSetSystemForwardsAllowChildOverrideWhenProvided(): void { + $resolvedPolicy = (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('ordered_numeric') + ->setSourceScope('system') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues([]) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(true) + ->setPreferenceWasCleared(false) + ->setBlockedBy(null); + + $this->l10n + ->expects($this->once()) + ->method('t') + ->with('Settings saved') + ->willReturn('Settings saved'); + + $this->policyService + ->expects($this->once()) + ->method('saveSystem') + ->with('signature_flow', 'ordered_numeric', true) + ->willReturn($resolvedPolicy); + + $response = $this->controller->setSystem('signature_flow', 'ordered_numeric', true); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + public function testSetSystemReturnsBadRequestWhenPolicyValueIsInvalid(): void { $this->l10n ->expects($this->once()) @@ -131,7 +162,7 @@ public function testSetSystemReturnsBadRequestWhenPolicyValueIsInvalid(): void { $this->policyService ->expects($this->once()) ->method('saveSystem') - ->with('signature_flow', 'banana') + ->with('signature_flow', 'banana', false) ->willThrowException(new \InvalidArgumentException('Invalid value for signature_flow')); $response = $this->controller->setSystem('signature_flow', 'banana'); @@ -172,4 +203,89 @@ public function testSetUserPreferenceReturnsSavedResolvedPolicy(): void { $this->assertSame(Http::STATUS_OK, $response->getStatus()); $this->assertSame('user', $response->getData()['policy']['sourceScope']); } + + public function testGetGroupReturnsStoredGroupPolicy(): void { + $this->policyService + ->expects($this->once()) + ->method('getGroupPolicy') + ->with('signature_flow', 'finance') + ->willReturn((new PolicyLayer()) + ->setScope('group') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues([])); + + $response = $this->controller->getGroup('finance', 'signature_flow'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame([ + 'policy' => [ + 'policyKey' => 'signature_flow', + 'scope' => 'group', + 'targetId' => 'finance', + 'value' => 'parallel', + 'allowChildOverride' => true, + 'visibleToChild' => true, + 'allowedValues' => [], + ], + ], $response->getData()); + } + + public function testSetGroupReturnsSavedGroupPolicy(): void { + $this->l10n + ->expects($this->once()) + ->method('t') + ->with('Settings saved') + ->willReturn('Settings saved'); + + $this->policyService + ->expects($this->once()) + ->method('saveGroupPolicy') + ->with('signature_flow', 'finance', 'ordered_numeric', false) + ->willReturn((new PolicyLayer()) + ->setScope('group') + ->setValue('ordered_numeric') + ->setAllowChildOverride(false) + ->setVisibleToChild(true) + ->setAllowedValues(['ordered_numeric'])); + + $response = $this->controller->setGroup('finance', 'signature_flow', 'ordered_numeric', false); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('group', $response->getData()['policy']['scope']); + $this->assertSame('finance', $response->getData()['policy']['targetId']); + } + + public function testSetUserPolicyForTargetUserReturnsSavedResolvedPolicy(): void { + $resolvedPolicy = (new ResolvedPolicy()) + ->setPolicyKey('signature_flow') + ->setEffectiveValue('ordered_numeric') + ->setSourceScope('user') + ->setVisible(true) + ->setEditableByCurrentActor(true) + ->setAllowedValues(['none', 'parallel', 'ordered_numeric']) + ->setCanSaveAsUserDefault(true) + ->setCanUseAsRequestOverride(true) + ->setPreferenceWasCleared(false) + ->setBlockedBy(null); + + $this->l10n + ->expects($this->once()) + ->method('t') + ->with('Settings saved') + ->willReturn('Settings saved'); + + $this->policyService + ->expects($this->once()) + ->method('saveUserPreferenceForUserId') + ->with('signature_flow', 'user1', 'ordered_numeric') + ->willReturn($resolvedPolicy); + + $response = $this->controller->setUserPolicyForUser('user1', 'signature_flow', 'ordered_numeric'); + + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + $this->assertSame('user', $response->getData()['policy']['sourceScope']); + $this->assertSame('ordered_numeric', $response->getData()['policy']['effectiveValue']); + } } From 69a499ccc99ad304700110e071f642a8c2b59738 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 108/417] test(Policy): cover PolicyServiceTest behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Unit/Service/Policy/PolicyServiceTest.php | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/tests/php/Unit/Service/Policy/PolicyServiceTest.php b/tests/php/Unit/Service/Policy/PolicyServiceTest.php index db2d14193a..6be2581ce1 100644 --- a/tests/php/Unit/Service/Policy/PolicyServiceTest.php +++ b/tests/php/Unit/Service/Policy/PolicyServiceTest.php @@ -188,4 +188,92 @@ public function testResolveUsesCurrentUserFromSession(): void { $this->assertSame('parallel', $resolved->getEffectiveValue()); $this->assertSame('group', $resolved->getSourceScope()); } + + public function testSaveUserPreferenceForUserIdPersistsForTargetUser(): void { + $targetUser = $this->createMock(IUser::class); + $targetUser->method('getUID')->willReturn('user1'); + + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('user1') + ->willReturn($targetUser); + + $this->groupManager + ->expects($this->once()) + ->method('getUserGroupIds') + ->with($targetUser) + ->willReturn(['finance']); + + $this->source + ->expects($this->once()) + ->method('saveUserPreference') + ->with( + SignatureFlowPolicy::KEY, + $this->callback(static function ($context): bool { + return $context->getUserId() === 'user1'; + }), + 'ordered_numeric', + ); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new PolicyLayer()) + ->setScope('system') + ->setValue('parallel') + ->setAllowChildOverride(true) + ->setVisibleToChild(true)); + + $this->source->method('loadGroupPolicies')->willReturn([]); + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source + ->method('loadUserPreference') + ->willReturn((new PolicyLayer()) + ->setScope('user') + ->setValue('ordered_numeric')); + $this->source->method('loadRequestOverride')->willReturn(null); + + $service = new PolicyService( + $this->contextFactory, + $this->source, + $this->registry, + ); + + $resolved = $service->saveUserPreferenceForUserId(SignatureFlowPolicy::KEY, 'user1', 'ordered_numeric'); + + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('user', $resolved->getSourceScope()); + } + + public function testSaveSystemPersistsAllowChildOverrideWhenEnabled(): void { + $this->source + ->expects($this->once()) + ->method('saveSystemPolicy') + ->with(SignatureFlowPolicy::KEY, 'ordered_numeric', true); + + $this->source + ->method('loadSystemPolicy') + ->willReturn((new PolicyLayer()) + ->setScope('system') + ->setValue('ordered_numeric') + ->setAllowChildOverride(true) + ->setVisibleToChild(true) + ->setAllowedValues([])); + + $this->source->method('loadGroupPolicies')->willReturn([]); + $this->source->method('loadCirclePolicies')->willReturn([]); + $this->source->method('loadUserPreference')->willReturn(null); + $this->source->method('loadRequestOverride')->willReturn(null); + + $service = new PolicyService( + $this->contextFactory, + $this->source, + $this->registry, + ); + + $resolved = $service->saveSystem(SignatureFlowPolicy::KEY, 'ordered_numeric', true); + + $this->assertSame('ordered_numeric', $resolved->getEffectiveValue()); + $this->assertSame('system', $resolved->getSourceScope()); + } } From 83268f3dd3d7d2335949f17c480957fa9000dc30 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 109/417] test(Runtime): cover DefaultPolicyResolverTest behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/Runtime/DefaultPolicyResolverTest.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php b/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php index 5cad6b090e..63886021f3 100644 --- a/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php +++ b/tests/php/Unit/Service/Policy/Runtime/DefaultPolicyResolverTest.php @@ -137,6 +137,10 @@ public function loadGroupPolicies(string $policyKey, PolicyContext $context): ar return $this->groupLayers; } + public function loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer { + return $this->groupLayers[0] ?? null; + } + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array { return []; } @@ -149,12 +153,18 @@ public function loadRequestOverride(string $policyKey, PolicyContext $context): return $this->requestOverride; } - public function saveSystemPolicy(string $policyKey, mixed $value): void { + public function saveSystemPolicy(string $policyKey, mixed $value, bool $allowChildOverride = false): void { + } + + public function saveGroupPolicy(string $policyKey, string $groupId, mixed $value, bool $allowChildOverride): void { } public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void { } + public function clearGroupPolicy(string $policyKey, string $groupId): void { + } + public function clearUserPreference(string $policyKey, PolicyContext $context): void { $this->userPreferenceCleared = true; } From dc8ebabb9acdcd5bff4599528019adf6693cf761 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 110/417] test(Runtime): cover PolicySourceTest behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Policy/Runtime/PolicySourceTest.php | 199 +++++++++++++++++- 1 file changed, 189 insertions(+), 10 deletions(-) diff --git a/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php b/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php index 9f5fb30171..3b2cd5388b 100644 --- a/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php +++ b/tests/php/Unit/Service/Policy/Runtime/PolicySourceTest.php @@ -16,6 +16,7 @@ use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\Policy\Runtime\PolicyRegistry; use OCA\Libresign\Service\Policy\Runtime\PolicySource; +use OCP\AppFramework\Db\DoesNotExistException; use OCP\AppFramework\Services\IAppConfig; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; @@ -41,11 +42,22 @@ protected function setUp(): void { } public function testLoadSystemPolicyReturnsForcedLayerWhenAppConfigIsSet(): void { + $calls = 0; $this->appConfig - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('getAppValueString') - ->with('signature_flow', 'none') - ->willReturn('ordered_numeric'); + ->willReturnCallback(static function (string $key, string $default) use (&$calls): string { + $calls += 1; + if ($key === 'signature_flow' && $default === '') { + return 'ordered_numeric'; + } + + if ($key === 'signature_flow.allow_child_override' && $default === '0') { + return '0'; + } + + throw new \RuntimeException('Unexpected app config key request: ' . $key); + }); $source = $this->getSource(); $layer = $source->loadSystemPolicy('signature_flow'); @@ -55,14 +67,15 @@ public function testLoadSystemPolicyReturnsForcedLayerWhenAppConfigIsSet(): void $this->assertSame('ordered_numeric', $layer->getValue()); $this->assertFalse($layer->isAllowChildOverride()); $this->assertSame(['ordered_numeric'], $layer->getAllowedValues()); + $this->assertSame(2, $calls); } public function testLoadSystemPolicyReturnsInheritableLayerWhenAppConfigMatchesDefault(): void { $this->appConfig ->expects($this->once()) ->method('getAppValueString') - ->with('signature_flow', 'none') - ->willReturn('none'); + ->with('signature_flow', '') + ->willReturn(''); $source = $this->getSource(); $layer = $source->loadSystemPolicy('signature_flow'); @@ -152,23 +165,189 @@ public function testClearUserPreferenceDeletesUserConfig(): void { } public function testSaveSystemPolicyDeletesAppConfigWhenValueMatchesDefault(): void { + $deletedKeys = []; $this->appConfig - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('deleteAppValue') - ->with('signature_flow'); + ->willReturnCallback(static function (string $key) use (&$deletedKeys): bool { + $deletedKeys[] = $key; + return true; + }); $source = $this->getSource(); $source->saveSystemPolicy('signature_flow', 'none'); + + $this->assertSame(['signature_flow', 'signature_flow.allow_child_override'], $deletedKeys); } public function testSaveSystemPolicyNormalizesAndPersistsAppConfigValue(): void { + $savedValues = []; $this->appConfig - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('setAppValueString') - ->with('signature_flow', 'ordered_numeric'); + ->willReturnCallback(static function (string $key, string $value) use (&$savedValues): bool { + $savedValues[$key] = $value; + return true; + }); + + $source = $this->getSource(); + $source->saveSystemPolicy('signature_flow', 2, true); + + $this->assertSame([ + 'signature_flow' => 'ordered_numeric', + 'signature_flow.allow_child_override' => '1', + ], $savedValues); + } + + public function testLoadSystemPolicyRespectsPersistedAllowChildOverride(): void { + $calls = 0; + $this->appConfig + ->expects($this->exactly(2)) + ->method('getAppValueString') + ->willReturnCallback(static function (string $key, string $default) use (&$calls): string { + $calls += 1; + if ($key === 'signature_flow' && $default === '') { + return 'ordered_numeric'; + } + + if ($key === 'signature_flow.allow_child_override' && $default === '0') { + return '1'; + } + + throw new \RuntimeException('Unexpected app config key request: ' . $key); + }); + + $source = $this->getSource(); + $layer = $source->loadSystemPolicy('signature_flow'); + + $this->assertNotNull($layer); + $this->assertSame('ordered_numeric', $layer->getValue()); + $this->assertTrue($layer->isAllowChildOverride()); + $this->assertSame([], $layer->getAllowedValues()); + $this->assertSame(2, $calls); + } + + public function testLoadGroupPolicyConfigReturnsBoundPolicyLayer(): void { + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId(77); + $binding->setTargetType('group'); + $binding->setTargetId('finance'); + + $permissionSet = new PermissionSet(); + $permissionSet->setId(77); + $permissionSet->setPolicyJson([ + 'signature_flow' => [ + 'defaultValue' => 'parallel', + 'allowChildOverride' => true, + 'visibleToChild' => true, + 'allowedValues' => [], + ], + ]); + + $this->bindingMapper + ->expects($this->once()) + ->method('getByTarget') + ->with('group', 'finance') + ->willReturn($binding); + + $this->permissionSetMapper + ->expects($this->once()) + ->method('getById') + ->with(77) + ->willReturn($permissionSet); + + $source = $this->getSource(); + $layer = $source->loadGroupPolicyConfig('signature_flow', 'finance'); + + $this->assertNotNull($layer); + $this->assertSame('group', $layer->getScope()); + $this->assertSame('parallel', $layer->getValue()); + $this->assertTrue($layer->isAllowChildOverride()); + } + + public function testSaveGroupPolicyCreatesPermissionSetAndBinding(): void { + $this->bindingMapper + ->expects($this->once()) + ->method('getByTarget') + ->with('group', 'finance') + ->willThrowException(new DoesNotExistException('missing')); + + $this->permissionSetMapper + ->expects($this->once()) + ->method('insert') + ->with($this->callback(function (PermissionSet $permissionSet): bool { + $this->assertSame('group', $permissionSet->getScopeType()); + $this->assertSame('group:finance', $permissionSet->getName()); + $this->assertSame([ + 'signature_flow' => [ + 'defaultValue' => 'ordered_numeric', + 'allowChildOverride' => false, + 'visibleToChild' => true, + 'allowedValues' => ['ordered_numeric'], + ], + ], $permissionSet->getPolicyJson()); + return true; + })) + ->willReturnCallback(static function (PermissionSet $permissionSet): PermissionSet { + $permissionSet->setId(77); + return $permissionSet; + }); + + $this->bindingMapper + ->expects($this->once()) + ->method('insert') + ->with($this->callback(function (PermissionSetBinding $binding): bool { + $this->assertSame(77, $binding->getPermissionSetId()); + $this->assertSame('group', $binding->getTargetType()); + $this->assertSame('finance', $binding->getTargetId()); + return true; + })); + + $source = $this->getSource(); + $source->saveGroupPolicy('signature_flow', 'finance', 2, false); + } + + public function testClearGroupPolicyDeletesBindingAndPermissionSetWhenItIsTheLastPolicy(): void { + $binding = new PermissionSetBinding(); + $binding->setPermissionSetId(77); + $binding->setTargetType('group'); + $binding->setTargetId('finance'); + + $permissionSet = new PermissionSet(); + $permissionSet->setId(77); + $permissionSet->setPolicyJson([ + 'signature_flow' => [ + 'defaultValue' => 'parallel', + 'allowChildOverride' => true, + 'visibleToChild' => true, + 'allowedValues' => [], + ], + ]); + + $this->bindingMapper + ->expects($this->once()) + ->method('getByTarget') + ->with('group', 'finance') + ->willReturn($binding); + + $this->permissionSetMapper + ->expects($this->once()) + ->method('getById') + ->with(77) + ->willReturn($permissionSet); + + $this->bindingMapper + ->expects($this->once()) + ->method('delete') + ->with($binding); + + $this->permissionSetMapper + ->expects($this->once()) + ->method('delete') + ->with($permissionSet); $source = $this->getSource(); - $source->saveSystemPolicy('signature_flow', 2); + $source->clearGroupPolicy('signature_flow', 'finance'); } public function testLoadRequestOverrideReturnsLayerFromContext(): void { From 6510cd7d09f39c8b545bba471efdce7f3ac582e7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 111/417] test(e2e): cover policy-workbench-system-default-persistence.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- ...rkbench-system-default-persistence.spec.ts | 328 ++++++++++++++++++ 1 file changed, 328 insertions(+) create mode 100644 playwright/e2e/policy-workbench-system-default-persistence.spec.ts diff --git a/playwright/e2e/policy-workbench-system-default-persistence.spec.ts b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts new file mode 100644 index 0000000000..fbe481bf44 --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,328 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Locator, Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { ensureUserExists } from '../support/nc-provisioning' + +test.describe.configure({ retries: 0, timeout: 45000 }) + +async function openSigningOrderDialog(page: Page) { + const manageButtons = page.getByRole('button', { name: 'Manage this setting' }) + await expect(manageButtons.first()).toBeVisible({ timeout: 20000 }) + await manageButtons.first().click() + await expect(page.getByLabel('Signing order')).toBeVisible() +} + +async function getSigningOrderDialog(page: Page): Promise { + const dialog = page.getByLabel('Signing order') + await expect(dialog).toBeVisible() + return dialog +} + +async function waitForEditorIdle(dialog: Locator) { + const savingOverlays = dialog.locator('[aria-busy="true"]') + await savingOverlays.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) +} + +async function setAllowOverride(dialog: Locator, enabled: boolean): Promise { + const allowOverrideSwitch = dialog.getByLabel('Allow lower layers to override this rule').first() + const label = dialog.getByText('Allow lower layers to override this rule').first() + + if (!(await allowOverrideSwitch.count())) { + return false + } + + if (enabled) { + if (!(await allowOverrideSwitch.isChecked())) { + await label.click() + } + await expect(allowOverrideSwitch).toBeChecked() + return true + } + + if (await allowOverrideSwitch.isChecked()) { + await label.click() + } + await expect(allowOverrideSwitch).not.toBeChecked() + return true +} + +async function setDefaultSigningOrder(dialog: Locator, enabled: boolean): Promise { + const defaultSigningOrderSwitch = dialog.getByLabel('Set default signing order').first() + const label = dialog.getByText('Set default signing order').first() + + if (!(await defaultSigningOrderSwitch.count())) { + return false + } + + if (enabled) { + if (!(await defaultSigningOrderSwitch.isChecked())) { + await label.click() + } + await expect(defaultSigningOrderSwitch).toBeChecked() + return true + } + + if (await defaultSigningOrderSwitch.isChecked()) { + await label.click() + } + await expect(defaultSigningOrderSwitch).not.toBeChecked() + return true +} + +async function submitRule(dialog: Locator) { + await waitForEditorIdle(dialog) + + const createButton = dialog.getByRole('button', { name: 'Create rule' }).first() + if (await createButton.isVisible().catch(() => false)) { + await expect(createButton).toBeEnabled({ timeout: 8000 }) + await createButton.click() + await waitForEditorIdle(dialog) + return + } + + const saveButton = dialog.getByRole('button', { name: 'Save rule changes' }).first() + await expect(saveButton).toBeVisible({ timeout: 8000 }) + await expect(saveButton).toBeEnabled({ timeout: 8000 }) + await saveButton.click() + await waitForEditorIdle(dialog) +} + +async function openGlobalRuleEditor(dialog: Locator, globalSection: Locator) { + const createDefaultButton = globalSection.getByRole('button', { name: 'Create default rule' }).first() + if (await createDefaultButton.isVisible().catch(() => false)) { + await createDefaultButton.click() + return + } + + await globalSection.getByRole('button', { name: 'Edit default' }).first().click() +} + +async function expectGlobalBaselineState(globalSection: Locator) { + const createDefaultButton = globalSection.getByRole('button', { name: 'Create default rule' }).first() + if (await createDefaultButton.isVisible().catch(() => false)) { + await expect(createDefaultButton).toBeVisible() + return + } + + await expect(globalSection.getByRole('button', { name: 'Edit default' }).first()).toBeVisible() +} + +async function chooseTarget(dialog: Locator, ariaLabel: 'Target groups' | 'Target users', optionText: string) { + await waitForEditorIdle(dialog) + + const combobox = dialog.getByRole('combobox', { name: ariaLabel }).first() + const labeledInput = dialog.getByLabel(ariaLabel).first() + const targetInput = await combobox.count() ? combobox : labeledInput + + await expect(targetInput).toBeVisible({ timeout: 8000 }) + await targetInput.click() + + const searchInput = targetInput.locator('input').first() + if (await searchInput.count()) { + await searchInput.fill(optionText) + const matchingOption = dialog.getByRole('option', { name: new RegExp(optionText, 'i') }).first() + const matchingVisible = await matchingOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (matchingVisible) { + await matchingOption.click() + return + } + + const anyOption = dialog.getByRole('option').first() + const anyVisible = await anyOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (anyVisible) { + await anyOption.click() + return + } + + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + } else { + const fallbackTextbox = dialog.getByRole('textbox').first() + await fallbackTextbox.fill(optionText) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + } +} + +async function clickSectionAction(section: Locator, actionLabel: string) { + await section.getByRole('button', { name: actionLabel }).first().click() +} + +async function removeRuleWithConfirmation(page: Page, dialog: Locator, section: Locator, actionLabel: string) { + const confirmButton = page.getByRole('button', { name: 'Remove rule' }).first() + if (await confirmButton.isVisible().catch(() => false)) { + await confirmButton.click() + await waitForEditorIdle(dialog) + } + + await clickSectionAction(section, actionLabel) + + const confirmationShown = await confirmButton.waitFor({ state: 'visible', timeout: 4000 }).then(() => true).catch(() => false) + if (confirmationShown) { + await confirmButton.click() + await waitForEditorIdle(dialog) + } +} + +async function removeAllRulesByAction( + page: Page, + dialog: Locator, + section: Locator, + actionLabel: 'Delete user rule' | 'Delete group rule', +) { + for (let attempt = 0; attempt < 8; attempt++) { + const currentCount = await section.getByRole('button', { name: actionLabel }).count() + if (!currentCount) { + return + } + + await removeRuleWithConfirmation(page, dialog, section, actionLabel) + await waitForEditorIdle(dialog) + } + + throw new Error(`Failed to remove all rules for action "${actionLabel}" after multiple attempts`) +} + +async function ensureBaselineRulesForAdminTarget(page: Page, dialog: Locator) { + const globalSection = dialog.getByRole('region', { name: 'Global default rules' }) + const groupSection = dialog.getByRole('region', { name: 'Group rules' }) + const userSection = dialog.getByRole('region', { name: 'User rules' }) + + await removeAllRulesByAction(page, dialog, userSection, 'Delete user rule') + await removeAllRulesByAction(page, dialog, groupSection, 'Delete group rule') + + // Normalize global default into a known baseline where lower layers may override. + if (await globalSection.getByRole('button', { name: 'Edit default' }).count()) { + await globalSection.getByRole('button', { name: 'Edit default' }).click() + if (await setAllowOverride(dialog, true)) { + await submitRule(dialog) + } + } +} + +test('system default persists allow-override changes across edit cycles', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.goto('./settings/admin/libresign') + await expect(page.getByRole('button', { name: 'Manage this setting' }).first()).toBeVisible() + + await openSigningOrderDialog(page) + + const signingOrderDialog = await getSigningOrderDialog(page) + await ensureBaselineRulesForAdminTarget(page, signingOrderDialog) + + const globalSection = signingOrderDialog.getByRole('region', { name: 'Global default rules' }) + await openGlobalRuleEditor(signingOrderDialog, globalSection) + await expect(signingOrderDialog.getByRole('heading', { name: 'Default rule' })).toBeVisible() + await setAllowOverride(signingOrderDialog, true) + await submitRule(signingOrderDialog) + + await signingOrderDialog.getByRole('button', { name: 'Edit default' }).click() + await setAllowOverride(signingOrderDialog, true) + + await setAllowOverride(signingOrderDialog, false) + const saveChangesResponsePromise = page.waitForResponse((response) => { + return response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/policies/system/signature_flow') + }) + await signingOrderDialog.getByRole('button', { name: 'Save rule changes' }).click() + const saveChangesResponse = await saveChangesResponsePromise + expect(saveChangesResponse.status(), 'Expected Save changes request to succeed').toBe(200) + + await signingOrderDialog.getByRole('button', { name: 'Edit default' }).click() + await setAllowOverride(signingOrderDialog, false) + + await expect(signingOrderDialog.getByText('Lower layers must inherit this rule')).toBeVisible() + + // Reset should restore inherited baseline behavior. + await removeRuleWithConfirmation(page, signingOrderDialog, globalSection, 'Reset default') + await expectGlobalBaselineState(globalSection) +}) + +test('admin can create, edit, and delete global, group, and user rules from the policy workbench', async ({ page }) => { + const userTarget = 'policy-e2e-user' + + await ensureUserExists(page.request, userTarget) + + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.goto('./settings/admin/libresign') + await openSigningOrderDialog(page) + + const dialog = await getSigningOrderDialog(page) + const globalSection = dialog.getByRole('region', { name: 'Global default rules' }) + const groupSection = dialog.getByRole('region', { name: 'Group rules' }) + const userSection = dialog.getByRole('region', { name: 'User rules' }) + + await ensureBaselineRulesForAdminTarget(page, dialog) + + // Global rule: edit + await openGlobalRuleEditor(dialog, globalSection) + await setDefaultSigningOrder(dialog, true) + await setAllowOverride(dialog, true) + await submitRule(dialog) + await expect(globalSection.getByRole('button', { name: 'Edit default' })).toBeVisible() + + // Global rule: enforce inheritance + await globalSection.getByRole('button', { name: 'Edit default' }).click() + expect(await setAllowOverride(dialog, false), 'Expected global allow-override switch in editor').toBe(true) + await submitRule(dialog) + await expect(globalSection.getByRole('button', { name: 'Edit default' })).toBeVisible() + + await globalSection.getByRole('button', { name: 'Edit default' }).click() + expect(await setAllowOverride(dialog, true), 'Expected global allow-override switch in editor').toBe(true) + await submitRule(dialog) + await expect(globalSection.getByRole('button', { name: 'Edit default' })).toBeVisible() + + // Group rule: create + await dialog.getByRole('button', { name: 'New group override' }).first().click() + await chooseTarget(dialog, 'Target groups', 'admin') + await setDefaultSigningOrder(dialog, true) + await setAllowOverride(dialog, true) + await submitRule(dialog) + await expect(groupSection.getByRole('button', { name: 'Edit group rule' }).first()).toBeVisible() + + // Group rule: edit + await groupSection.getByRole('button', { name: 'Edit group rule' }).first().click() + expect(await setDefaultSigningOrder(dialog, false), 'Expected default-signing-order switch in group editor').toBe(true) + await submitRule(dialog) + await expect(groupSection.getByRole('button', { name: 'Edit group rule' }).first()).toBeVisible() + + // User rule: create + await dialog.getByRole('button', { name: 'New user override' }).first().click() + await chooseTarget(dialog, 'Target users', userTarget) + await setDefaultSigningOrder(dialog, true) + await submitRule(dialog) + await expect(userSection.getByRole('button', { name: 'Edit user rule' }).first()).toBeVisible() + + // User rule: edit + await clickSectionAction(userSection, 'Edit user rule') + expect(await setDefaultSigningOrder(dialog, false), 'Expected default-signing-order switch in user editor').toBe(true) + await submitRule(dialog) + await expect(userSection.getByRole('button', { name: 'Edit user rule' }).first()).toBeVisible() + + // User rule: delete + await removeAllRulesByAction(page, dialog, userSection, 'Delete user rule') + await expect(userSection.getByRole('button', { name: 'Delete user rule' })).toHaveCount(0) + + // Group rule: delete + await removeAllRulesByAction(page, dialog, groupSection, 'Delete group rule') + await expect(groupSection.getByRole('button', { name: 'Delete group rule' })).toHaveCount(0) + + // Global rule: reset to inherited baseline + await removeRuleWithConfirmation(page, dialog, globalSection, 'Reset default') + await expectGlobalBaselineState(globalSection) +}) From a44b7560e474790544f0f815b52d19b5b6c3bf8c Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 112/417] test(Settings): cover PolicyCatalog.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../views/Settings/PolicyCatalog.spec.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/tests/views/Settings/PolicyCatalog.spec.ts diff --git a/src/tests/views/Settings/PolicyCatalog.spec.ts b/src/tests/views/Settings/PolicyCatalog.spec.ts new file mode 100644 index 0000000000..a85e0fd3d9 --- /dev/null +++ b/src/tests/views/Settings/PolicyCatalog.spec.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { createL10nMock } from '../../testHelpers/l10n.js' +import PolicyCatalog from '../../../views/Settings/SignatureFlowPolicy/PolicyCatalog.vue' + +const fetchEffectivePolicies = vi.fn() +const getPolicy = vi.fn() + +vi.mock('@nextcloud/l10n', () => createL10nMock()) +vi.mock('../../../store/policies', () => ({ + usePoliciesStore: () => ({ + fetchEffectivePolicies, + getPolicy, + }), +})) + +describe('PolicyCatalog.vue', () => { + beforeEach(() => { + fetchEffectivePolicies.mockReset() + getPolicy.mockReset() + }) + + it('renders the catalog and exposes signing order as the live item', () => { + getPolicy.mockReturnValue({ effectiveValue: 'ordered_numeric' }) + + const wrapper = mount(PolicyCatalog, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcNoteCard: { template: '
' }, + SignatureFlow: { template: '
Signing order editor
' }, + }, + }, + }) + + expect(wrapper.text()).toContain('One list, live settings') + expect(wrapper.text()).toContain('Unified settings catalog') + expect(wrapper.text()).toContain('signature_flow') + expect(wrapper.text()).toContain('Signing order') + expect(wrapper.text()).toContain('Current effective value: Sequential.') + expect(wrapper.text()).toContain('signature_stamp') + expect(wrapper.find('.signature-flow-stub').exists()).toBe(true) + expect(fetchEffectivePolicies).not.toHaveBeenCalled() + }) + + it('loads the effective policies when signing order state is not bootstrapped yet', async () => { + getPolicy.mockReturnValue(null) + + mount(PolicyCatalog, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcNoteCard: { template: '
' }, + SignatureFlow: true, + }, + }, + }) + + await Promise.resolve() + + expect(fetchEffectivePolicies).toHaveBeenCalledTimes(1) + }) +}) From 930efccef4a75e26e9018bac7e398b9b72600e44 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 113/417] test(PolicyWorkbench): cover SignatureFlowScalarRuleEditor.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignatureFlowScalarRuleEditor.spec.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts diff --git a/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts b/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts new file mode 100644 index 0000000000..518adab799 --- /dev/null +++ b/src/tests/views/Settings/PolicyWorkbench/SignatureFlowScalarRuleEditor.spec.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { mount } from '@vue/test-utils' +import { describe, expect, it, vi } from 'vitest' + +import { createL10nMock } from '../../../testHelpers/l10n.js' + +vi.mock('@nextcloud/l10n', () => createL10nMock()) + +import SignatureFlowScalarRuleEditor from '../../../../views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue' + +describe('SignatureFlowScalarRuleEditor.vue', () => { + it('emits scalar values and does not keep stale local state', async () => { + const wrapper = mount(SignatureFlowScalarRuleEditor, { + props: { + modelValue: 'parallel', + }, + global: { + stubs: { + NcCheckboxRadioSwitch: { + props: ['modelValue'], + template: '
', + }, + }, + }, + }) + + await wrapper.find('.switch').trigger('click') + + const emissions = wrapper.emitted('update:modelValue') + expect(emissions).toBeTruthy() + expect(emissions?.[0]?.[0]).toBe('none') + + await wrapper.setProps({ modelValue: 'ordered_numeric' }) + expect(wrapper.text()).toContain('Sequential') + }) +}) From 50347f40dee2d618e4479dd8d57e751b81fe39cc Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 114/417] test(Settings): cover SignatureFlowGroupPolicy.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../Settings/SignatureFlowGroupPolicy.spec.ts | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/tests/views/Settings/SignatureFlowGroupPolicy.spec.ts diff --git a/src/tests/views/Settings/SignatureFlowGroupPolicy.spec.ts b/src/tests/views/Settings/SignatureFlowGroupPolicy.spec.ts new file mode 100644 index 0000000000..d0f3ca5fc7 --- /dev/null +++ b/src/tests/views/Settings/SignatureFlowGroupPolicy.spec.ts @@ -0,0 +1,193 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createL10nMock } from '../../testHelpers/l10n.js' +import { flushPromises, mount } from '@vue/test-utils' +import SignatureFlowGroupPolicy from '../../../views/Settings/SignatureFlowGroupPolicy.vue' + +type GroupRow = { + id: string + displayname: string +} + +type SignatureFlowGroupPolicyVm = { + enabled: boolean + allowChildOverride: boolean + selectedFlow?: { value: string } | null + onToggleChange: () => Promise + onAllowChildOverrideChange: () => Promise + onFlowChange: () => Promise +} + +const axiosGetMock = vi.fn() +const generateOcsUrlMock = vi.fn((path: string) => path) +const confirmPasswordMock = vi.fn(() => Promise.resolve()) + +const fetchGroupPolicyMock = vi.fn() +const saveGroupPolicyMock = vi.fn() +const clearGroupPolicyMock = vi.fn() + +vi.mock('@nextcloud/axios', () => ({ + default: { + get: (...args: unknown[]) => axiosGetMock(...args), + }, +})) + +vi.mock('@nextcloud/router', () => ({ + generateOcsUrl: (...args: unknown[]) => generateOcsUrlMock(...(args as [string])), +})) + +vi.mock('@nextcloud/password-confirmation', () => ({ + confirmPassword: () => confirmPasswordMock(), +})) + +vi.mock('@nextcloud/l10n', () => createL10nMock()) + +vi.mock('../../../store/policies', () => ({ + usePoliciesStore: () => ({ + fetchGroupPolicy: (...args: unknown[]) => fetchGroupPolicyMock(...args), + saveGroupPolicy: (...args: unknown[]) => saveGroupPolicyMock(...args), + clearGroupPolicy: (...args: unknown[]) => clearGroupPolicyMock(...args), + }), +})) + +describe('SignatureFlowGroupPolicy', () => { + beforeEach(() => { + axiosGetMock.mockReset() + generateOcsUrlMock.mockClear() + confirmPasswordMock.mockClear() + fetchGroupPolicyMock.mockReset() + saveGroupPolicyMock.mockReset() + clearGroupPolicyMock.mockReset() + + axiosGetMock.mockResolvedValue({ + data: { + ocs: { + data: { + groups: [ + { id: 'finance', displayname: 'Finance' }, + { id: 'legal', displayname: 'Legal' }, + ], + }, + }, + }, + }) + }) + + function createWrapper() { + return mount(SignatureFlowGroupPolicy, { + global: { + stubs: { + NcSettingsSection: { template: '
' }, + NcSelect: { + name: 'NcSelect', + props: ['modelValue'], + emits: ['update:modelValue', 'search-change'], + template: '
', + }, + NcCheckboxRadioSwitch: { + name: 'NcCheckboxRadioSwitch', + props: ['modelValue', 'value', 'type'], + emits: ['update:modelValue'], + template: '', + }, + NcLoadingIcon: true, + NcSavingIndicatorIcon: true, + NcNoteCard: true, + }, + }, + }) + } + + it('loads a selected group policy from the store', async () => { + fetchGroupPolicyMock.mockResolvedValue({ + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: 'ordered_numeric', + allowChildOverride: false, + visibleToChild: true, + allowedValues: ['parallel', 'ordered_numeric'], + }) + + const wrapper = createWrapper() + await flushPromises() + + const select = wrapper.findComponent({ name: 'NcSelect' }) + select.vm.$emit('update:modelValue', { id: 'finance', displayname: 'Finance' } satisfies GroupRow) + await flushPromises() + + expect(fetchGroupPolicyMock).toHaveBeenCalledWith('finance', 'signature_flow') + expect((wrapper.vm as unknown as SignatureFlowGroupPolicyVm).enabled).toBe(true) + expect((wrapper.vm as unknown as SignatureFlowGroupPolicyVm).selectedFlow?.value).toBe('ordered_numeric') + expect((wrapper.vm as unknown as SignatureFlowGroupPolicyVm).allowChildOverride).toBe(false) + }) + + it('saves the selected group override through the policy store', async () => { + fetchGroupPolicyMock.mockResolvedValue(null) + saveGroupPolicyMock.mockResolvedValue({ + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: 'parallel', + allowChildOverride: true, + visibleToChild: true, + allowedValues: ['parallel', 'ordered_numeric'], + }) + + const wrapper = createWrapper() + await flushPromises() + + const select = wrapper.findComponent({ name: 'NcSelect' }) + select.vm.$emit('update:modelValue', { id: 'finance', displayname: 'Finance' } satisfies GroupRow) + await flushPromises() + + const vm = wrapper.vm as unknown as SignatureFlowGroupPolicyVm + vm.enabled = true + vm.allowChildOverride = true + await vm.onToggleChange() + await flushPromises() + + expect(confirmPasswordMock).toHaveBeenCalledTimes(1) + expect(saveGroupPolicyMock).toHaveBeenCalledWith('finance', 'signature_flow', 'parallel', true) + }) + + it('clears the selected group override when disabled', async () => { + fetchGroupPolicyMock.mockResolvedValue({ + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: 'parallel', + allowChildOverride: true, + visibleToChild: true, + allowedValues: ['parallel', 'ordered_numeric'], + }) + clearGroupPolicyMock.mockResolvedValue({ + policyKey: 'signature_flow', + scope: 'group', + targetId: 'finance', + value: null, + allowChildOverride: true, + visibleToChild: true, + allowedValues: [], + }) + + const wrapper = createWrapper() + await flushPromises() + + const select = wrapper.findComponent({ name: 'NcSelect' }) + select.vm.$emit('update:modelValue', { id: 'finance', displayname: 'Finance' } satisfies GroupRow) + await flushPromises() + + const vm = wrapper.vm as unknown as SignatureFlowGroupPolicyVm + vm.enabled = false + await vm.onToggleChange() + await flushPromises() + + expect(confirmPasswordMock).toHaveBeenCalledTimes(1) + expect(clearGroupPolicyMock).toHaveBeenCalledWith('finance', 'signature_flow') + }) +}) From 20719b4eb77af8d3d6e644dd041d5a7bf1544d40 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 115/417] test(Settings): cover usePolicyWorkbench.spec behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../views/Settings/usePolicyWorkbench.spec.ts | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 src/tests/views/Settings/usePolicyWorkbench.spec.ts diff --git a/src/tests/views/Settings/usePolicyWorkbench.spec.ts b/src/tests/views/Settings/usePolicyWorkbench.spec.ts new file mode 100644 index 0000000000..9a586299ca --- /dev/null +++ b/src/tests/views/Settings/usePolicyWorkbench.spec.ts @@ -0,0 +1,110 @@ +/* + * SPDX-FileCopyrightText: 2026 LibreSign contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { describe, expect, it, vi } from 'vitest' + +import { createL10nMock } from '../../testHelpers/l10n.js' + +vi.mock('@nextcloud/l10n', () => createL10nMock()) + +import { createPolicyWorkbenchState } from '../../../views/Settings/PolicyWorkbench/usePolicyWorkbench' + +describe('usePolicyWorkbench', () => { + it('creates, edits and removes rules across settings without duplicating shell logic', () => { + const state = createPolicyWorkbenchState() + + state.openSetting('confetti') + state.startEditor({ scope: 'user' }) + expect(state.editorDraft.value?.targetId).toBe('maria') + state.saveDraft() + expect(state.settingsState.confetti.some((rule) => rule.scope === 'user' && rule.targetId === 'maria')).toBe(true) + + state.openSetting('signature_flow') + state.startEditor({ scope: 'group', ruleId: 'signature-group-finance' }) + state.updateDraftAllowOverride(false) + state.saveDraft() + expect(state.settingsState.signature_flow.find((rule) => rule.id === 'signature-group-finance')?.allowChildOverride).toBe(false) + + state.removeRule('signature-user-maria') + expect(state.settingsState.signature_flow.some((rule) => rule.id === 'signature-user-maria')).toBe(false) + + state.openSetting('signature_stamp') + state.startEditor({ scope: 'group', ruleId: 'stamp-group-legal' }) + state.updateDraftValue({ + enabled: true, + renderMode: 'GRAPHIC_ONLY', + template: '{{ signer_name }}', + templateFontSize: 12, + signatureFontSize: 18, + signatureWidth: 240, + signatureHeight: 90, + backgroundMode: 'custom', + showSigningDate: true, + }) + state.saveDraft() + expect(state.settingsState.signature_stamp.find((rule) => rule.id === 'stamp-group-legal')?.value.renderMode).toBe('GRAPHIC_ONLY') + + state.openSetting('identify_factors') + state.startEditor({ scope: 'user', ruleId: 'identify-user-maria' }) + state.updateDraftValue({ + enabled: true, + requireAnyTwo: true, + factors: [ + { + key: 'email', + label: 'Email', + enabled: true, + required: true, + allowCreateAccount: true, + signatureMethod: 'email_token', + }, + { + key: 'sms', + label: 'SMS', + enabled: true, + required: true, + allowCreateAccount: false, + signatureMethod: 'sms_token', + }, + { + key: 'whatsapp', + label: 'WhatsApp', + enabled: false, + required: false, + allowCreateAccount: false, + signatureMethod: 'whatsapp_token', + }, + { + key: 'document', + label: 'Document data', + enabled: true, + required: false, + allowCreateAccount: false, + signatureMethod: 'document_validation', + }, + ], + }) + state.saveDraft() + expect(state.settingsState.identify_factors.find((rule) => rule.id === 'identify-user-maria')?.value.requireAnyTwo).toBe(true) + }) + + it('filters the workspace for group admins to the current group and its users', () => { + const state = createPolicyWorkbenchState() + + state.setViewMode('group-admin') + state.openSetting('signature_flow') + + expect(state.visibleGroupRules.value).toHaveLength(1) + expect(state.visibleGroupRules.value[0]?.targetId).toBe('finance') + expect(state.visibleUserRules.value.every((rule) => ['maria', 'joao'].includes(rule.targetId ?? ''))).toBe(true) + + state.startEditor({ scope: 'user' }) + expect(state.editorDraft.value?.targetId).toBe('joao') + + const summaryKeys = state.visibleSettingSummaries.value.map((summary) => summary.key) + expect(summaryKeys).toContain('signature_stamp') + expect(summaryKeys).toContain('identify_factors') + }) +}) From cbd9974ad22592cc5c33c158dc63e04e8f76af73 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:41 -0300 Subject: [PATCH 116/417] feat(PolicyWorkbench): implement PolicyRuleCard behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../PolicyWorkbench/PolicyRuleCard.vue | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue diff --git a/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue b/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue new file mode 100644 index 0000000000..4ec5fac2c4 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/PolicyRuleCard.vue @@ -0,0 +1,252 @@ + + + + + + + From 189637e36d5209214dcd05d2f35a9347df5b841a Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 117/417] feat(PolicyWorkbench): implement SettingsPolicyWorkbench behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SettingsPolicyWorkbench.vue | 1486 +++++++++++++++++ 1 file changed, 1486 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue diff --git a/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue b/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue new file mode 100644 index 0000000000..53d1b210fd --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/SettingsPolicyWorkbench.vue @@ -0,0 +1,1486 @@ + + + + + + + From 4fde20f95c156c05a39ebca414c8ba200b4972b4 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 118/417] feat(PolicyWorkbench): implement SigningOrderPolicyWorkbench behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SigningOrderPolicyWorkbench.vue | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/SigningOrderPolicyWorkbench.vue diff --git a/src/views/Settings/PolicyWorkbench/SigningOrderPolicyWorkbench.vue b/src/views/Settings/PolicyWorkbench/SigningOrderPolicyWorkbench.vue new file mode 100644 index 0000000000..4c2435088d --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/SigningOrderPolicyWorkbench.vue @@ -0,0 +1,309 @@ + + + + + + + From 0a7f705450ec1699cfb83fdb90e2e1b6d4330ead Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 119/417] feat(confetti): implement ConfettiRuleEditor behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../settings/confetti/ConfettiRuleEditor.vue | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue diff --git a/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue new file mode 100644 index 0000000000..99ccd40eb4 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/confetti/ConfettiRuleEditor.vue @@ -0,0 +1,59 @@ + + + + + + + From b6392eb011d74294c0a0ac5312ac3117a35ee824 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 120/417] feat(confetti): implement index behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../settings/confetti/index.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/confetti/index.ts diff --git a/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts b/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts new file mode 100644 index 0000000000..5cd3f3cecb --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/confetti/index.ts @@ -0,0 +1,26 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +import ConfettiRuleEditor from './ConfettiRuleEditor.vue' +import type { PolicySettingDefinition } from '../../types' + +export const confettiDefinition: PolicySettingDefinition<'confetti'> = { + key: 'confetti', + title: t('libresign', 'Confetti animation'), + description: t('libresign', 'Control whether a celebratory animation is shown when someone signs a document.'), + menuHint: t('libresign', 'Useful to validate the policy shell with a compact editor and simple value shape.'), + editor: ConfettiRuleEditor, + createEmptyValue: () => ({ + enabled: true, + }), + summarizeValue: (value) => value.enabled + ? t('libresign', 'Enabled') + : t('libresign', 'Disabled'), + formatAllowOverride: (allowChildOverride) => allowChildOverride + ? t('libresign', 'Lower layers may override this rule') + : t('libresign', 'Lower layers must inherit this value'), +} From 5bed266a06162ac1a9f75e65492c983149853456 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 121/417] feat(identify-factors): implement IdentifyFactorsRuleEditor behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../IdentifyFactorsRuleEditor.vue | 266 ++++++++++++++++++ 1 file changed, 266 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue diff --git a/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue new file mode 100644 index 0000000000..3e4280ea37 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/identify-factors/IdentifyFactorsRuleEditor.vue @@ -0,0 +1,266 @@ + + + + + + + From fce3d35c8a26d02bee1bd0aef6adbeb86b15152b Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 122/417] feat(identify-factors): implement index behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../settings/identify-factors/index.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts diff --git a/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts b/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts new file mode 100644 index 0000000000..1af84e3477 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/identify-factors/index.ts @@ -0,0 +1,69 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +import IdentifyFactorsRuleEditor from './IdentifyFactorsRuleEditor.vue' +import type { PolicySettingDefinition } from '../../types' + +export const identifyFactorsDefinition: PolicySettingDefinition<'identify_factors'> = { + key: 'identify_factors', + title: t('libresign', 'Identification factors'), + description: t('libresign', 'Configure which factors identify signers and how each factor maps to signature methods.'), + menuHint: t('libresign', 'Complex matrix example with nested options per factor and fallback behavior.'), + editor: IdentifyFactorsRuleEditor, + createEmptyValue: () => ({ + enabled: true, + requireAnyTwo: false, + factors: [ + { + key: 'email', + label: t('libresign', 'Email'), + enabled: true, + required: true, + allowCreateAccount: true, + signatureMethod: 'email_token', + }, + { + key: 'sms', + label: t('libresign', 'SMS'), + enabled: false, + required: false, + allowCreateAccount: false, + signatureMethod: 'sms_token', + }, + { + key: 'whatsapp', + label: t('libresign', 'WhatsApp'), + enabled: false, + required: false, + allowCreateAccount: false, + signatureMethod: 'whatsapp_token', + }, + { + key: 'document', + label: t('libresign', 'Document data'), + enabled: false, + required: false, + allowCreateAccount: false, + signatureMethod: 'document_validation', + }, + ], + }), + summarizeValue: (value) => { + if (!value.enabled) { + return t('libresign', 'Disabled') + } + + const enabledCount = value.factors.filter((factor) => factor.enabled).length + const strategy = value.requireAnyTwo + ? t('libresign', 'any two') + : t('libresign', 'single factor') + return `${enabledCount} ${t('libresign', 'factors enabled')} - ${strategy}` + }, + formatAllowOverride: (allowChildOverride) => allowChildOverride + ? t('libresign', 'Lower layers may override this rule') + : t('libresign', 'Lower layers must inherit this value'), +} From e2d4a3c35005c34c53e771b71e9a904b8404808e Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 123/417] feat(signature-flow): implement SignatureFlowRuleEditor behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignatureFlowRuleEditor.vue | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue new file mode 100644 index 0000000000..be05cf9220 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowRuleEditor.vue @@ -0,0 +1,101 @@ + + + + + + + From 8da0bc14ed52b48556e4d272be24c85c686224b7 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 124/417] feat(signature-flow): implement SignatureFlowScalarRuleEditor behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignatureFlowScalarRuleEditor.vue | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue new file mode 100644 index 0000000000..21fdfd43d5 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/SignatureFlowScalarRuleEditor.vue @@ -0,0 +1,101 @@ + + + + + + + From 4ce0709f41ef626c9713c878246c03a2e2f25724 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 125/417] feat(signature-flow): implement index behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../settings/signature-flow/index.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts b/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts new file mode 100644 index 0000000000..d8ee8edd77 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/signature-flow/index.ts @@ -0,0 +1,33 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and LibreCode contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { t } from '@nextcloud/l10n' + +import SignatureFlowRuleEditor from './SignatureFlowRuleEditor.vue' +import type { PolicySettingDefinition } from '../../types' + +export const signatureFlowDefinition: PolicySettingDefinition<'signature_flow'> = { + key: 'signature_flow', + title: t('libresign', 'Signing order'), + description: t('libresign', 'Define how signers receive and process the signature request.'), + menuHint: t('libresign', 'Good policy-shell candidate because it combines a simple editor with multiple scopes.'), + editor: SignatureFlowRuleEditor, + createEmptyValue: () => ({ + enabled: true, + flow: 'parallel', + }), + summarizeValue: (value) => { + if (!value.enabled) { + return t('libresign', 'Disabled') + } + + return value.flow === 'parallel' + ? t('libresign', 'Parallel') + : t('libresign', 'Sequential') + }, + formatAllowOverride: (allowChildOverride) => allowChildOverride + ? t('libresign', 'Lower layers may override this rule') + : t('libresign', 'Lower layers must inherit this value'), +} From a5d7ea9351859722d3bfdc7b45d52c40842a9e9f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Fri, 20 Mar 2026 06:38:42 -0300 Subject: [PATCH 126/417] feat(signature-stamp): implement SignatureStampRuleEditor behavior Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- .../SignatureStampRuleEditor.vue | 429 ++++++++++++++++++ 1 file changed, 429 insertions(+) create mode 100644 src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue diff --git a/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue new file mode 100644 index 0000000000..cd60c97e14 --- /dev/null +++ b/src/views/Settings/PolicyWorkbench/settings/signature-stamp/SignatureStampRuleEditor.vue @@ -0,0 +1,429 @@ + + +