diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 43238b34fd..6cfef858ca 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -60,7 +60,6 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(Notifier::class); $context->registerSearchProvider(FileSearchProvider::class); - $context->registerEventListener(LoadSidebar::class, TemplateLoader::class); $context->registerEventListener(BeforeNodeDeletedEvent::class, BeforeNodeDeletedListener::class); $context->registerEventListener(CacheEntryRemovedEvent::class, BeforeNodeDeletedListener::class); diff --git a/lib/Command/Developer/Reset.php b/lib/Command/Developer/Reset.php index 18b58dc58b..c9c4e1793a 100644 --- a/lib/Command/Developer/Reset.php +++ b/lib/Command/Developer/Reset.php @@ -96,6 +96,12 @@ protected function configure(): void { mode: InputOption::VALUE_NONE, description: 'Reset config' ) + ->addOption( + name: 'policy', + shortcut: null, + mode: InputOption::VALUE_NONE, + description: 'Reset policy data' + ) ; } @@ -140,6 +146,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->resetConfig(); $ok = true; } + if ($input->getOption('policy') || $all) { + $this->resetPolicy(); + $ok = true; + } } catch (\Exception $e) { $this->logger->error($e->getMessage()); throw $e; @@ -254,4 +264,17 @@ private function resetConfig(): void { } catch (\Throwable) { } } + + private function resetPolicy(): void { + try { + $delete = $this->db->getQueryBuilder(); + $delete->delete('libresign_permission_set_binding') + ->executeStatement(); + + $delete = $this->db->getQueryBuilder(); + $delete->delete('libresign_permission_set') + ->executeStatement(); + } catch (\Throwable) { + } + } } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 34c2458980..d9f1beed8f 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, ) { @@ -875,7 +877,7 @@ public function getFooterTemplate(): DataResponse { public function saveFooterTemplate(string $template = '', int $width = 595, int $height = 50) { try { $this->footerService->saveTemplate($template); - $pdf = $this->footerService->renderPreviewPdf('', $width, $height); + $pdf = $this->footerService->renderPreviewPdf($template, $width, $height); return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); } catch (\Exception $e) { @@ -960,57 +962,6 @@ private function saveOrDeleteConfig(string $key, ?string $value, string $default } } - /** - * Set signature flow configuration - * - * @param bool $enabled Whether to force a signature flow for all documents - * @param string|null $mode Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true) - * @return DataResponse|DataResponse|DataResponse - * - * 200: Configuration saved successfully - * 400: Invalid signature flow mode provided - * 500: Internal server error - */ - #[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) { - 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 - ); - - return new DataResponse([ - 'message' => $this->l10n->t('Settings saved'), - ]); - } catch (\Exception $e) { - return new DataResponse([ - 'error' => $e->getMessage(), - ], Http::STATUS_INTERNAL_SERVER_ERROR); - } - } - /** * Configure DocMDP signature restrictions * diff --git a/lib/Controller/FileController.php b/lib/Controller/FileController.php index 55101552fe..18e3aff994 100644 --- a/lib/Controller/FileController.php +++ b/lib/Controller/FileController.php @@ -182,7 +182,7 @@ public function validateBinary(): DataResponse { ->toArray(); $statusCode = Http::STATUS_OK; } catch (InvalidArgumentException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] @@ -254,15 +254,15 @@ private function validate( ->toArray(); $statusCode = Http::STATUS_OK; } catch (LibresignException $e) { - $message = $this->l10n->t($e->getMessage()); + $message = $e->getMessage(); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] ]; $statusCode = Http::STATUS_NOT_FOUND; } catch (\Throwable $th) { - $message = $this->l10n->t($th->getMessage()); - $this->logger->error($message); + $this->logger->error($th->getMessage(), ['exception' => $th]); + $message = $this->l10n->t('Internal error. Contact admin.'); $return = [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => [['message' => $message]] diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 51d7305461..95fed81b0f 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\PolicyService; 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 PolicyService $policyService, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -106,7 +108,13 @@ 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)); + $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')); diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..425bec9527 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,545 @@ + + * + * 200: OK + */ + #[NoAdminRequired] + #[NoCSRFRequired] + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/effective', requirements: ['apiVersion' => '(v1)'])] + public function effective(): DataResponse { + $user = $this->userSession->getUser(); + $ruleCounts = $this->resolveRuleCountsForActor($user); + + /** @var array $policies */ + $policies = []; + foreach ($this->policyService->resolveKnownPolicies() as $policyKey => $resolvedPolicy) { + /** @var LibresignEffectivePolicyState $policyState */ + $policyState = $resolvedPolicy->toArray(); + $policyState['groupCount'] = $ruleCounts[$policyKey]['groupCount'] ?? 0; + $policyState['userCount'] = $ruleCounts[$policyKey]['userCount'] ?? 0; + $policies[$policyKey] = $policyState; + } + + /** @var LibresignEffectivePoliciesResponse $data */ + $data = [ + 'policies' => $policies, + ]; + + return new DataResponse($data); + } + + /** + * Read explicit system policy configuration + * + * @param string $policyKey Policy identifier to read from the system layer. + * @return DataResponse + * + * 200: OK + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/system/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function getSystem(string $policyKey): DataResponse { + $policy = $this->policyService->getSystemPolicy($policyKey); + + /** @var LibresignSystemPolicyResponse $data */ + $data = [ + 'policy' => [ + 'policyKey' => $policyKey, + 'scope' => ($policy?->getScope() === 'global' ? 'global' : 'system'), + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + 'visibleToChild' => $policy?->isVisibleToChild() ?? true, + 'allowedValues' => $policy?->getAllowedValues() ?? [], + ], + ]; + + 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|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[NoAdminRequired] + #[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 { + if (!$this->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $policy = $this->policyService->getGroupPolicy($policyKey, $groupId); + + /** @var LibresignGroupPolicyResponse $data */ + $data = [ + 'policy' => $this->serializeGroupPolicy($groupId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } + + /** + * Read an explicit user-level policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to read for the selected user. + * @return DataResponse|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[ApiRoute(verb: 'GET', url: '/api/{apiVersion}/policies/user/{userId}/{policyKey}', requirements: ['apiVersion' => '(v1)', 'userId' => '[^/]+', 'policyKey' => '[a-z0-9_]+'])] + public function getUserPolicyForUser(string $userId, string $policyKey): DataResponse { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $policy = $this->policyService->getUserPolicyForUserId($policyKey, $userId); + + /** @var LibresignUserPolicyResponse $data */ + $data = [ + 'policy' => $this->serializeUserPolicy($userId, $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 + * + * 200: OK + * 400: Invalid policy value + */ + #[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, bool $allowChildOverride = false): DataResponse { + $value = $this->readScalarParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $policy = $this->policyService->saveSystem($policyKey, $value, $allowChildOverride); + /** @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' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * 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 + * 403: Forbidden + */ + #[NoAdminRequired] + #[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 { + if (!$this->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + $value = $this->readScalarParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + 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 (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * 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 + * 403: Forbidden + */ + #[NoAdminRequired] + #[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 { + if (!$this->canManageGroupPolicy($groupId)) { + return $this->forbiddenGroupPolicyResponse(); + } + + 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 (\DomainException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } + } + + /** + * 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 + * + * 200: OK + * 400: Invalid policy value + */ + #[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 { + $value = $this->readScalarParam('value', $value); + + 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' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Save an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment. + * @param string $policyKey Policy identifier to persist for the target user. + * @param null|bool|int|float|string $value Policy value to persist as assigned target user policy. + * @param bool $allowChildOverride Whether the target user may still override the assigned value in personal preferences. + * @return DataResponse|DataResponse|DataResponse + * + * 200: OK + * 400: Invalid policy value + * 403: Forbidden + */ + #[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, bool $allowChildOverride = false): DataResponse { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $value = $this->readScalarParam('value', $value); + $allowChildOverride = $this->readBoolParam('allowChildOverride', $allowChildOverride); + + try { + $policy = $this->policyService->saveUserPolicyForUserId($policyKey, $userId, $value, $allowChildOverride); + /** @var LibresignUserPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** + * Clear a user policy preference + * + * @param string $policyKey Policy identifier to clear for the current user. + * @return DataResponse + * + * 200: OK + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPreference(string $policyKey): DataResponse { + $policy = $this->policyService->clearUserPreference($policyKey); + /** @var LibresignSystemPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $policy->toArray(), + ]; + + return new DataResponse($data); + } + + /** + * Clear an explicit user policy for a target user (admin scope) + * + * @param string $userId Target user identifier that receives the policy assignment removal. + * @param string $policyKey Policy identifier to clear for the target user. + * @return DataResponse|DataResponse + * + * 200: OK + * 403: Forbidden + */ + #[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 { + if (!$this->canManageUserPolicy($userId)) { + return $this->forbiddenUserPolicyResponse(); + } + + $policy = $this->policyService->clearUserPolicyForUserId($policyKey, $userId); + /** @var LibresignUserPolicyWriteResponse $data */ + $data = [ + 'message' => $this->l10n->t('Settings saved'), + 'policy' => $this->serializeUserPolicy($userId, $policyKey, $policy), + ]; + + return new DataResponse($data); + } + + /** @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() ?? [], + ]; + } + + /** @return LibresignUserPolicyState */ + private function serializeUserPolicy(string $userId, string $policyKey, ?PolicyLayer $policy): array { + return [ + 'policyKey' => $policyKey, + 'scope' => 'user_policy', + 'targetId' => $userId, + 'value' => $policy?->getValue(), + 'allowChildOverride' => $policy?->isAllowChildOverride() ?? true, + ]; + } + + private function canManageGroupPolicy(string $groupId): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + $group = $this->groupManager->get($groupId); + if ($group === null) { + return false; + } + + return $this->subAdmin->isSubAdminOfGroup($user, $group); + } + + private function canManageUserPolicy(string $userId): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + return true; + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return false; + } + + $targetUser = $this->userManager->get($userId); + if (!$targetUser instanceof IUser) { + return false; + } + + $managedGroupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + if ($managedGroupIds === []) { + return false; + } + + $targetGroupIds = $this->groupManager->getUserGroupIds($targetUser); + return array_intersect($managedGroupIds, $targetGroupIds) !== []; + } + + /** + * @return array + */ + private function resolveRuleCountsForActor(?IUser $user): array { + if ($user === null) { + return []; + } + + if ($this->groupManager->isAdmin($user->getUID())) { + $groupIds = array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->groupManager->search(''), + )); + $userIds = array_values(array_map( + static fn ($candidate): string => $candidate->getUID(), + $this->userManager->searchDisplayName(''), + )); + + return $this->policyService->getRuleCounts($groupIds, $userIds); + } + + if ($this->subAdmin->isSubAdmin($user)) { + $groupIds = array_map( + static fn ($group) => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + ); + return $this->policyService->getRuleCounts($groupIds, []); + } + + return []; + } + + private function readScalarParam(string $key, null|bool|int|float|string $default): null|bool|int|float|string { + $value = $this->request->getParams()[$key] ?? $default; + if (!is_scalar($value) && $value !== null) { + return $default; + } + + return $value; + } + + private function readBoolParam(string $key, bool $default): bool { + $value = $this->request->getParams()[$key] ?? $default; + return is_bool($value) ? $value : $default; + } + + /** @return DataResponse */ + private function forbiddenGroupPolicyResponse(): DataResponse { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t('Not allowed to manage this group policy'), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } + + /** @return DataResponse */ + private function forbiddenUserPolicyResponse(): DataResponse { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $this->l10n->t('Not allowed to manage this user policy'), + ]; + + return new DataResponse($data, Http::STATUS_FORBIDDEN); + } +} diff --git a/lib/Controller/RequestSignatureController.php b/lib/Controller/RequestSignatureController.php index 0fcc5a6de9..f52612bc5b 100644 --- a/lib/Controller/RequestSignatureController.php +++ b/lib/Controller/RequestSignatureController.php @@ -67,7 +67,7 @@ public function __construct( * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. * @param string|null $callback URL that will receive a POST after the document is signed * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration + * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. * @return DataResponse|DataResponse * * 200: OK @@ -133,7 +133,7 @@ public function request( * @param LibresignVisibleElement[]|null $visibleElements Visible elements on document * @param LibresignNewFile|array|null $file File object. Supports nodeId, url, base64 or path when creating a new request. * @param integer|null $status Numeric code of status * 0 - no signers * 1 - signed * 2 - pending - * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration + * @param string|null $signatureFlow Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution. * @param string|null $name The name of file to sign * @param LibresignFolderSettings $settings Settings to define how and where the file should be stored * @param list $files Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path. diff --git a/lib/Db/PermissionSet.php b/lib/Db/PermissionSet.php new file mode 100644 index 0000000000..7b1c7b1cfc --- /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 getDecodedPolicyJson(): 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; + } +} 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; + } +} diff --git a/lib/Db/PermissionSetBindingMapper.php b/lib/Db/PermissionSetBindingMapper.php new file mode 100644 index 0000000000..3952835c74 --- /dev/null +++ b/lib/Db/PermissionSetBindingMapper.php @@ -0,0 +1,101 @@ + + */ +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; + } + + /** + * @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; + } + + /** + * @return list + */ + public function findByTargetType(string $targetType): array { + $qb = $this->db->getQueryBuilder(); + $qb->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('target_type', $qb->createNamedParameter($targetType))); + + /** @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 new file mode 100644 index 0000000000..bafa288eb2 --- /dev/null +++ b/lib/Db/PermissionSetMapper.php @@ -0,0 +1,66 @@ + + */ +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; + } + + /** + * @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 7d3c8e3d1f..425ce46316 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\PolicyService; 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 PolicyService $policyService, private IAppManager $appManager, private ConfigService $docMdpConfigService, ) { @@ -63,23 +63,22 @@ 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' => $this->getSignatureFlow(), + 'effective_policies' => [ + 'policies' => $resolvedPolicies, + ], '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()); diff --git a/lib/Handler/CertificateEngine/OpenSslHandler.php b/lib/Handler/CertificateEngine/OpenSslHandler.php index 430bea301b..3e49638115 100644 --- a/lib/Handler/CertificateEngine/OpenSslHandler.php +++ b/lib/Handler/CertificateEngine/OpenSslHandler.php @@ -247,7 +247,7 @@ private function buildCaCertificateConfig(): array { ], 'v3_ca' => [ 'basicConstraints' => 'critical, CA:TRUE, pathlen:1', - 'keyUsage' => 'critical, digitalSignature, keyCertSign', + 'keyUsage' => 'critical, digitalSignature, keyCertSign, cRLSign', 'extendedKeyUsage' => 'clientAuth, emailProtection', 'subjectAltName' => $this->getSubjectAltNames(), 'authorityKeyIdentifier' => 'keyid', diff --git a/lib/Handler/DocMdpHandler.php b/lib/Handler/DocMdpHandler.php index ad0f1d2afd..f65d277a55 100644 --- a/lib/Handler/DocMdpHandler.php +++ b/lib/Handler/DocMdpHandler.php @@ -329,14 +329,14 @@ private function validateModifications(DocMdpLevel $docmdpLevel, array $modifica * * @param bool $valid Whether modification is valid * @param int $status Status constant from File class - * @param string $messageKey Translation key + * @param string $message Translated message * @return array Validation result */ - private function buildValidationResult(bool $valid, int $status, string $messageKey): array { + private function buildValidationResult(bool $valid, int $status, string $message): array { return [ 'valid' => $valid, 'status' => $status, - 'message' => $this->l10n->t($messageKey), + 'message' => $message, ]; } @@ -348,10 +348,10 @@ private function buildValidationResult(bool $valid, int $status, string $message */ private function getAllowedModificationMessage(DocMdpLevel $level): string { return match ($level) { - DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => 'Invalid: Document was modified after signing (DocMDP violation - no changes allowed)', - DocMdpLevel::CERTIFIED_FORM_FILLING => 'Document form fields were modified (allowed by DocMDP P=2)', - DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => 'Document form fields or annotations were modified (allowed by DocMDP P=3)', - default => 'Document was modified after signing', + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED => $this->l10n->t('Invalid: Document was modified after signing (DocMDP violation - no changes allowed)'), + DocMdpLevel::CERTIFIED_FORM_FILLING => $this->l10n->t('Document form fields were modified (allowed by DocMDP P=2)'), + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS => $this->l10n->t('Document form fields or annotations were modified (allowed by DocMDP P=3)'), + default => $this->l10n->t('Document was modified after signing'), }; } diff --git a/lib/Handler/FooterHandler.php b/lib/Handler/FooterHandler.php index fd5ea2c882..e0c20a451b 100644 --- a/lib/Handler/FooterHandler.php +++ b/lib/Handler/FooterHandler.php @@ -12,6 +12,9 @@ use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicyValue; use OCA\Libresign\Vendor\Endroid\QrCode\Color\Color; use OCA\Libresign\Vendor\Endroid\QrCode\Encoding\Encoding; use OCA\Libresign\Vendor\Endroid\QrCode\ErrorCorrectionLevel; @@ -41,17 +44,17 @@ public function __construct( private IL10N $l10n, private IFactory $l10nFactory, private ITempManager $tempManager, + private PolicyService $policyService, private TemplateVariables $templateVars, ) { } - public function getFooter(array $dimensions): string { - $add_footer = (bool)$this->appConfig->getValueBool(Application::APP_ID, 'add_footer', true); - if (!$add_footer) { + public function getFooter(array $dimensions, bool $forceEnabled = false): string { + if (!$forceEnabled && !$this->isFooterEnabled()) { return ''; } - $htmlFooter = $this->getRenderedHtmlFooter(); + $htmlFooter = $this->getRenderedHtmlFooter($forceEnabled); foreach ($dimensions as $dimension) { if (!isset($pdf)) { $pdf = new Mpdf([ @@ -94,14 +97,14 @@ public function getMetadata(File $file, FileEntity $fileEntity): array { return $metadata; } - private function getRenderedHtmlFooter(): string { + private function getRenderedHtmlFooter(bool $forceEnabled = false): string { try { $twigEnvironment = new Environment( new FilesystemLoader(), ); return $twigEnvironment ->createTemplate($this->getTemplate()) - ->render($this->prepareTemplateVars()); + ->render($this->prepareTemplateVars($forceEnabled)); } catch (SyntaxError $e) { throw new LibresignException($e->getMessage()); } @@ -112,7 +115,11 @@ public function setTemplateVar(string $name, mixed $value): self { return $this; } - private function prepareTemplateVars(): array { + private function prepareTemplateVars(bool $forceEnabled = false): array { + $footerPolicy = FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + if (!$this->templateVars->getSignedBy()) { $this->templateVars->setSignedBy( $this->appConfig->getValueString(Application::APP_ID, 'footer_signed_by', $this->l10n->t('Digitally signed by LibreSign.')) @@ -132,7 +139,7 @@ private function prepareTemplateVars(): array { } if (!$this->templateVars->getValidationSite() && $this->templateVars->getUuid()) { - $validationSite = $this->appConfig->getValueString(Application::APP_ID, 'validation_site'); + $validationSite = $footerPolicy['validationSite']; if ($validationSite) { $this->templateVars->setValidationSite( rtrim($validationSite, '/') . '/' . $this->templateVars->getUuid() @@ -155,7 +162,7 @@ private function prepareTemplateVars(): array { } } - if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) { + if ($footerPolicy['writeQrcodeOnFooter'] && $this->templateVars->getValidationSite()) { $this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite())); } @@ -204,4 +211,10 @@ private function getQrCodeImageBase64(string $text): string { public function getTemplateVariablesMetadata(): array { return $this->templateVars->getVariablesMetadata(); } + + private function isFooterEnabled(): bool { + return FooterPolicyValue::isEnabled( + $this->policyService->resolve(FooterPolicy::KEY)->getEffectiveValue() + ); + } } diff --git a/lib/Handler/SigningErrorHandler.php b/lib/Handler/SigningErrorHandler.php index f3cd539c52..c6a6383f35 100644 --- a/lib/Handler/SigningErrorHandler.php +++ b/lib/Handler/SigningErrorHandler.php @@ -60,7 +60,7 @@ private function handleGenericException(\Throwable $exception): array { return [ 'action' => JSActions::ACTION_DO_NOTHING, 'errors' => $this->isKnownError($message) - ? [['message' => $this->l10n->t($message)]] + ? [['message' => $this->translateKnownError($message)]] : $this->formatUnknownError($message, $exception), ]; } @@ -73,6 +73,15 @@ private function isKnownError(string $message): bool { ], true); } + private function translateKnownError(string $message): string { + return match ($message) { + 'Host violates local access rules.' => $this->l10n->t('Host violates local access rules.'), + 'Certificate Password Invalid.' => $this->l10n->t('Certificate Password Invalid.'), + 'Certificate Password is Empty.' => $this->l10n->t('Certificate Password is Empty.'), + default => $message, + }; + } + /** * @return list */ diff --git a/lib/Migration/Version18000Date20260317000000.php b/lib/Migration/Version18000Date20260317000000.php new file mode 100644 index 0000000000..a06a974ee0 --- /dev/null +++ b/lib/Migration/Version18000Date20260317000000.php @@ -0,0 +1,100 @@ +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, + ]); + $permissionSetTable->addColumn('name', Types::STRING, [ + 'notnull' => true, + 'length' => 255, + ]); + $permissionSetTable->addColumn('description', Types::TEXT, [ + 'notnull' => false, + ]); + $permissionSetTable->addColumn('scope_type', Types::STRING, [ + 'notnull' => true, + 'length' => 64, + ]); + $permissionSetTable->addColumn('enabled', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 1, + ]); + $permissionSetTable->addColumn('priority', Types::SMALLINT, [ + 'notnull' => true, + 'default' => 0, + ]); + $permissionSetTable->addColumn('policy_json', Types::TEXT, [ + 'notnull' => true, + 'default' => '{}', + ]); + $permissionSetTable->addColumn('created_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $permissionSetTable->addColumn('updated_at', Types::DATETIME, [ + 'notnull' => true, + ]); + $permissionSetTable->setPrimaryKey(['id']); + $permissionSetTable->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($permissionSetTable, ['permission_set_id'], ['id'], [ + 'onDelete' => 'CASCADE', + ]); + } + + return $schema; + } +} diff --git a/lib/Migration/Version18001Date20260320000000.php b/lib/Migration/Version18001Date20260320000000.php new file mode 100644 index 0000000000..7cdda6ea15 --- /dev/null +++ b/lib/Migration/Version18001Date20260320000000.php @@ -0,0 +1,155 @@ +migrateLegacyFooterSettings(); + $this->migrateDocMdpLevelType(); + $this->migrateIdentifyMethodsType(); + } + + private function migrateLegacyFooterSettings(): void { + $legacyAddFooter = $this->readLegacyValue(FooterPolicy::KEY); + $legacyWriteQrCodeOnFooter = $this->readLegacyBool('write_qrcode_on_footer', true); + $legacyValidationSite = $this->readLegacyString('validation_site') ?? ''; + $legacyFooterTemplateIsDefault = $this->readLegacyBool('footer_template_is_default', true); + + $rawFooterPolicyValue = $legacyAddFooter; + if (!$this->isStructuredFooterPayload($legacyAddFooter)) { + $rawFooterPolicyValue = [ + 'enabled' => $this->toBool($legacyAddFooter, true), + 'writeQrcodeOnFooter' => $legacyWriteQrCodeOnFooter, + 'validationSite' => $legacyValidationSite, + 'customizeFooterTemplate' => !$legacyFooterTemplateIsDefault, + ]; + } + + $encodedFooterPolicyValue = FooterPolicyValue::encode( + FooterPolicyValue::normalize($rawFooterPolicyValue), + ); + + $this->appConfig->deleteKey(Application::APP_ID, FooterPolicy::KEY); + $this->appConfig->setValueString(Application::APP_ID, FooterPolicy::KEY, $encodedFooterPolicyValue); + } + + private function migrateDocMdpLevelType(): void { + $legacyValue = $this->readLegacyString(DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue === null || $legacyValue === '' || !is_numeric($legacyValue)) { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueInt(Application::APP_ID, DocMdpPolicy::SYSTEM_APP_CONFIG_KEY, (int)$legacyValue); + } + + private function migrateIdentifyMethodsType(): void { + $legacyValue = $this->readLegacyString('identify_methods'); + if ($legacyValue === null || $legacyValue === '') { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, 'identify_methods'); + $decoded = json_decode($legacyValue, true); + if (!is_array($decoded)) { + return; + } + + $this->appConfig->setValueArray(Application::APP_ID, 'identify_methods', $decoded); + } + + private function readLegacyString(string $key): ?string { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + // The key is already stored in the target typed format + return null; + } + } + + private function readLegacyValue(string $key): mixed { + try { + return $this->appConfig->getValueString(Application::APP_ID, $key, ''); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, true); + } + } + + private function readLegacyBool(string $key, bool $default): bool { + try { + $rawValue = $this->appConfig->getValueString(Application::APP_ID, $key, ''); + if ($rawValue === '') { + return $default; + } + + return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getValueBool(Application::APP_ID, $key, $default); + } + } + + private function isStructuredFooterPayload(mixed $value): bool { + if (!is_string($value)) { + return false; + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return false; + } + + return array_key_exists('enabled', $decoded) + || array_key_exists('writeQrcodeOnFooter', $decoded) + || array_key_exists('validationSite', $decoded) + || array_key_exists('customizeFooterTemplate', $decoded); + } + + private function toBool(mixed $value, bool $default): bool { + if (is_bool($value)) { + return $value; + } + + if (is_int($value)) { + return $value === 1; + } + + if (is_string($value)) { + $trimmed = trim($value); + if ($trimmed === '') { + return $default; + } + + return in_array(strtolower($trimmed), ['1', 'true', 'yes', 'on'], true); + } + + return $default; + } + + #[\Override] + public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { + return null; + } +} diff --git a/lib/ResponseDefinitions.php b/lib/ResponseDefinitions.php index cb386d9f76..54d2b722d5 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -354,11 +354,88 @@ * * 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, + * groupCount: non-negative-int, + * userCount: non-negative-int, + * } + * @psalm-type LibresignEffectivePolicyResponse = array{ + * policy: LibresignEffectivePolicyState, + * } + * @psalm-type LibresignEffectivePoliciesResponse = array{ + * policies: array, + * } + * @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 LibresignSystemPolicyState = array{ + * policyKey: string, + * scope: 'system'|'global', + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * visibleToChild: bool, + * allowedValues: list, + * } + * @psalm-type LibresignSystemPolicyResponse = array{ + * policy: LibresignSystemPolicyState, + * } + * @psalm-type LibresignUserPolicyState = array{ + * policyKey: string, + * scope: 'user_policy', + * targetId: string, + * value: null|LibresignEffectivePolicyValue, + * allowChildOverride: bool, + * } + * @psalm-type LibresignUserPolicyResponse = array{ + * policy: LibresignUserPolicyState, + * } + * @psalm-type LibresignGroupPolicyWriteResponse = LibresignMessageResponse&LibresignGroupPolicyResponse + * @psalm-type LibresignSystemPolicyWriteResponse = LibresignMessageResponse&LibresignEffectivePolicyResponse + * @psalm-type LibresignUserPolicyWriteResponse = LibresignMessageResponse&LibresignUserPolicyResponse + * @psalm-type LibresignPolicySnapshotEntry = array{ + * effectiveValue: string, + * sourceScope: string, + * } + * @psalm-type LibresignPolicySnapshotNumericEntry = array{ + * effectiveValue: int, + * sourceScope: string, + * } + * @psalm-type LibresignValidatePolicySnapshot = array{ + * docmdp?: LibresignPolicySnapshotNumericEntry, + * 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, * } diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index f721938150..30eed63ec9 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -36,6 +36,7 @@ use OCP\Files\IMimeTypeDetector; use OCP\Files\IRootFolder; use OCP\Files\NotFoundException; +use OCP\Group\ISubAdmin; use OCP\Http\Client\IClientService; use OCP\IAppConfig; use OCP\IGroupManager; @@ -73,6 +74,7 @@ public function __construct( private IURLGenerator $urlGenerator, private Pkcs12Handler $pkcs12Handler, private IGroupManager $groupManager, + private ISubAdmin $subAdmin, private IdDocsService $idDocsService, private SignerElementsService $signerElementsService, private UserElementMapper $userElementMapper, @@ -207,8 +209,11 @@ public function getConfig(?IUser $user = null): array { $info['files_list_signer_identify_tab'] = $this->getUserConfigByKey('files_list_signer_identify_tab', $user); $info['files_list_sorting_mode'] = $this->getUserConfigByKey('files_list_sorting_mode', $user) ?: 'name'; $info['files_list_sorting_direction'] = $this->getUserConfigByKey('files_list_sorting_direction', $user) ?: 'asc'; + $info['policy_workbench_catalog_compact_view'] = $this->getUserConfigByKey('policy_workbench_catalog_compact_view', $user) === '1'; + $info['can_manage_group_policies'] = $user !== null + && ($this->groupManager->isAdmin($user->getUID()) || $this->subAdmin->isSubAdmin($user)); - return array_filter($info); + return array_filter($info, static fn (mixed $value): bool => $value !== null && $value !== ''); } public function getConfigFilters(?IUser $user = null): array { diff --git a/lib/Service/DocMdp/ConfigService.php b/lib/Service/DocMdp/ConfigService.php index 39e0689d99..d25c8b7e72 100644 --- a/lib/Service/DocMdp/ConfigService.php +++ b/lib/Service/DocMdp/ConfigService.php @@ -20,6 +20,7 @@ */ class ConfigService { private const CONFIG_KEY_LEVEL = 'docmdp_level'; + private const DEFAULT_LEVEL = DocMdpLevel::CERTIFIED_FORM_FILLING; public function __construct( private IAppConfig $appConfig, @@ -43,8 +44,8 @@ public function setEnabled(bool $enabled): void { } public function getLevel(): DocMdpLevel { - $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, DocMdpLevel::CERTIFIED_FORM_FILLING->value); - return DocMdpLevel::tryFrom($level) ?? DocMdpLevel::CERTIFIED_FORM_FILLING; + $level = $this->appConfig->getValueInt(Application::APP_ID, self::CONFIG_KEY_LEVEL, self::DEFAULT_LEVEL->value); + return DocMdpLevel::tryFrom($level) ?? self::DEFAULT_LEVEL; } public function setLevel(DocMdpLevel $level): void { @@ -71,4 +72,5 @@ private function getAvailableLevels(): array { DocMdpLevel::cases() ); } + } diff --git a/lib/Service/FolderService.php b/lib/Service/FolderService.php index 626193bdaf..a263fbe54d 100644 --- a/lib/Service/FolderService.php +++ b/lib/Service/FolderService.php @@ -69,14 +69,12 @@ public function getUserRootFolder(): Folder { public function getFolder(): Folder { $path = $this->getLibreSignDefaultPath(); $containerFolder = $this->getContainerFolder(); + try { - /** @var Folder $folder */ - $folder = $containerFolder->get($path); + return $this->ensureFolderPathExists($containerFolder, $path); } catch (NotFoundException) { - /** @var Folder $folder */ - $folder = $containerFolder->newFolder($path); + return $this->ensureFolderPathExists($this->getAppDataContainerFolder(), $path); } - return $folder; } /** @@ -108,17 +106,42 @@ public function getFileByNodeId(int $nodeId): File { protected function getContainerFolder(): Folder { if ($this->getUserId() && !$this->groupManager->isInGroup($this->getUserId(), 'guest_app')) { - $containerFolder = $this->root->getUserFolder($this->getUserId()); - if ($containerFolder->isUpdateable()) { - return $containerFolder; + try { + $containerFolder = $this->root->getUserFolder($this->getUserId()); + if ($containerFolder->isUpdateable()) { + return $containerFolder; + } + } catch (NotFoundException) { + // Users provisioned in tests may not have a home folder yet. } } + return $this->getAppDataContainerFolder(); + } + + private function getAppDataContainerFolder(): Folder { $containerFolder = $this->appData->getFolder('/'); $reflection = new \ReflectionClass($containerFolder); $reflectionProperty = $reflection->getProperty('folder'); return $reflectionProperty->getValue($containerFolder); } + private function ensureFolderPathExists(Folder $folder, string $path): Folder { + $cleanPath = trim($path, '/'); + + if ($cleanPath === '') { + return $folder; + } + + $segments = array_filter(explode('/', $cleanPath), static fn (string $segment): bool => $segment !== ''); + $currentFolder = $folder; + + foreach ($segments as $segment) { + $currentFolder = $currentFolder->getOrCreateFolder($segment); + } + + return $currentFolder; + } + private function getLibreSignDefaultPath(): string { if (!$this->userId) { return 'unauthenticated'; diff --git a/lib/Service/FooterService.php b/lib/Service/FooterService.php index abbe7055d0..e1edb4be8c 100644 --- a/lib/Service/FooterService.php +++ b/lib/Service/FooterService.php @@ -64,7 +64,7 @@ public function renderPreviewPdf(string $template = '', int $width = 595, int $h 'signed' => date('c'), ], ]) - ->getFooter([['w' => $width, 'h' => $height]]); + ->getFooter([['w' => $width, 'h' => $height]], true); } public function getTemplateVariablesMetadata(): array { diff --git a/lib/Service/IdentifyMethod/Account.php b/lib/Service/IdentifyMethod/Account.php index 20cbb84514..cd28a7081e 100644 --- a/lib/Service/IdentifyMethod/Account.php +++ b/lib/Service/IdentifyMethod/Account.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Service\IdentifyMethod; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Helper\JSActions; @@ -161,10 +160,7 @@ public function getSettings(): array { } private function isEnabledByDefault(): bool { - $config = $this->identifyService->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []); - if (json_last_error() !== JSON_ERROR_NONE || !is_array($config)) { - return true; - } + $config = $this->identifyService->getSavedSettings(); // Remove not enabled $config = array_filter($config, fn ($i) => isset($i['enabled']) && $i['enabled'] ? true : false); diff --git a/lib/Service/IdentifyMethod/IdentifyService.php b/lib/Service/IdentifyMethod/IdentifyService.php index 2329a59227..2693acb489 100644 --- a/lib/Service/IdentifyMethod/IdentifyService.php +++ b/lib/Service/IdentifyMethod/IdentifyService.php @@ -17,6 +17,7 @@ use OCA\Libresign\Service\SessionService; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; +use OCP\Exceptions\AppConfigTypeConflictException; use OCP\Files\IRootFolder; use OCP\IAppConfig; use OCP\IL10N; @@ -26,7 +27,7 @@ use Psr\Log\LoggerInterface; class IdentifyService { - private array $savedSettings = []; + private ?array $savedSettings = null; public function __construct( private IdentifyMethodMapper $identifyMethodMapper, private SessionService $sessionService, @@ -126,10 +127,32 @@ private function refreshIdFromDatabaseIfNecessary(IdentifyMethod $identifyMethod } public function getSavedSettings(): array { - if (!empty($this->savedSettings)) { + if ($this->savedSettings !== null) { return $this->savedSettings; } - return $this->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []); + + $this->getAppConfig()->clearCache(true); + try { + $this->savedSettings = $this->getAppConfig()->getValueArray(Application::APP_ID, 'identify_methods', []); + } catch (AppConfigTypeConflictException) { + // Key was stored with wrong type (e.g., string written by the provisioning API). + // Normalize it: read the raw string, delete the key, and re-store as array type. + try { + $raw = $this->getAppConfig()->getValueString(Application::APP_ID, 'identify_methods', ''); + } catch (AppConfigTypeConflictException) { + $raw = ''; + } + $this->getAppConfig()->deleteKey(Application::APP_ID, 'identify_methods'); + $decoded = json_decode($raw, true); + if (is_array($decoded)) { + $this->getAppConfig()->setValueArray(Application::APP_ID, 'identify_methods', $decoded); + $this->savedSettings = $decoded; + } else { + $this->savedSettings = []; + } + } + + return $this->savedSettings; } public function getEventDispatcher(): IEventDispatcher { diff --git a/lib/Service/Policy/Contract/IPolicyDefinition.php b/lib/Service/Policy/Contract/IPolicyDefinition.php new file mode 100644 index 0000000000..b16fd9c7c1 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicyDefinition.php @@ -0,0 +1,30 @@ + */ + public function allowedValues(PolicyContext $context): array; + + public function defaultSystemValue(): mixed; +} 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/Contract/IPolicySource.php b/lib/Service/Policy/Contract/IPolicySource.php new file mode 100644 index 0000000000..5151d89f35 --- /dev/null +++ b/lib/Service/Policy/Contract/IPolicySource.php @@ -0,0 +1,70 @@ + */ + public function loadGroupPolicies(string $policyKey, PolicyContext $context): array; + + /** @return list */ + public function loadCirclePolicies(string $policyKey, PolicyContext $context): array; + + public function loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer; + + public function loadUserPreference(string $policyKey, PolicyContext $context): ?PolicyLayer; + + /** + * Bulk-load group policy layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array> keyed by policyKey + */ + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array; + + /** + * Bulk-load user preference layers for all known policy keys at once. + * + * @param list $policyKeys + * @return array keyed by policyKey + */ + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array; + + public function loadRequestOverride(string $policyKey, PolicyContext $context): ?PolicyLayer; + + 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 loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer; + + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void; + + public function clearUserPolicy(string $policyKey, PolicyContext $context): void; + + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void; + + public function clearUserPreference(string $policyKey, PolicyContext $context): void; +} diff --git a/lib/Service/Policy/Model/PolicyContext.php b/lib/Service/Policy/Model/PolicyContext.php new file mode 100644 index 0000000000..0e7d56f818 --- /dev/null +++ b/lib/Service/Policy/Model/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; + } +} diff --git a/lib/Service/Policy/Model/PolicyLayer.php b/lib/Service/Policy/Model/PolicyLayer.php new file mode 100644 index 0000000000..16e8cdc17b --- /dev/null +++ b/lib/Service/Policy/Model/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; + } +} diff --git a/lib/Service/Policy/Model/PolicySpec.php b/lib/Service/Policy/Model/PolicySpec.php new file mode 100644 index 0000000000..79b80e44cb --- /dev/null +++ b/lib/Service/Policy/Model/PolicySpec.php @@ -0,0 +1,102 @@ +|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, + private string $resolutionMode = self::RESOLUTION_MODE_RESOLVED, + ) { + $this->allowedValuesResolver = $allowedValues; + $this->normalizer = $normalizer; + $this->validator = $validator; + } + + #[\Override] + public function key(): string { + return $this->key; + } + + #[\Override] + public function resolutionMode(): string { + return $this->resolutionMode; + } + + #[\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/Model/ResolvedPolicy.php b/lib/Service/Policy/Model/ResolvedPolicy.php new file mode 100644 index 0000000000..e934c20870 --- /dev/null +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -0,0 +1,131 @@ + */ + 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; + } + + /** @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(), + ]; + } +} diff --git a/lib/Service/Policy/PolicyService.php b/lib/Service/Policy/PolicyService.php new file mode 100644 index 0000000000..2b165e1780 --- /dev/null +++ b/lib/Service/Policy/PolicyService.php @@ -0,0 +1,192 @@ +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), + ); + } + + /** @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); + } + + public function getSystemPolicy(string|\BackedEnum $policyKey): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadSystemPolicy($definition->key()); + } + + public function getUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $definition = $this->registry->get($policyKey); + return $this->source->loadUserPolicyConfig($definition->key(), $userId); + } + + public function saveSystem(string|\BackedEnum $policyKey, mixed $value, bool $allowChildOverride = false): 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, $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); + $this->assertCurrentActorCanManageGroupOverride($definition->key()); + $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->assertCurrentActorCanManageGroupOverride($definition->key()); + $this->source->clearGroupPolicy($definition->key(), $groupId); + + return $this->source->loadGroupPolicyConfig($definition->key(), $groupId); + } + + private function assertCurrentActorCanManageGroupOverride(string $policyKey): void { + if ($this->contextFactory->isCurrentActorSystemAdmin()) { + return; + } + + $systemPolicy = $this->source->loadSystemPolicy($policyKey); + if ($systemPolicy !== null && !$systemPolicy->isAllowChildOverride()) { + throw new \DomainException($this->l10n->t('Lower-level overrides are not allowed for this policy')); + } + } + + 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($this->l10n->t('Saving a user preference is not allowed for {policyKey}', [ + 'policyKey' => $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); + } + + public function saveUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId, mixed $value, bool $allowChildOverride): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $definition->validateValue($normalizedValue, $context); + $this->source->saveUserPolicy($definition->key(), $context, $normalizedValue, $allowChildOverride); + + return $this->source->loadUserPolicy($definition->key(), $context) + ?? (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true); + } + + public function clearUserPolicyForUserId(string|\BackedEnum $policyKey, string $userId): ?PolicyLayer { + $context = $this->contextFactory->forUserId($userId); + $definition = $this->registry->get($policyKey); + $this->source->clearUserPolicy($definition->key(), $context); + + return $this->source->loadUserPolicy($definition->key(), $context); + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function getRuleCounts(array $groupIds, array $userIds): array { + return $this->source->loadRuleCounts($groupIds, $userIds); + } + + /** @return array */ + public function getAllRuleCounts(): array { + return $this->source->loadAllRuleCounts(); + } +} diff --git a/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php new file mode 100644 index 0000000000..c40bea3928 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/DocMdpPolicy.php @@ -0,0 +1,63 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: DocMdpLevel::NOT_CERTIFIED->value, + allowedValues: [ + DocMdpLevel::NOT_CERTIFIED->value, + DocMdpLevel::CERTIFIED_NO_CHANGES_ALLOWED->value, + DocMdpLevel::CERTIFIED_FORM_FILLING->value, + DocMdpLevel::CERTIFIED_FORM_FILLING_AND_ANNOTATIONS->value, + ], + normalizer: static function (mixed $rawValue): mixed { + if ($rawValue instanceof DocMdpLevel) { + return $rawValue->value; + } + + if (is_int($rawValue)) { + return $rawValue; + } + + return $rawValue; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php new file mode 100644 index 0000000000..9af6e112de --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -0,0 +1,59 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: FooterPolicyValue::encode(FooterPolicyValue::defaults()), + allowedValues: static fn (): array => [], + normalizer: static function (mixed $rawValue): mixed { + return FooterPolicyValue::encode(FooterPolicyValue::normalize($rawValue)); + }, + validator: static function (mixed $value): void { + if (!is_string($value) || trim($value) === '') { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + ), + 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/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php new file mode 100644 index 0000000000..48d66c295b --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php @@ -0,0 +1,94 @@ + true, + 'writeQrcodeOnFooter' => true, + 'validationSite' => '', + 'customizeFooterTemplate' => false, + ]; + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool} */ + public static function normalize(mixed $rawValue): array { + $defaults = self::defaults(); + + if (is_array($rawValue)) { + return [ + 'enabled' => self::toBool($rawValue['enabled'] ?? $rawValue['addFooter'] ?? $defaults['enabled']), + 'writeQrcodeOnFooter' => self::toBool($rawValue['writeQrcodeOnFooter'] ?? $rawValue['write_qrcode_on_footer'] ?? $defaults['writeQrcodeOnFooter']), + 'validationSite' => self::toString($rawValue['validationSite'] ?? $rawValue['validation_site'] ?? $defaults['validationSite']), + 'customizeFooterTemplate' => self::toBool($rawValue['customizeFooterTemplate'] ?? $rawValue['customize_footer_template'] ?? $defaults['customizeFooterTemplate']), + ]; + } + + if (is_bool($rawValue) || is_int($rawValue)) { + $defaults['enabled'] = self::toBool($rawValue); + return $defaults; + } + + if (is_string($rawValue)) { + $trimmedValue = trim($rawValue); + if ($trimmedValue === '') { + return $defaults; + } + + $decoded = json_decode($trimmedValue, true); + if (is_array($decoded)) { + return self::normalize($decoded); + } + + $defaults['enabled'] = self::toBool($trimmedValue); + return $defaults; + } + + return $defaults; + } + + public static function encode(array $value): string { + return (string)json_encode(self::normalize($value), JSON_UNESCAPED_SLASHES); + } + + public static function isEnabled(mixed $rawValue): bool { + return self::normalize($rawValue)['enabled']; + } + + public static function isQrCodeEnabled(mixed $rawValue): bool { + $normalized = self::normalize($rawValue); + return $normalized['enabled'] && $normalized['writeQrcodeOnFooter']; + } + + private static function toBool(mixed $rawValue): bool { + if (is_bool($rawValue)) { + return $rawValue; + } + + if (is_int($rawValue)) { + return $rawValue === 1; + } + + if (is_string($rawValue)) { + return in_array(strtolower(trim($rawValue)), ['1', 'true', 'yes', 'on'], true); + } + + return (bool)$rawValue; + } + + private static function toString(mixed $rawValue): string { + if (!is_scalar($rawValue)) { + return ''; + } + + return trim((string)$rawValue); + } +} diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..187f9e9902 --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,22 @@ + */ + public const BY_KEY = [ + FooterPolicy::KEY => FooterPolicy::class, + DocMdpPolicy::KEY => DocMdpPolicy::class, + 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..9976c158b9 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/SignatureFlowPolicy.php @@ -0,0 +1,59 @@ +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; + } + + return $rawValue; + }, + appConfigKey: self::SYSTEM_APP_CONFIG_KEY, + resolutionMode: PolicySpec::RESOLUTION_MODE_VALUE_CHOICE, + ), + 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/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php new file mode 100644 index 0000000000..5dd39e4956 --- /dev/null +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -0,0 +1,386 @@ +resolveCore( + $definition, + $context, + $this->source->loadGroupPolicies($definition->key(), $context), + $this->source->loadUserPolicy($definition->key(), $context), + $this->source->loadUserPreference($definition->key(), $context), + ); + } + + /** + * @param list $groupLayers Pre-fetched group layers (avoids repeat DB calls in bulk resolution) + */ + private function resolveCore( + IPolicyDefinition $definition, + PolicyContext $context, + array $groupLayers, + ?PolicyLayer $userPolicy, + ?PolicyLayer $userPreference, + ): ResolvedPolicy { + $policyKey = $definition->key(); + $resolved = (new ResolvedPolicy()) + ->setPolicyKey($policyKey) + ->setAllowedValues($definition->allowedValues($context)); + + $systemLayer = $this->source->loadSystemPolicy($policyKey); + + $currentValue = $definition->defaultSystemValue(); + $currentSourceScope = 'system'; + $currentBlockedBy = null; + $canOverrideBelow = false; + $visible = true; + + if ($systemLayer !== null) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $systemLayer, + $context, + $currentValue, + $currentSourceScope, + true, + $visible, + ); + } + + if ($definition->resolutionMode() === 'value_choice') { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyValueChoiceGroupLayers( + $definition, + $resolved, + $groupLayers, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } else { + foreach ($groupLayers as $layer) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $layer, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + } + + if ($userPolicy !== null) { + [$currentValue, $currentSourceScope, $canOverrideBelow, $visible] = $this->applyLayer( + $definition, + $resolved, + $userPolicy, + $context, + $currentValue, + $currentSourceScope, + $canOverrideBelow, + $visible, + ); + } + + if ($userPreference !== null) { + if ($this->canApplyLowerLayer($definition, $resolved, $userPreference, $canOverrideBelow, $visible, $context)) { + $currentValue = $definition->normalizeValue($userPreference->getValue()); + $definition->validateValue($currentValue, $context); + $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, $context)) { + $currentValue = $definition->normalizeValue($requestOverride->getValue()); + $definition->validateValue($currentValue, $context); + $currentSourceScope = $requestOverride->getScope(); + } elseif ($currentBlockedBy === null) { + $currentBlockedBy = $currentSourceScope; + } + } + + $resolved + ->setEffectiveValue($currentValue) + ->setSourceScope($currentSourceScope) + ->setVisible($visible) + ->setEditableByCurrentActor($visible && $this->canManagePolicyAtCurrentScope($context)) + ->setCanSaveAsUserDefault($visible && $canOverrideBelow) + ->setCanUseAsRequestOverride($visible && $canOverrideBelow) + ->setBlockedBy($currentBlockedBy); + + return $resolved; + } + + /** + * @param list $layers + * @return array{0: mixed, 1: string, 2: bool, 3: bool} + */ + private function applyValueChoiceGroupLayers( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + array $layers, + PolicyContext $context, + mixed $currentValue, + string $currentSourceScope, + bool $canOverrideBelow, + bool $visible, + ): array { + if ($layers === [] || !$visible || !$canOverrideBelow) { + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + + $upstreamAllowedValues = $resolved->getAllowedValues(); + $combinedChoices = []; + $groupDefaultValues = []; + $hasVisibleLayer = false; + + foreach ($layers as $layer) { + if (!$layer->isVisibleToChild()) { + continue; + } + + $hasVisibleLayer = true; + $layerChoices = $this->resolveValueChoiceLayerChoices($definition, $layer, $upstreamAllowedValues, $context); + $combinedChoices = $this->mergeUnionAllowedValues( + $definition->allowedValues($context), + $combinedChoices, + $layerChoices, + ); + + $normalizedDefault = $definition->normalizeValue($layer->getValue()); + if ($layer->getValue() !== null && in_array($normalizedDefault, $combinedChoices, true) && !in_array($normalizedDefault, $groupDefaultValues, true)) { + $groupDefaultValues[] = $normalizedDefault; + } + } + + if (!$hasVisibleLayer || $combinedChoices === []) { + return [$currentValue, $currentSourceScope, false, $visible && $hasVisibleLayer]; + } + + $resolved->setAllowedValues($combinedChoices); + + return [ + $this->pickValueChoiceDefault($definition, $currentValue, $combinedChoices, $groupDefaultValues, $context), + 'group', + count($combinedChoices) > 1, + true, + ]; + } + + #[\Override] + /** @param list $definitions */ + public function resolveMany(array $definitions, PolicyContext $context): array { + $validDefinitions = array_filter( + $definitions, + static fn (mixed $d): bool => $d instanceof IPolicyDefinition, + ); + + $policyKeys = array_map( + static fn (IPolicyDefinition $d): string => $d->key(), + $validDefinitions, + ); + + $allGroupLayers = $this->source->loadAllGroupPolicies($policyKeys, $context); + $allUserPolicies = $this->source->loadAllUserPolicies($policyKeys, $context); + $allUserPrefs = $this->source->loadAllUserPreferences($policyKeys, $context); + + $resolved = []; + foreach ($validDefinitions as $definition) { + $key = $definition->key(); + $resolved[$key] = $this->resolveCore( + $definition, + $context, + $allGroupLayers[$key] ?? [], + $allUserPolicies[$key] ?? null, + $allUserPrefs[$key] ?? null, + ); + } + return $resolved; + } + + private function applyLayer( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + PolicyContext $context, + 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 && $canOverrideBelow) { + $currentValue = $definition->normalizeValue($layer->getValue()); + $definition->validateValue($currentValue, $context); + $currentSourceScope = $layer->getScope(); + } + + $canOverrideBelow = $canOverrideBelow && $layer->isAllowChildOverride(); + + return [$currentValue, $currentSourceScope, $canOverrideBelow, $visible]; + } + + private function canApplyLowerLayer( + IPolicyDefinition $definition, + ResolvedPolicy $resolved, + PolicyLayer $layer, + bool $canOverrideBelow, + bool $visible, + PolicyContext $context, + ): 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, $context); + return true; + } + + private function canManagePolicyAtCurrentScope(PolicyContext $context): bool { + $actorCapabilities = $context->getActorCapabilities(); + + return ($actorCapabilities['canManageSystemPolicies'] ?? false) === true + || ($actorCapabilities['canManageGroupPolicies'] ?? false) === 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)); + } + + /** + * @param list $upstreamAllowedValues + * @return list + */ + private function resolveValueChoiceLayerChoices( + IPolicyDefinition $definition, + PolicyLayer $layer, + array $upstreamAllowedValues, + PolicyContext $context, + ): array { + if ($layer->isAllowChildOverride()) { + $choices = $layer->getAllowedValues() === [] + ? $upstreamAllowedValues + : array_values(array_intersect($upstreamAllowedValues, $layer->getAllowedValues())); + + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + return array_values(array_filter( + $choices, + static fn (mixed $choice): bool => $choice !== $defaultValue, + )); + } + + if ($layer->getValue() === null) { + return []; + } + + $value = $definition->normalizeValue($layer->getValue()); + if ($upstreamAllowedValues !== [] && !in_array($value, $upstreamAllowedValues, true)) { + return []; + } + + $definition->validateValue($value, $context); + return [$value]; + } + + /** + * @param list $canonicalOrder + * @param list $currentValues + * @param list $newValues + * @return list + */ + private function mergeUnionAllowedValues(array $canonicalOrder, array $currentValues, array $newValues): array { + $merged = []; + foreach ($canonicalOrder as $candidate) { + if ((in_array($candidate, $currentValues, true) || in_array($candidate, $newValues, true)) && !in_array($candidate, $merged, true)) { + $merged[] = $candidate; + } + } + + foreach ([$currentValues, $newValues] as $values) { + foreach ($values as $candidate) { + if (!in_array($candidate, $merged, true)) { + $merged[] = $candidate; + } + } + } + + return $merged; + } + + /** + * @param list $allowedValues + * @param list $groupDefaultValues + */ + private function pickValueChoiceDefault( + IPolicyDefinition $definition, + mixed $currentValue, + array $allowedValues, + array $groupDefaultValues, + PolicyContext $context, + ): mixed { + $normalizedCurrentValue = $definition->normalizeValue($currentValue); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + + if (count($groupDefaultValues) === 1 && in_array($groupDefaultValues[0], $allowedValues, true)) { + return $groupDefaultValues[0]; + } + + if ($normalizedCurrentValue !== $defaultValue && in_array($normalizedCurrentValue, $allowedValues, true)) { + return $normalizedCurrentValue; + } + + $orderedAllowedValues = $this->mergeUnionAllowedValues($definition->allowedValues($context), [], $allowedValues); + return $orderedAllowedValues[0] ?? $normalizedCurrentValue; + } +} diff --git a/lib/Service/Policy/Runtime/PolicyContextFactory.php b/lib/Service/Policy/Runtime/PolicyContextFactory.php new file mode 100644 index 0000000000..d5d1cb2259 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,94 @@ + $requestOverrides */ + public function forCurrentUser(array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + $user = $this->userSession->getUser(); + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext, $user); + } + + public function isCurrentActorSystemAdmin(): bool { + $user = $this->userSession->getUser(); + if ($user === null) { + return false; + } + + return $this->groupManager->isAdmin($user->getUID()); + } + + /** @param array $requestOverrides */ + public function forUser(?IUser $user, array $requestOverrides = [], ?array $activeContext = null): PolicyContext { + return $this->build($user?->getUID(), $user, $requestOverrides, $activeContext, $this->userSession->getUser()); + } + + /** @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, $this->userSession->getUser()); + } + + /** @param array $requestOverrides */ + private function build(?string $userId, ?IUser $user, array $requestOverrides = [], ?array $activeContext = null, ?IUser $currentActor = null): PolicyContext { + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveContext($activeContext) + ->setActorCapabilities($this->resolveActorCapabilities($currentActor)); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $context; + } + + /** @return array */ + private function resolveActorCapabilities(?IUser $currentActor): array { + if (!$currentActor instanceof IUser) { + return [ + 'canManageSystemPolicies' => false, + 'canManageGroupPolicies' => false, + ]; + } + + $userId = $currentActor->getUID(); + $canManageSystemPolicies = $this->groupManager->isAdmin($userId) === true; + + return [ + 'canManageSystemPolicies' => $canManageSystemPolicies, + 'canManageGroupPolicies' => $canManageSystemPolicies || $this->subAdmin->isSubAdmin($currentActor) === true, + ]; + } +} 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..bcd44565e5 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,816 @@ +registry->get($policyKey); + $defaultValue = $definition->normalizeValue($definition->defaultSystemValue()); + $hasExplicitSystemValue = $this->appConfig->hasAppKey($definition->getAppConfigKey()); + $storedValue = $hasExplicitSystemValue + ? $this->readSystemValue($definition->getAppConfigKey(), $defaultValue) + : null; + $value = $hasExplicitSystemValue + ? $definition->normalizeValue($storedValue) + : $defaultValue; + + $layer = (new PolicyLayer()) + ->setScope($hasExplicitSystemValue ? 'global' : 'system') + ->setValue($value) + ->setVisibleToChild(true); + + if (!$hasExplicitSystemValue) { + return $layer->setAllowChildOverride(true); + } + + if ($value === $defaultValue) { + $allowChildOverride = $this->appConfig->getAppValueString( + $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()), + '0', + ) === '1'; + + if ($allowChildOverride) { + // Explicitly persisted default value ("let users choose") + return $layer + ->setAllowChildOverride(true) + ->setAllowedValues([]); + } + + return $layer->setAllowChildOverride(true); + } + + $allowChildOverride = $this->appConfig->getAppValueString( + $this->getSystemAllowOverrideConfigKey($definition->getAppConfigKey()), + '0', + ) === '1'; + + return $layer + ->setAllowChildOverride($allowChildOverride) + ->setAllowedValues($allowChildOverride ? [] : [$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->getDecodedPolicyJson()[$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 loadUserPolicy(string $policyKey, PolicyContext $context): ?PolicyLayer { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return null; + } + + return $this->loadUserPolicyConfig($policyKey, $userId); + } + + #[\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($this->deserializeStoredValue($value))); + } + + #[\Override] + public function loadUserPolicyConfig(string $policyKey, string $userId): ?PolicyLayer { + if ($userId === '') { + return null; + } + + $definition = $this->registry->get($policyKey); + $storedPayload = $this->appConfig->getUserValue($userId, $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), ''); + if ($storedPayload === '') { + return null; + } + + $decodedPayload = $this->deserializeStoredUserPolicyPayload($storedPayload); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload)) { + return null; + } + + return (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($definition->normalizeValue($decodedPayload['value'])) + ->setAllowChildOverride((bool)($decodedPayload['allowChildOverride'] ?? false)) + ->setVisibleToChild(true) + ->setAllowedValues(((bool)($decodedPayload['allowChildOverride'] ?? false)) ? [] : [$definition->normalizeValue($decodedPayload['value'])]); + } + + /** + * @param list $policyKeys + * @return array> + */ + #[\Override] + public function loadAllGroupPolicies(array $policyKeys, PolicyContext $context): array { + /** @var array> $result */ + $result = array_fill_keys($policyKeys, []); + + $groupIds = $this->resolveGroupIds($context); + if ($groupIds === []) { + return $result; + } + + $bindingsByTargetId = []; + foreach ($this->bindingMapper->findByTargets('group', $groupIds) as $binding) { + $bindingsByTargetId[$binding->getTargetId()] = $binding; + } + + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $b): int => $b->getPermissionSetId(), + $bindingsByTargetId, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupIds as $groupId) { + $binding = $bindingsByTargetId[$groupId] ?? null; + if (!$binding instanceof PermissionSetBinding) { + continue; + } + + $permissionSet = $permissionSetsById[$binding->getPermissionSetId()] ?? null; + if (!$permissionSet instanceof PermissionSet) { + continue; + } + + $policyJson = $permissionSet->getDecodedPolicyJson(); + foreach ($policyKeys as $policyKey) { + $policyConfig = $policyJson[$policyKey] ?? null; + if (!is_array($policyConfig)) { + continue; + } + + $result[$policyKey][] = (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 $result; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPolicies(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByAssignedKey = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByAssignedKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $decodedPayload = $this->deserializeStoredUserPolicyPayload($row['configvalue']); + if (!is_array($decodedPayload) || !array_key_exists('value', $decodedPayload)) { + continue; + } + + $normalizedValue = $definition->normalizeValue($decodedPayload['value']); + $allowChildOverride = (bool)($decodedPayload['allowChildOverride'] ?? false); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user_policy') + ->setValue($normalizedValue) + ->setAllowChildOverride($allowChildOverride) + ->setVisibleToChild(true) + ->setAllowedValues($allowChildOverride ? [] : [$normalizedValue]); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + /** + * @param list $policyKeys + * @return array + */ + #[\Override] + public function loadAllUserPreferences(array $policyKeys, PolicyContext $context): array { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return []; + } + + $userPreferenceKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPreferenceKeyByPolicy[$policyKey] = $this->registry->get($policyKey)->getUserPreferenceKey(); + } + $policyKeyByPreferenceKey = array_flip($userPreferenceKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey', 'configvalue') + ->from('preferences') + ->where($query->expr()->eq('userid', $query->createNamedParameter($userId))) + ->andWhere($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPreferenceKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))); + + $result = $query->executeQuery(); + $layers = []; + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByPreferenceKey[$row['configkey']] ?? null; + if ($policyKey === null) { + continue; + } + + $definition = $this->registry->get($policyKey); + $layers[$policyKey] = (new PolicyLayer()) + ->setScope('user') + ->setValue($definition->normalizeValue($this->deserializeStoredValue($row['configvalue']))); + } + } finally { + $result->closeCursor(); + } + + return $layers; + } + + #[\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 loadGroupPolicyConfig(string $policyKey, string $groupId): ?PolicyLayer { + $permissionSet = $this->findPermissionSetByGroupId($groupId); + if (!$permissionSet instanceof PermissionSet) { + return null; + } + + $policyConfig = $permissionSet->getDecodedPolicyJson()[$policyKey] ?? null; + if (!is_array($policyConfig)) { + return null; + } + + return $this->createGroupPolicyLayer($policyConfig); + } + + /** + * @param list $groupIds + * @param list $userIds + * @return array + */ + public function loadRuleCounts(array $groupIds, array $userIds): array { + $policyKeys = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = [ + 'groupCount' => 0, + 'userCount' => 0, + ]; + } + + $groupIds = array_values(array_unique(array_filter($groupIds, static fn (string $groupId): bool => $groupId !== ''))); + if ($groupIds !== []) { + $groupBindings = $this->bindingMapper->findByTargets('group', $groupIds); + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $userIds = array_values(array_unique(array_filter($userIds, static fn (string $userId): bool => $userId !== ''))); + if ($userIds === []) { + return $counts; + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('userid', $query->createNamedParameter($userIds, IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + + return $counts; + } + + /** + * Count group/user rules for ALL known targets (no ID filter). Suitable for system admins. + * + * @return array + */ + public function loadAllRuleCounts(): array { + $policyKeys = array_keys(PolicyProviders::BY_KEY); + /** @var array $counts */ + $counts = []; + foreach ($policyKeys as $policyKey) { + $counts[$policyKey] = ['groupCount' => 0, 'userCount' => 0]; + } + + $groupBindings = $this->bindingMapper->findByTargetType('group'); + if ($groupBindings !== []) { + $permissionSetIds = array_values(array_unique(array_map( + static fn (PermissionSetBinding $binding): int => $binding->getPermissionSetId(), + $groupBindings, + ))); + + $permissionSetsById = []; + foreach ($this->permissionSetMapper->findByIds($permissionSetIds) as $permissionSet) { + $permissionSetsById[$permissionSet->getId()] = $permissionSet; + } + + foreach ($groupBindings as $binding) { + $policyJson = $permissionSetsById[$binding->getPermissionSetId()]?->getDecodedPolicyJson() ?? []; + foreach ($policyJson as $policyKey => $policyConfig) { + if (!isset($counts[$policyKey]) || !is_array($policyConfig)) { + continue; + } + + if (!array_key_exists('defaultValue', $policyConfig) || $policyConfig['defaultValue'] === null) { + continue; + } + + $counts[$policyKey]['groupCount']++; + } + } + } + + $userPolicyKeyByPolicy = []; + foreach ($policyKeys as $policyKey) { + $userPolicyKeyByPolicy[$policyKey] = $this->getAssignedUserPolicyKey($this->registry->get($policyKey)->getUserPreferenceKey()); + } + $policyKeyByUserPreference = array_flip($userPolicyKeyByPolicy); + + $query = $this->db->getQueryBuilder(); + $query->select('configkey') + ->selectAlias($query->createFunction('COUNT(DISTINCT userid)'), 'user_count') + ->from('preferences') + ->where($query->expr()->eq('appid', $query->createNamedParameter(Application::APP_ID))) + ->andWhere($query->expr()->in('configkey', $query->createNamedParameter(array_values($userPolicyKeyByPolicy), IQueryBuilder::PARAM_STR_ARRAY))) + ->andWhere($query->expr()->neq('configvalue', $query->createNamedParameter(''))) + ->groupBy('configkey'); + + $result = $query->executeQuery(); + try { + while ($row = $result->fetchAssociative()) { + $policyKey = $policyKeyByUserPreference[$row['configkey']] ?? null; + if (!is_string($policyKey) || !isset($counts[$policyKey])) { + continue; + } + + $counts[$policyKey]['userCount'] = (int)($row['user_count'] ?? 0); + } + } finally { + $result->closeCursor(); + } + + return $counts; + } + + #[\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) { + if ($allowChildOverride) { + $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, '1'); + return; + } + + $this->appConfig->deleteAppValue($definition->getAppConfigKey()); + $this->appConfig->deleteAppValue($allowOverrideConfigKey); + return; + } + + $this->writeSystemValue($definition->getAppConfigKey(), $normalizedValue); + $this->appConfig->setAppValueString($allowOverrideConfigKey, $allowChildOverride ? '1' : '0'); + } + + private function readSystemValue(string $key, mixed $defaultValue): mixed { + try { + if (is_int($defaultValue)) { + return $this->appConfig->getAppValueInt($key, $defaultValue); + } + + if (is_bool($defaultValue)) { + return $this->appConfig->getAppValueBool($key, $defaultValue); + } + + if (is_float($defaultValue)) { + return $this->appConfig->getAppValueFloat($key, $defaultValue); + } + + if (is_array($defaultValue)) { + return $this->appConfig->getAppValueArray($key, $defaultValue); + } + + return $this->appConfig->getAppValueString($key, (string)$defaultValue); + } catch (AppConfigTypeConflictException $exception) { + if (is_string($defaultValue)) { + return $this->appConfig->getAppValueBool($key, in_array(strtolower(trim($defaultValue)), ['1', 'true', 'yes', 'on'], true)); + } + + if (is_bool($defaultValue)) { + return $this->appConfig->getAppValueString($key, $defaultValue ? '1' : '0'); + } + + throw $exception; + } + } + + private function writeSystemValue(string $key, mixed $value): void { + if (is_int($value)) { + $this->appConfig->setAppValueInt($key, $value); + return; + } + + if (is_bool($value)) { + $this->appConfig->setAppValueBool($key, $value); + return; + } + + if (is_float($value)) { + $this->appConfig->setAppValueFloat($key, $value); + return; + } + + if (is_array($value)) { + $this->appConfig->setAppValueArray($key, $value); + return; + } + + $this->appConfig->setAppValueString($key, (string)$value); + } + + private function getSystemAllowOverrideConfigKey(string $policyConfigKey): string { + return $policyConfigKey . '.allow_child_override'; + } + + private function getAssignedUserPolicyKey(string $policyConfigKey): string { + return $policyConfigKey . '.assigned'; + } + + private function serializeStoredValue(mixed $value): string { + return json_encode($value, JSON_THROW_ON_ERROR); + } + + private function deserializeStoredValue(string $value): mixed { + try { + return json_decode($value, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return $value; + } + } + + private function serializeStoredUserPolicyPayload(mixed $value, bool $allowChildOverride): string { + return json_encode([ + 'value' => $value, + 'allowChildOverride' => $allowChildOverride, + ], JSON_THROW_ON_ERROR); + } + + private function deserializeStoredUserPolicyPayload(string $payload): mixed { + try { + return json_decode($payload, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException) { + return null; + } + } + + #[\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->getDecodedPolicyJson(); + $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->getDecodedPolicyJson(); + 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] + public function saveUserPreference(string $policyKey, PolicyContext $context, mixed $value): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException($this->l10n->t('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(), $this->serializeStoredValue($normalizedValue)); + } + + #[\Override] + public function saveUserPolicy(string $policyKey, PolicyContext $context, mixed $value, bool $allowChildOverride): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + throw new \InvalidArgumentException($this->l10n->t('A target user is required to save a user policy.')); + } + + $definition = $this->registry->get($policyKey); + $normalizedValue = $definition->normalizeValue($value); + $this->appConfig->setUserValue( + $userId, + $this->getAssignedUserPolicyKey($definition->getUserPreferenceKey()), + $this->serializeStoredUserPolicyPayload($normalizedValue, $allowChildOverride), + ); + } + + #[\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()); + } + + #[\Override] + public function clearUserPolicy(string $policyKey, PolicyContext $context): void { + $userId = $context->getUserId(); + if ($userId === null || $userId === '') { + return; + } + + $definition = $this->registry->get($policyKey); + $this->appConfig->deleteUserValue($userId, $this->getAssignedUserPolicyKey($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(); + } + + /** @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); + } +} diff --git a/lib/Service/RequestSignatureService.php b/lib/Service/RequestSignatureService.php index 78320822ec..c6beced524 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -8,13 +8,13 @@ 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; use OCA\Libresign\Db\IdentifyMethodMapper; use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; +use OCA\Libresign\Enum\DocMdpLevel; use OCA\Libresign\Enum\FileStatus; use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; @@ -27,6 +27,10 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\Model\ResolvedPolicy; +use OCA\Libresign\Service\Policy\PolicyService; +use OCA\Libresign\Service\Policy\Provider\DocMdp\DocMdpPolicy; +use OCA\Libresign\Service\Policy\Provider\Signature\SignatureFlowPolicy; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,6 +71,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, + protected PolicyService $policyService, ) { } @@ -317,6 +322,7 @@ public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { $file = $this->fileMapper->getByUuid($data['uuid']); $this->updateSignatureFlowIfAllowed($file, $data); + $this->updateDocMdpLevelFromPolicy($file, $data); if (!empty($data['name'])) { $file->setName($data['name']); $this->fileService->update($file); @@ -333,6 +339,7 @@ public function saveFile(array $data): FileEntity { try { $file = $this->fileMapper->getByNodeId($fileId); $this->updateSignatureFlowIfAllowed($file, $data); + $this->updateDocMdpLevelFromPolicy($file, $data); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } catch (\Throwable) { } @@ -374,51 +381,109 @@ public function saveFile(array $data): FileEntity { } $this->setSignatureFlow($file, $data); - $this->setDocMdpLevelFromGlobalConfig($file); + $this->setDocMdpLevelFromPolicy($file, $data); $this->fileMapper->insert($file); return $file; } 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; + $requestOverrides = $this->getSignatureFlowRequestOverrides($data); + $resolvedPolicy = $this->policyService->resolveForUserId( + SignatureFlowPolicy::KEY, + $file->getUserId(), + $requestOverrides, + ); + $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy); + $newFlow = SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue()); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($file->getSignatureFlowEnum() !== $newFlow || $metadataChanged) { + $file->setSignatureFlowEnum($newFlow); + $this->fileService->update($file); + } + } - if ($adminForcedConfig) { - $adminFlowEnum = SignatureFlow::from($adminFlow); - if ($file->getSignatureFlowEnum() !== $adminFlowEnum) { - $file->setSignatureFlowEnum($adminFlowEnum); - $this->fileService->update($file); - } - return; + private function setSignatureFlow(FileEntity $file, array $data): void { + $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; + $requestOverrides = $this->getSignatureFlowRequestOverrides($data); + $resolvedPolicy = $this->policyService->resolveForUser( + SignatureFlowPolicy::KEY, + $user, + $requestOverrides, + ); + $this->assertSignatureFlowOverrideAllowed($requestOverrides, $resolvedPolicy); + $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + /** @return array */ + private function getSignatureFlowRequestOverrides(array $data): array { + if (!isset($data['signatureFlow']) || empty($data['signatureFlow'])) { + return []; } - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $newFlow = SignatureFlow::from($data['signatureFlow']); - if ($file->getSignatureFlowEnum() !== $newFlow) { - $file->setSignatureFlowEnum($newFlow); - $this->fileService->update($file); - } + 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 setSignatureFlow(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); + 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); + } - 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); + private function updateDocMdpLevelFromPolicy(FileEntity $file, array $data): void { + $resolvedPolicy = $this->policyService->resolveForUserId( + DocMdpPolicy::KEY, + $file->getUserId(), + $this->getDocMdpRequestOverrides($data), + ); + $newLevel = DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED; + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($file->getDocmdpLevelEnum() !== $newLevel || $metadataChanged) { + $file->setDocmdpLevelEnum($newLevel); + $this->fileService->update($file); } } - private function setDocMdpLevelFromGlobalConfig(FileEntity $file): void { - if ($this->docMdpConfigService->isEnabled()) { - $docmdpLevel = $this->docMdpConfigService->getLevel(); - $file->setDocmdpLevelEnum($docmdpLevel); + private function setDocMdpLevelFromPolicy(FileEntity $file, array $data): void { + $user = ($data['userManager'] ?? null) instanceof IUser ? $data['userManager'] : null; + $resolvedPolicy = $this->policyService->resolveForUser( + DocMdpPolicy::KEY, + $user, + $this->getDocMdpRequestOverrides($data), + ); + $file->setDocmdpLevelEnum(DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + /** @return array */ + private function getDocMdpRequestOverrides(array $data): array { + if (!isset($data['docmdpLevel']) || $data['docmdpLevel'] === null || $data['docmdpLevel'] === '') { + return []; } + + return [DocMdpPolicy::KEY => (int)$data['docmdpLevel']]; } private function getFileMetadata(\OCP\Files\Node $node): array { diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 93f243bdb2..527f189c17 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -594,6 +594,22 @@ private function addCredentialsToJobArgs(array $args, SignRequestEntity $signReq return $args; } + private function runWithVolatileActiveUser(?IUser $user, callable $callback): mixed { + $currentUser = $this->userSession->getUser(); + + if ($user === null || $currentUser?->getUID() === $user->getUID()) { + return $callback(); + } + + $this->userSession->setVolatileActiveUser($user); + + try { + return $callback(); + } finally { + $this->userSession->setVolatileActiveUser($currentUser); + } + } + /** * @return DateTimeInterface|null Last signed date */ @@ -614,7 +630,11 @@ private function signSequentially(array $signRequests): ?DateTimeInterface { $this->validateDocMdpAllowsSignatures(); try { - $signedFile = $this->getEngine()->sign(); + $engine = $this->getEngine(); + $signedFile = $this->runWithVolatileActiveUser( + $this->fileToSign?->getOwner(), + fn (): File => $engine->sign(), + ); } catch (LibresignException|Exception $e) { $this->cleanupUnsignedSignedFile(); $this->recordSignatureAttempt($e); @@ -1439,7 +1459,8 @@ private function createSignedFile(File $originalFile, string $content): File { $this->l10n->t('signed') . '.' . $originalFile->getExtension(), basename($originalFile->getPath()) ); - $owner = $originalFile->getOwner()->getUID(); + $owner = $originalFile->getOwner(); + $ownerUid = $owner->getUID(); $fileId = $this->libreSignFile->getId(); $extension = $originalFile->getExtension(); @@ -1447,9 +1468,12 @@ private function createSignedFile(File $originalFile, string $content): File { try { /** @var \OCP\Files\Folder */ - $parentFolder = $this->root->getUserFolder($owner)->getFirstNodeById($originalFile->getParentId()); + $parentFolder = $this->root->getUserFolder($ownerUid)->getFirstNodeById($originalFile->getParentId()); - $this->createdSignedFile = $parentFolder->newFile($uniqueFilename, $content); + $this->createdSignedFile = $this->runWithVolatileActiveUser( + $owner, + fn (): File => $parentFolder->newFile($uniqueFilename, $content), + ); return $this->createdSignedFile; } catch (NotPermittedException) { diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index 411b28d16a..e57ef79717 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -11,15 +11,19 @@ use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\CertificateEngine\CertificateEngineFactory; +use OCA\Libresign\Service\AccountService; use OCA\Libresign\Service\CertificatePolicyService; 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\ContentSecurityPolicy; use OCP\AppFramework\Http\TemplateResponse; use OCP\AppFramework\Services\IInitialState; use OCP\IAppConfig; +use OCP\IUserSession; use OCP\Settings\ISettings; use OCP\Util; @@ -33,6 +37,8 @@ class Admin implements ISettings { public function __construct( private IInitialState $initialState, + private AccountService $accountService, + private IUserSession $userSession, private IdentifyMethodService $identifyMethodService, private CertificateEngineFactory $certificateEngineFactory, private CertificatePolicyService $certificatePolicyService, @@ -41,12 +47,14 @@ public function __construct( private SignatureBackgroundService $signatureBackgroundService, private FooterService $footerService, private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, ) { } #[\Override] public function getForm(): TemplateResponse { Util::addScript(Application::APP_ID, 'libresign-settings'); Util::addStyle(Application::APP_ID, 'libresign-settings'); + $this->initialState->provideInitialState('config', $this->accountService->getConfig($this->userSession->getUser())); try { $signatureParsed = $this->signatureTextService->parse(); $this->initialState->provideInitialState('signature_text_parsed', $signatureParsed['parsed']); @@ -87,7 +95,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)); @@ -97,7 +111,14 @@ public function getForm(): TemplateResponse { $this->initialState->provideInitialState('show_confetti_after_signing', $this->appConfig->getValueBool(Application::APP_ID, 'show_confetti_after_signing', true)); $this->initialState->provideInitialState('crl_external_validation_enabled', $this->appConfig->getValueBool(Application::APP_ID, 'crl_external_validation_enabled', true)); $this->initialState->provideInitialState('ldap_extension_available', function_exists('ldap_connect')); - return new TemplateResponse(Application::APP_ID, 'admin_settings'); + + $response = new TemplateResponse(Application::APP_ID, 'admin_settings'); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedWorkerSrcDomain('blob:'); + $response->setContentSecurityPolicy($policy); + + return $response; } /** diff --git a/openapi-administration.json b/openapi-administration.json index 849c63dc64..8e6ed080a6 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -374,6 +374,99 @@ } } }, + "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", + "groupCount", + "userCount" + ], + "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 + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -799,6 +892,118 @@ ] } } + }, + "SystemPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/SystemPolicyState" + } + } + }, + "SystemPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "system", + "global" + ] + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, + "UserPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + }, + "UserPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] } } }, @@ -3315,10 +3520,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", + "operationId": "admin-set-doc-mdp-config", + "summary": "Configure DocMDP signature restrictions", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -3343,12 +3548,13 @@ "properties": { "enabled": { "type": "boolean", - "description": "Whether to force a signature flow for all documents" + "description": "Whether to enable DocMDP restrictions" }, - "mode": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" + "defaultLevel": { + "type": "integer", + "format": "int64", + "default": 2, + "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" } } } @@ -3411,7 +3617,7 @@ } }, "400": { - "description": "Invalid signature flow mode provided", + "description": "Invalid DocMDP level provided", "content": { "application/json": { "schema": { @@ -3473,10 +3679,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { - "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { + "get": { + "operationId": "admin-get-active-signings", + "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -3489,31 +3695,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to enable DocMDP restrictions" - }, - "defaultLevel": { - "type": "integer", - "format": "int64", - "default": 2, - "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -3540,37 +3721,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid DocMDP level provided", + "description": "List of active signings", "content": { "application/json": { "schema": { @@ -3590,7 +3741,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -3600,7 +3751,7 @@ } }, "500": { - "description": "Internal server error", + "description": "", "content": { "application/json": { "schema": { @@ -3632,13 +3783,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { "get": { - "operationId": "admin-get-active-signings", - "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl_api" ], "security": [ { @@ -3662,117 +3813,13 @@ } }, { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, + "name": "page", + "in": "query", + "description": "Page number (1-based)", "schema": { - "type": "boolean", - "default": true - } - } - ], - "responses": { - "200": { - "description": "List of active signings", - "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/ActiveSigningsResponse" - } - } - } - } - } - } - } - }, - "500": { - "description": "", - "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}/crl/list": { - "get": { - "operationId": "crl_api-list", - "summary": "List CRL entries with pagination and filters", - "description": "This endpoint requires admin access", - "tags": [ - "crl_api" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "page", - "in": "query", - "description": "Page number (1-based)", - "schema": { - "type": "integer", - "format": "int64", - "nullable": true + "type": "integer", + "format": "int64", + "nullable": true } }, { @@ -4076,6 +4123,674 @@ } } }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "get": { + "operationId": "policy-get-system", + "summary": "Read explicit system policy configuration", + "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": "policyKey", + "in": "path", + "description": "Policy identifier to read from 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/SystemPolicyResponse" + } + } + } + } + } + } + } + } + } + }, + "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" + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether lower layers may override this system default." + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { + "get": { + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", + "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": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected 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/UserPolicyResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", + "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 as assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the target 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/UserPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", + "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": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment removal.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the target 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/UserPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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", diff --git a/openapi-full.json b/openapi-full.json index 157caffe52..b0c5f4c9d7 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -947,6 +947,113 @@ } } }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "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", + "groupCount", + "userCount" + ], + "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 + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "EngineHandler": { "type": "object", "required": [ @@ -1447,6 +1554,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": [ @@ -1862,6 +2047,37 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -2516,6 +2732,77 @@ } } }, + "SystemPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/SystemPolicyState" + } + } + }, + "SystemPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "value", + "allowChildOverride", + "visibleToChild", + "allowedValues" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "system", + "global" + ] + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + }, + "visibleToChild": { + "type": "boolean" + }, + "allowedValues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EffectivePolicyValue" + } + } + } + }, + "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": [ @@ -2598,6 +2885,58 @@ } } }, + "UserPolicyResponse": { + "type": "object", + "required": [ + "policy" + ], + "properties": { + "policy": { + "$ref": "#/components/schemas/UserPolicyState" + } + } + }, + "UserPolicyState": { + "type": "object", + "required": [ + "policyKey", + "scope", + "targetId", + "value", + "allowChildOverride" + ], + "properties": { + "policyKey": { + "type": "string" + }, + "scope": { + "type": "string", + "enum": [ + "user_policy" + ] + }, + "targetId": { + "type": "string" + }, + "value": { + "$ref": "#/components/schemas/EffectivePolicyValue", + "nullable": true + }, + "allowChildOverride": { + "type": "boolean" + } + } + }, + "UserPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/UserPolicyResponse" + } + ] + }, "ValidateMetadata": { "type": "object", "required": [ @@ -2635,6 +2974,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2643,6 +2985,17 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -8019,13 +8372,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { - "post": { - "operationId": "request_signature-request", - "summary": "Request signature", - "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/effective": { + "get": { + "operationId": "policy-effective", + "summary": "Effective policies bootstrap", "tags": [ - "request_signature" + "policy" ], "security": [ { @@ -8035,66 +8387,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signers": { - "type": "array", - "default": [], - "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status", - "items": { - "$ref": "#/components/schemas/NewSigner" - } - }, - "name": { - "type": "string", - "default": "", - "description": "The name of file to sign" - }, - "settings": { - "$ref": "#/components/schemas/FolderSettings", - "default": [], - "description": "Settings to define how and where the file should be stored" - }, - "file": { - "$ref": "#/components/schemas/NewFile", - "default": [], - "description": "File object. Supports nodeId, url, base64 or path." - }, - "files": { - "type": "array", - "default": [], - "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", - "items": { - "$ref": "#/components/schemas/NewFile" - } - }, - "callback": { - "type": "string", - "nullable": true, - "description": "URL that will receive a POST after the document is signed" - }, - "status": { - "type": "integer", - "format": "int64", - "nullable": true, - "default": 1, - "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" - }, - "signatureFlow": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8141,44 +8433,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "Unauthorized", - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/EffectivePoliciesResponse" } } } @@ -8188,13 +8443,14 @@ } } } - }, - "patch": { - "operationId": "request_signature-update-sign", - "summary": "Updates signatures data", - "description": "It is necessary to inform the UUID of the file and a list of signers.", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", "tags": [ - "request_signature" + "policy" ], "security": [ { @@ -8204,83 +8460,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "signers": { - "type": "array", - "nullable": true, - "default": [], - "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", - "items": { - "$ref": "#/components/schemas/NewSigner" - } - }, - "uuid": { - "type": "string", - "nullable": true, - "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID." - }, - "visibleElements": { - "type": "array", - "nullable": true, - "description": "Visible elements on document", - "items": { - "$ref": "#/components/schemas/VisibleElement" - } - }, - "file": { - "nullable": true, - "default": [], - "description": "File object. Supports nodeId, url, base64 or path when creating a new request.", - "anyOf": [ - { - "$ref": "#/components/schemas/NewFile" - }, - { - "type": "array", - "maxItems": 0 - } - ] - }, - "status": { - "type": "integer", - "format": "int64", - "nullable": true, - "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" - }, - "signatureFlow": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" - }, - "name": { - "type": "string", - "nullable": true, - "description": "The name of file to sign" - }, - "settings": { - "$ref": "#/components/schemas/FolderSettings", - "default": [], - "description": "Settings to define how and where the file should be stored" - }, - "files": { - "type": "array", - "default": [], - "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", - "items": { - "$ref": "#/components/schemas/NewFile" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8294,6 +8473,26 @@ "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", @@ -8327,7 +8526,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/GroupPolicyResponse" } } } @@ -8336,8 +8535,8 @@ } } }, - "422": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8357,14 +8556,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8374,15 +8566,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { - "delete": { - "operationId": "request_signature-delete-one-request-signature-using-file-id", - "summary": "Delete sign request", - "description": "You can only request exclusion as any sign", + }, + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", "tags": [ - "request_signature" + "policy" ], "security": [ { @@ -8392,6 +8581,43 @@ "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", @@ -8406,23 +8632,23 @@ } }, { - "name": "fileId", + "name": "groupId", "in": "path", - "description": "LibreSign file ID", + "description": "Group identifier that receives the policy binding.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[^/]+$" } }, { - "name": "signRequestId", + "name": "policyKey", "in": "path", - "description": "The sign request id", + "description": "Policy identifier to persist at the group layer.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8458,7 +8684,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8467,8 +8693,8 @@ } } }, - "401": { - "description": "Failed", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8488,7 +8714,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8497,8 +8723,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8518,7 +8744,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8528,15 +8754,12 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { + }, "delete": { - "operationId": "request_signature-delete-all-request-signature-using-file-id", - "summary": "Delete sign request", - "description": "You can only request exclusion as any sign", + "operationId": "policy-clear-group", + "summary": "Clear a group-level policy value", "tags": [ - "request_signature" + "policy" ], "security": [ { @@ -8560,13 +8783,23 @@ } }, { - "name": "fileId", + "name": "groupId", "in": "path", - "description": "LibreSign file ID", + "description": "Group identifier that receives the policy binding.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "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_]+$" } }, { @@ -8602,37 +8835,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" - } - } - } - } - } - } - } - }, - "401": { - "description": "Failed", - "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/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8641,8 +8844,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8662,7 +8865,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8672,15 +8875,16 @@ } } } - }, - "post": { - "operationId": "sign_file-sign-using-file-id", - "summary": "Sign a file using file Id", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{policyKey}": { + "put": { + "operationId": "policy-set-user-preference", + "summary": "Save a user policy preference", "tags": [ - "sign_file" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8689,41 +8893,31 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "method" - ], "properties": { - "method": { - "type": "string", - "description": "Signature method" - }, - "elements": { - "type": "object", - "default": {}, - "description": "List of visible elements", - "additionalProperties": { - "type": "object" - } - }, - "identifyValue": { - "type": "string", - "default": "", - "description": "Identify value" - }, - "token": { - "type": "string", - "default": "", - "description": "Token, commonly send by email" - }, - "async": { - "type": "boolean", - "default": false, - "description": "Execute signing asynchronously when possible" + "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" + } + ] } } } @@ -8744,13 +8938,13 @@ } }, { - "name": "fileId", + "name": "policyKey", "in": "path", - "description": "Id of LibreSign file", + "description": "Policy identifier to persist for the current user.", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8786,7 +8980,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8795,8 +8989,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8816,7 +9010,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8826,17 +9020,14 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { - "post": { - "operationId": "sign_file-sign-using-uuid", - "summary": "Sign a file using file UUID", + }, + "delete": { + "operationId": "policy-clear-user-preference", + "summary": "Clear a user policy preference", "tags": [ - "sign_file" + "policy" ], "security": [ - {}, { "bearer_auth": [] }, @@ -8844,48 +9035,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "method" - ], - "properties": { - "method": { - "type": "string", - "description": "Signature method" - }, - "elements": { - "type": "object", - "default": {}, - "description": "List of visible elements", - "additionalProperties": { - "type": "object" - } - }, - "identifyValue": { - "type": "string", - "default": "", - "description": "Identify value" - }, - "token": { - "type": "string", - "default": "", - "description": "Token, commonly send by email" - }, - "async": { - "type": "boolean", - "default": false, - "description": "Execute signing asynchronously when possible" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -8900,12 +9049,13 @@ } }, { - "name": "uuid", + "name": "policyKey", "in": "path", - "description": "UUID of LibreSign file", + "description": "Policy identifier to clear for the current user.", "required": true, "schema": { - "type": "string" + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { @@ -8941,37 +9091,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "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/SignActionErrorResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8983,106 +9103,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { - "operationId": "sign_file-sign-renew", - "summary": "Renew the signature method", + "operationId": "request_signature-request", + "summary": "Request signature", + "description": "Request that a file be signed by a list of signers. Each signer in the signers array can optionally include a 'signingOrder' field to control the order of signatures when ordered signing flow is enabled. The returned `data` always includes `filesCount` and `files`. For `nodeType=file`, `filesCount=1` and `files` contains the current file. For `nodeType=envelope`, `files` contains envelope child files.", "tags": [ - "sign_file" + "request_signature" ], "security": [ - {}, - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "uuid", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "method", - "in": "path", - "description": "Signature method", - "required": true, - "schema": { - "type": "string" - } - }, - { - "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/MessageResponse" - } - } - } - } - } - } - } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": { - "post": { - "operationId": "sign_file-get-code-using-uuid", - "summary": "Get code to sign the document using UUID", - "tags": [ - "sign_file" - ], - "security": [ - {}, { "bearer_auth": [] }, @@ -9097,24 +9126,53 @@ "schema": { "type": "object", "properties": { - "identifyMethod": { + "signers": { + "type": "array", + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format. Other supported fields: displayName, description, notify, signingOrder, status", + "items": { + "$ref": "#/components/schemas/NewSigner" + } + }, + "name": { "type": "string", - "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": "", + "description": "The name of file to sign" }, - "signMethod": { + "settings": { + "$ref": "#/components/schemas/FolderSettings", + "default": [], + "description": "Settings to define how and where the file should be stored" + }, + "file": { + "$ref": "#/components/schemas/NewFile", + "default": [], + "description": "File object. Supports nodeId, url, base64 or path." + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", + "items": { + "$ref": "#/components/schemas/NewFile" + } + }, + "callback": { "type": "string", "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + "description": "URL that will receive a POST after the document is signed" }, - "identify": { + "status": { + "type": "integer", + "format": "int64", + "nullable": true, + "default": 1, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "signatureFlow": { "type": "string", "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" + "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution." } } } @@ -9134,15 +9192,6 @@ "default": "v1" } }, - { - "name": "uuid", - "in": "path", - "description": "UUID of LibreSign file", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -9176,7 +9225,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9186,7 +9235,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9206,7 +9255,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9216,17 +9272,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { - "post": { - "operationId": "sign_file-get-code-using-file-id", - "summary": "Get code to sign the document using FileID", + }, + "patch": { + "operationId": "request_signature-update-sign", + "summary": "Updates signatures data", + "description": "It is necessary to inform the UUID of the file and a list of signers.", "tags": [ - "sign_file" + "request_signature" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9241,24 +9295,70 @@ "schema": { "type": "object", "properties": { - "identifyMethod": { + "signers": { + "type": "array", + "nullable": true, + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", + "items": { + "$ref": "#/components/schemas/NewSigner" + } + }, + "uuid": { "type": "string", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "description": "UUID of sign request. The signer UUID is what the person receives via email when asked to sign. This is not the file UUID." }, - "signMethod": { + "visibleElements": { + "type": "array", + "nullable": true, + "description": "Visible elements on document", + "items": { + "$ref": "#/components/schemas/VisibleElement" + } + }, + "file": { + "nullable": true, + "default": [], + "description": "File object. Supports nodeId, url, base64 or path when creating a new request.", + "anyOf": [ + { + "$ref": "#/components/schemas/NewFile" + }, + { + "type": "array", + "maxItems": 0 + } + ] + }, + "status": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" + }, + "signatureFlow": { "type": "string", "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution." }, - "identify": { + "name": { "type": "string", "nullable": true, - "description": "Identify value, i.e. the signer email, account or phone number" + "description": "The name of file to sign" + }, + "settings": { + "$ref": "#/components/schemas/FolderSettings", + "default": [], + "description": "Settings to define how and where the file should be stored" + }, + "files": { + "type": "array", + "default": [], + "description": "Multiple files to create an envelope (optional, use either file or files). Each file supports nodeId, url, base64 or path.", + "items": { + "$ref": "#/components/schemas/NewFile" + } } } } @@ -9278,16 +9378,6 @@ "default": "v1" } }, - { - "name": "fileId", - "in": "path", - "description": "Id of LibreSign file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -9321,7 +9411,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9331,7 +9421,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9351,7 +9441,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9363,15 +9460,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { - "post": { - "operationId": "signature_elements-create-signature-element", - "summary": "Create signature element", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/{signRequestId}": { + "delete": { + "operationId": "request_signature-delete-one-request-signature-using-file-id", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", "tags": [ - "signature_elements" + "request_signature" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9379,28 +9476,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "elements" - ], - "properties": { - "elements": { - "type": "object", - "description": "Element object", - "additionalProperties": { - "type": "object" - } - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -9415,8 +9490,28 @@ } }, { - "name": "OCS-APIRequest", - "in": "header", + "name": "fileId", + "in": "path", + "description": "LibreSign file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "signRequestId", + "in": "path", + "description": "The sign request id", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", "description": "Required to be true for the API request to pass", "required": true, "schema": { @@ -9428,36 +9523,6 @@ "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/UserElementsMessageResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "Invalid data", "content": { "application/json": { "schema": { @@ -9485,51 +9550,9 @@ } } } - } - } - }, - "get": { - "operationId": "signature_elements-get-signature-elements", - "summary": "Get signature elements", - "tags": [ - "signature_elements" - ], - "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", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9549,7 +9572,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9558,8 +9581,8 @@ } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9579,7 +9602,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ActionErrorResponse" } } } @@ -9591,15 +9614,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element-preview", - "summary": "Get preview of signature elements of", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}": { + "delete": { + "operationId": "request_signature-delete-all-request-signature-using-file-id", + "summary": "Delete sign request", + "description": "You can only request exclusion as any sign", "tags": [ - "signature_elements" + "request_signature" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9621,9 +9644,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "LibreSign file ID", "required": true, "schema": { "type": "integer", @@ -9644,17 +9667,6 @@ "responses": { "200": { "description": "OK", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "404": { - "description": "Invalid data", "content": { "application/json": { "schema": { @@ -9674,7 +9686,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9682,62 +9694,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element", - "summary": "Get signature element of signer", - "tags": [ - "signature_elements" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "nodeId", - "in": "path", - "description": "Node id of a Nextcloud file", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } }, - { - "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", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9757,7 +9716,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElement" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9766,8 +9725,8 @@ } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9787,7 +9746,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ActionErrorResponse" } } } @@ -9798,11 +9757,11 @@ } } }, - "patch": { - "operationId": "signature_elements-patch-signature-element", - "summary": "Update signature element", + "post": { + "operationId": "sign_file-sign-using-file-id", + "summary": "Sign a file using file Id", "tags": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9814,24 +9773,41 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "method" + ], "properties": { - "type": { + "method": { "type": "string", - "default": "", - "description": "The type of signature element" + "description": "Signature method" }, - "file": { + "elements": { "type": "object", "default": {}, - "description": "Element object", + "description": "List of visible elements", "additionalProperties": { "type": "object" } + }, + "identifyValue": { + "type": "string", + "default": "", + "description": "Identify value" + }, + "token": { + "type": "string", + "default": "", + "description": "Token, commonly send by email" + }, + "async": { + "type": "boolean", + "default": false, + "description": "Execute signing asynchronously when possible" } } } @@ -9852,9 +9828,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Id of LibreSign file", "required": true, "schema": { "type": "integer", @@ -9894,7 +9870,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9924,7 +9900,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9934,12 +9910,14 @@ } } } - }, - "delete": { - "operationId": "signature_elements-delete-signature-element", - "summary": "Delete signature element", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}": { + "post": { + "operationId": "sign_file-sign-using-uuid", + "summary": "Sign a file using file UUID", "tags": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9950,6 +9928,48 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "method" + ], + "properties": { + "method": { + "type": "string", + "description": "Signature method" + }, + "elements": { + "type": "object", + "default": {}, + "description": "List of visible elements", + "additionalProperties": { + "type": "object" + } + }, + "identifyValue": { + "type": "string", + "default": "", + "description": "Identify value" + }, + "token": { + "type": "string", + "default": "", + "description": "Token, commonly send by email" + }, + "async": { + "type": "boolean", + "default": false, + "description": "Execute signing asynchronously when possible" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -9964,13 +9984,12 @@ } }, { - "name": "nodeId", + "name": "uuid", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "UUID of LibreSign file", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } }, { @@ -10006,7 +10025,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -10015,8 +10034,8 @@ } } }, - "404": { - "description": "Not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10036,7 +10055,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -10048,15 +10067,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { "post": { - "operationId": "admin-generate-certificate-cfssl", - "summary": "Generate certificate using CFSSL engine", - "description": "This endpoint requires admin access", + "operationId": "sign_file-sign-renew", + "summary": "Renew the signature method", "tags": [ - "admin" + "sign_file" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10064,68 +10083,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "rootCert" - ], - "properties": { - "rootCert": { - "type": "object", - "description": "fields of root certificate", - "required": [ - "commonName", - "names" - ], - "properties": { - "commonName": { - "type": "string" - }, - "names": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - } - } - } - }, - "cfsslUri": { - "type": "string", - "default": "", - "description": "URI of CFSSL API" - }, - "configPath": { - "type": "string", - "default": "", - "description": "Path of config files of CFSSL" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -10139,6 +10096,23 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "method", + "in": "path", + "description": "Signature method", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10153,36 +10127,6 @@ "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/EngineHandlerResponse" - } - } - } - } - } - } - } - }, - "401": { - "description": "Account not found", "content": { "application/json": { "schema": { @@ -10214,15 +10158,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/code": { "post": { - "operationId": "admin-generate-certificate-open-ssl", - "summary": "Generate certificate using OpenSSL engine", - "description": "This endpoint requires admin access", + "operationId": "sign_file-get-code-using-uuid", + "summary": "Get code to sign the document using UUID", "tags": [ - "admin" + "sign_file" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10231,56 +10175,30 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "rootCert" - ], "properties": { - "rootCert": { - "type": "object", - "description": "fields of root certificate", - "required": [ - "commonName", - "names" + "identifyMethod": { + "type": "string", + "nullable": true, + "enum": [ + "account", + "email" ], - "properties": { - "commonName": { - "type": "string" - }, - "names": { - "type": "object", - "additionalProperties": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ] - } - } - } - } - } + "description": "Identify signer method" }, - "configPath": { + "signMethod": { "type": "string", - "default": "", - "description": "Path of config files of CFSSL" + "nullable": true, + "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + }, + "identify": { + "type": "string", + "nullable": true, + "description": "Identify value, i.e. the signer email, account or phone number" } } } @@ -10300,6 +10218,15 @@ "default": "v1" } }, + { + "name": "uuid", + "in": "path", + "description": "UUID of LibreSign file", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10333,7 +10260,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10342,8 +10269,8 @@ } } }, - "401": { - "description": "Account not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10375,15 +10302,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/file_id/{fileId}/code": { "post": { - "operationId": "admin-set-certificate-engine", - "summary": "Set certificate engine", - "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access", + "operationId": "sign_file-get-code-using-file-id", + "summary": "Get code to sign the document using FileID", "tags": [ - "admin" + "sign_file" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10392,18 +10319,30 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "engine" - ], "properties": { - "engine": { + "identifyMethod": { "type": "string", - "description": "The certificate engine to use (openssl, cfssl, or none)" + "nullable": true, + "enum": [ + "account", + "email" + ], + "description": "Identify signer method" + }, + "signMethod": { + "type": "string", + "nullable": true, + "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + }, + "identify": { + "type": "string", + "nullable": true, + "description": "Identify value, i.e. the signer email, account or phone number" } } } @@ -10423,6 +10362,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10456,7 +10405,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificateEngineConfigResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10465,8 +10414,8 @@ } } }, - "400": { - "description": "Invalid engine", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10498,15 +10447,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": { - "get": { - "operationId": "admin-load-certificate", - "summary": "Load certificate data", - "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { + "post": { + "operationId": "signature_elements-create-signature-element", + "summary": "Create signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10514,6 +10463,28 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "elements" + ], + "properties": { + "elements": { + "type": "object", + "description": "Element object", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10560,7 +10531,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CetificateDataGenerated" + "$ref": "#/components/schemas/UserElementsMessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "Invalid data", + "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/MessageResponse" } } } @@ -10570,17 +10571,15 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": { + }, "get": { - "operationId": "admin-configure-check", - "summary": "Check the configuration of LibreSign", - "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access", + "operationId": "signature_elements-get-signature-elements", + "summary": "Get signature elements", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10634,7 +10633,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ConfigureChecksResponse" + "$ref": "#/components/schemas/UserElementsResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Invalid data", + "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/MessageResponse" } } } @@ -10646,15 +10675,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/preview/{nodeId}": { "get": { - "operationId": "admin-disable-hate-limit", - "summary": "Disable hate limit to current session", - "description": "This will disable hate limit to current session.\nThis endpoint requires admin access", + "operationId": "signature_elements-get-signature-element-preview", + "summary": "Get preview of signature elements of", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10675,6 +10704,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10689,6 +10728,17 @@ "responses": { "200": { "description": "OK", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10707,7 +10757,9 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "type": "object" + } } } } @@ -10718,13 +10770,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { - "post": { - "operationId": "admin-signature-background-save", - "summary": "Add custom background image", - "description": "This endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { + "get": { + "operationId": "signature_elements-get-signature-element", + "summary": "Get signature element of signer", "tags": [ - "admin" + "signature_elements" ], "security": [ { @@ -10747,6 +10798,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10780,7 +10841,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserElement" } } } @@ -10789,8 +10850,8 @@ } } }, - "422": { - "description": "Error", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10810,7 +10871,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -10821,14 +10882,14 @@ } } }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", - "description": "This endpoint requires admin access", + "patch": { + "operationId": "signature_elements-patch-signature-element", + "summary": "Update signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10836,59 +10897,31 @@ "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": "Image returned", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "type": { + "type": "string", + "default": "", + "description": "The type of signature element" + }, + "file": { + "type": "object", + "default": {}, + "description": "Element object", + "additionalProperties": { + "type": "object" + } + } } } } } - } - }, - "patch": { - "operationId": "admin-signature-background-reset", - "summary": "Reset the background image to be the default of LibreSign", - "description": "This endpoint requires admin access", - "tags": [ - "admin" - ], - "security": [ - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], + }, "parameters": [ { "name": "apiVersion", @@ -10902,6 +10935,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10915,7 +10958,7 @@ ], "responses": { "200": { - "description": "Image reseted to default", + "description": "OK", "content": { "application/json": { "schema": { @@ -10935,7 +10978,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" + } + } + } + } + } + } + } + }, + "422": { + "description": "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/MessageResponse" } } } @@ -10947,13 +11020,13 @@ } }, "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-delete-signature-element", + "summary": "Delete signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10974,6 +11047,16 @@ "default": "v1" } }, + { + "name": "nodeId", + "in": "path", + "description": "Node id of a Nextcloud file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -10987,7 +11070,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -11007,7 +11090,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Not found", + "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/MessageResponse" } } } @@ -11019,10 +11132,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", + "operationId": "admin-generate-certificate-cfssl", + "summary": "Generate certificate using CFSSL engine", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11042,41 +11155,55 @@ "schema": { "type": "object", "required": [ - "template" + "rootCert" ], "properties": { - "template": { - "type": "string", - "description": "Template to signature text" - }, - "templateFontSize": { - "type": "number", - "format": "double", - "default": 10, - "description": "Font size used when print the parsed text of this template at PDF file" - }, - "signatureFontSize": { - "type": "number", - "format": "double", - "default": 20, - "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION" - }, - "signatureWidth": { - "type": "number", - "format": "double", - "default": 350, - "description": "Signature box width, minimum 1" + "rootCert": { + "type": "object", + "description": "fields of root certificate", + "required": [ + "commonName", + "names" + ], + "properties": { + "commonName": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + } + } }, - "signatureHeight": { - "type": "number", - "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" + "cfsslUri": { + "type": "string", + "default": "", + "description": "URI of CFSSL API" }, - "renderMode": { + "configPath": { "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "default": "", + "description": "Path of config files of CFSSL" } } } @@ -11129,7 +11256,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/EngineHandlerResponse" } } } @@ -11138,8 +11265,8 @@ } } }, - "400": { - "description": "Bad request", + "401": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -11159,7 +11286,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11169,10 +11296,12 @@ } } } - }, - "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/openssl": { + "post": { + "operationId": "admin-generate-certificate-open-ssl", + "summary": "Generate certificate using OpenSSL engine", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11185,35 +11314,74 @@ "basic_auth": [] } ], - "parameters": [ - { - "name": "apiVersion", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" - } - }, - { - "name": "template", - "in": "query", - "description": "Template to signature text", - "schema": { - "type": "string", - "default": "" + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rootCert" + ], + "properties": { + "rootCert": { + "type": "object", + "description": "fields of root certificate", + "required": [ + "commonName", + "names" + ], + "properties": { + "commonName": { + "type": "string" + }, + "names": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ] + } + } + } + } + } + }, + "configPath": { + "type": "string", + "default": "", + "description": "Path of config files of CFSSL" + } + } + } } - }, + } + }, + "parameters": [ { - "name": "context", - "in": "query", - "description": "Context for parsing the template", + "name": "apiVersion", + "in": "path", + "required": true, "schema": { "type": "string", - "default": "" + "enum": [ + "v1" + ], + "default": "v1" } }, { @@ -11249,7 +11417,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/EngineHandlerResponse" } } } @@ -11258,8 +11426,8 @@ } } }, - "400": { - "description": "Bad request", + "401": { + "description": "Account not found", "content": { "application/json": { "schema": { @@ -11279,7 +11447,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11291,11 +11459,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { - "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", - "description": "This endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/engine": { + "post": { + "operationId": "admin-set-certificate-engine", + "summary": "Set certificate engine", + "description": "Sets the certificate engine (openssl, cfssl, or none) and automatically configures identify_methods when needed\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11307,6 +11475,25 @@ "basic_auth": [] } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "engine" + ], + "properties": { + "engine": { + "type": "string", + "description": "The certificate engine to use (openssl, cfssl, or none)" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -11353,7 +11540,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" + "$ref": "#/components/schemas/CertificateEngineConfigResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid engine", + "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/MessageResponse" } } } @@ -11365,11 +11582,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": { "get": { - "operationId": "admin-signer-name", - "summary": "Convert signer name as image", - "description": "This endpoint requires admin access", + "operationId": "admin-load-certificate", + "summary": "Load certificate data", + "description": "Return all data of root certificate and a field called `generated` with a boolean value.\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11394,67 +11611,6 @@ "default": "v1" } }, - { - "name": "width", - "in": "query", - "description": "Image width,", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "height", - "in": "query", - "description": "Image height", - "required": true, - "schema": { - "type": "integer", - "format": "int64" - } - }, - { - "name": "text", - "in": "query", - "description": "Text to be added to image", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "fontSize", - "in": "query", - "description": "Font size of text", - "required": true, - "schema": { - "type": "number", - "format": "double" - } - }, - { - "name": "isDarkTheme", - "in": "query", - "description": "Color of text, white if is tark theme and black if not", - "required": true, - "schema": { - "type": "integer", - "enum": [ - 0, - 1 - ] - } - }, - { - "name": "align", - "in": "query", - "description": "Align of text: left, center or right", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -11469,27 +11625,6 @@ "responses": { "200": { "description": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, - "content": { - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", "content": { "application/json": { "schema": { @@ -11509,7 +11644,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CetificateDataGenerated" } } } @@ -11521,11 +11656,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { - "post": { - "operationId": "admin-save-certificate-policy", - "summary": "Update certificate policy of this instance", - "description": "This endpoint requires admin access", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/configure-check": { + "get": { + "operationId": "admin-configure-check", + "summary": "Check the configuration of LibreSign", + "description": "Return the status of necessary configuration and tips to fix the problems.\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11583,37 +11718,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificatePolicyResponse" - } - } - } - } - } - } - } - }, - "422": { - "description": "Not found", - "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/FailureStatusResponse" + "$ref": "#/components/schemas/ConfigureChecksResponse" } } } @@ -11623,11 +11728,13 @@ } } } - }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/disable-hate-limit": { + "get": { + "operationId": "admin-disable-hate-limit", + "summary": "Disable hate limit to current session", + "description": "This will disable hate limit to current session.\nThis endpoint requires admin access", "tags": [ "admin" ], @@ -11684,9 +11791,7 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": { - "type": "object" - } + "data": {} } } } @@ -11697,10 +11802,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { "post": { - "operationId": "admin-updateoid", - "summary": "Update OID", + "operationId": "admin-signature-background-save", + "summary": "Add custom background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11713,25 +11818,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "oid" - ], - "properties": { - "oid": { - "type": "string", - "description": "OID is a unique numeric identifier for certificate policies in digital certificates." - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -11788,7 +11874,7 @@ } }, "422": { - "description": "Validation error", + "description": "Error", "content": { "application/json": { "schema": { @@ -11818,12 +11904,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { + }, "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11862,7 +11946,60 @@ ], "responses": { "200": { - "description": "OK", + "description": "Image returned", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + }, + "patch": { + "operationId": "admin-signature-background-reset", + "summary": "Reset the background image to be the default of LibreSign", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "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": "Image reseted to default", "content": { "application/json": { "schema": { @@ -11882,7 +12019,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11893,9 +12030,9 @@ } } }, - "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11908,43 +12045,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "daysBefore", - "daysBetween", - "max", - "sendTimer" - ], - "properties": { - "daysBefore": { - "type": "integer", - "format": "int64", - "description": "First reminder after (days)" - }, - "daysBetween": { - "type": "integer", - "format": "int64", - "description": "Days between reminders" - }, - "max": { - "type": "integer", - "format": "int64", - "description": "Max reminders per signer" - }, - "sendTimer": { - "type": "string", - "description": "Send time (HH:mm)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -11971,7 +12071,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Deleted with success", "content": { "application/json": { "schema": { @@ -11991,7 +12091,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -12003,11 +12103,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { "post": { - "operationId": "admin-set-tsa-config", - "summary": "Set TSA configuration values with proper sensitive data handling", - "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", + "operationId": "admin-signature-text-save", + "summary": "Save signature text service", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -12020,36 +12120,47 @@ } ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", + "required": [ + "template" + ], "properties": { - "tsa_url": { + "template": { "type": "string", - "nullable": true, - "description": "TSA server URL (required for saving)" + "description": "Template to signature text" }, - "tsa_policy_oid": { - "type": "string", - "nullable": true, - "description": "TSA policy OID" + "templateFontSize": { + "type": "number", + "format": "double", + "default": 10, + "description": "Font size used when print the parsed text of this template at PDF file" }, - "tsa_auth_type": { - "type": "string", - "nullable": true, - "description": "Authentication type (none|basic), defaults to 'none'" + "signatureFontSize": { + "type": "number", + "format": "double", + "default": 20, + "description": "Font size used when the signature mode is SIGNAME_AND_DESCRIPTION" }, - "tsa_username": { - "type": "string", - "nullable": true, - "description": "Username for basic authentication" + "signatureWidth": { + "type": "number", + "format": "double", + "default": 350, + "description": "Signature box width, minimum 1" }, - "tsa_password": { + "signatureHeight": { + "type": "number", + "format": "double", + "default": 100, + "description": "Signature box height, minimum 1" + }, + "renderMode": { "type": "string", - "nullable": true, - "description": "Password for basic authentication (stored as sensitive data)" + "default": "GRAPHIC_AND_DESCRIPTION", + "description": "Signature render mode" } } } @@ -12102,7 +12213,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/SignatureTextSettingsResponse" } } } @@ -12112,7 +12223,7 @@ } }, "400": { - "description": "Validation error", + "description": "Bad request", "content": { "application/json": { "schema": { @@ -12132,7 +12243,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12143,10 +12254,10 @@ } } }, - "delete": { - "operationId": "admin-delete-tsa-config", - "summary": "Delete TSA configuration", - "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", + "get": { + "operationId": "admin-signature-text-get", + "summary": "Get parsed signature text service", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -12172,10 +12283,28 @@ } }, { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, + "name": "template", + "in": "query", + "description": "Template to signature text", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "context", + "in": "query", + "description": "Context for parsing the template", + "schema": { + "type": "string", + "default": "" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, "schema": { "type": "boolean", "default": true @@ -12204,7 +12333,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/SignatureTextSettingsResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Bad request", + "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" } } } @@ -12216,11 +12375,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { "get": { - "operationId": "admin-get-footer-template", - "summary": "Get footer template", - "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access", + "operationId": "admin-get-signature-settings", + "summary": "Get signature settings", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -12278,7 +12437,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FooterTemplateResponse" + "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" } } } @@ -12288,11 +12447,13 @@ } } } - }, - "post": { - "operationId": "admin-save-footer-template", - "summary": "Save footer template and render preview", - "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access", + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signer-name": { + "get": { + "operationId": "admin-signer-name", + "summary": "Convert signer name as image", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -12304,35 +12465,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "template": { - "type": "string", - "default": "", - "description": "The Twig template to save (empty to reset to default)" - }, - "width": { - "type": "integer", - "format": "int64", - "default": 595, - "description": "Width of preview in points (default: 595 - A4 width)" - }, - "height": { - "type": "integer", - "format": "int64", - "default": 50, - "description": "Height of preview in points (default: 50)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12346,6 +12478,67 @@ "default": "v1" } }, + { + "name": "width", + "in": "query", + "description": "Image width,", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "height", + "in": "query", + "description": "Image height", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, + { + "name": "text", + "in": "query", + "description": "Text to be added to image", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fontSize", + "in": "query", + "description": "Font size of text", + "required": true, + "schema": { + "type": "number", + "format": "double" + } + }, + { + "name": "isDarkTheme", + "in": "query", + "description": "Color of text, white if is tark theme and black if not", + "required": true, + "schema": { + "type": "integer", + "enum": [ + 0, + 1 + ] + } + }, + { + "name": "align", + "in": "query", + "description": "Align of text: left, center or right", + "required": true, + "schema": { + "type": "string" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -12360,8 +12553,18 @@ "responses": { "200": { "description": "OK", + "headers": { + "Content-Disposition": { + "schema": { + "type": "string", + "enum": [ + "inline; filename=\"signer-name.png\"" + ] + } + } + }, "content": { - "application/pdf": { + "image/png": { "schema": { "type": "string", "format": "binary" @@ -12402,11 +12605,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy": { "post": { - "operationId": "admin-set-signing-mode-config", - "summary": "Set signing mode configuration", - "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access", + "operationId": "admin-save-certificate-policy", + "summary": "Update certificate policy of this instance", + "description": "This endpoint requires admin access", "tags": [ "admin" ], @@ -12418,30 +12621,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "mode" - ], - "properties": { - "mode": { - "type": "string", - "description": "Signing mode: \"sync\" or \"async\"" - }, - "workerType": { - "type": "string", - "nullable": true, - "description": "Worker type when async: \"local\" or \"external\" (optional)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12468,37 +12647,7 @@ ], "responses": { "200": { - "description": "Settings saved", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid parameters", + "description": "OK", "content": { "application/json": { "schema": { @@ -12518,7 +12667,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CertificatePolicyResponse" } } } @@ -12527,8 +12676,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -12548,7 +12697,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -12558,15 +12707,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { - "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", - "description": "This endpoint requires admin access", - "tags": [ - "admin" + }, + "delete": { + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", + "description": "This endpoint requires admin access", + "tags": [ + "admin" ], "security": [ { @@ -12576,30 +12723,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "enabled" - ], - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to force a signature flow for all documents" - }, - "mode": { - "type": "string", - "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric' (only used when enabled is true)" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -12626,67 +12749,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid signature flow mode provided", - "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", + "description": "OK", "content": { "application/json": { "schema": { @@ -12706,7 +12769,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "type": "object" } } } @@ -12718,10 +12781,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-set-doc-mdp-config", - "summary": "Configure DocMDP signature restrictions", + "operationId": "admin-updateoid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -12741,18 +12804,12 @@ "schema": { "type": "object", "required": [ - "enabled" + "oid" ], "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to enable DocMDP restrictions" - }, - "defaultLevel": { - "type": "integer", - "format": "int64", - "default": 2, - "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" + "oid": { + "type": "string", + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -12785,37 +12842,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", - "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/MessageResponse" - } - } - } - } - } - } - } - }, - "400": { - "description": "Invalid DocMDP level provided", + "description": "OK", "content": { "application/json": { "schema": { @@ -12835,7 +12862,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -12844,8 +12871,8 @@ } } }, - "500": { - "description": "Internal server error", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -12865,7 +12892,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -12877,10 +12904,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/active-signings": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { "get": { - "operationId": "admin-get-active-signings", - "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", + "operationId": "admin-reminder-fetch", + "summary": "Get reminder settings", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -12919,7 +12946,7 @@ ], "responses": { "200": { - "description": "List of active signings", + "description": "OK", "content": { "application/json": { "schema": { @@ -12939,7 +12966,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActiveSigningsResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -12947,9 +12974,88 @@ } } } + } + } + }, + "post": { + "operationId": "admin-reminder-save", + "summary": "Save reminder", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] }, - "500": { - "description": "", + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "daysBefore", + "daysBetween", + "max", + "sendTimer" + ], + "properties": { + "daysBefore": { + "type": "integer", + "format": "int64", + "description": "First reminder after (days)" + }, + "daysBetween": { + "type": "integer", + "format": "int64", + "description": "Days between reminders" + }, + "max": { + "type": "integer", + "format": "int64", + "description": "Max reminders per signer" + }, + "sendTimer": { + "type": "string", + "description": "Send time (HH:mm)" + } + } + } + } + } + }, + "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": { @@ -12969,7 +13075,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -12981,15 +13087,1175 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/list": { - "get": { - "operationId": "crl_api-list", - "summary": "List CRL entries with pagination and filters", - "description": "This endpoint requires admin access", - "tags": [ - "crl_api" - ], - "security": [ + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/tsa": { + "post": { + "operationId": "admin-set-tsa-config", + "summary": "Set TSA configuration values with proper sensitive data handling", + "description": "Only saves configuration if tsa_url is provided. Automatically manages username/password fields based on authentication type.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tsa_url": { + "type": "string", + "nullable": true, + "description": "TSA server URL (required for saving)" + }, + "tsa_policy_oid": { + "type": "string", + "nullable": true, + "description": "TSA policy OID" + }, + "tsa_auth_type": { + "type": "string", + "nullable": true, + "description": "Authentication type (none|basic), defaults to 'none'" + }, + "tsa_username": { + "type": "string", + "nullable": true, + "description": "Username for basic authentication" + }, + "tsa_password": { + "type": "string", + "nullable": true, + "description": "Password for basic authentication (stored as sensitive data)" + } + } + } + } + } + }, + "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/SuccessStatusResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Validation 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/ErrorStatusResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "admin-delete-tsa-config", + "summary": "Delete TSA configuration", + "description": "Delete all TSA configuration fields from the application settings.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "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/SuccessStatusResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/footer-template": { + "get": { + "operationId": "admin-get-footer-template", + "summary": "Get footer template", + "description": "Returns the current footer template if set, otherwise returns the default template.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "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/FooterTemplateResponse" + } + } + } + } + } + } + } + } + } + }, + "post": { + "operationId": "admin-save-footer-template", + "summary": "Save footer template and render preview", + "description": "Saves the footer template and returns the rendered PDF preview.\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "template": { + "type": "string", + "default": "", + "description": "The Twig template to save (empty to reset to default)" + }, + "width": { + "type": "integer", + "format": "int64", + "default": 595, + "description": "Width of preview in points (default: 595 - A4 width)" + }, + "height": { + "type": "integer", + "format": "int64", + "default": 50, + "description": "Height of preview in points (default: 50)" + } + } + } + } + } + }, + "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/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", + "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}/admin/signing-mode/config": { + "post": { + "operationId": "admin-set-signing-mode-config", + "summary": "Set signing mode configuration", + "description": "Configure whether document signing should be synchronous or asynchronous\nThis endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "mode" + ], + "properties": { + "mode": { + "type": "string", + "description": "Signing mode: \"sync\" or \"async\"" + }, + "workerType": { + "type": "string", + "nullable": true, + "description": "Worker type when async: \"local\" or \"external\" (optional)" + } + } + } + } + } + }, + "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": "Settings saved", + "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/MessageResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters", + "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}/admin/docmdp/config": { + "post": { + "operationId": "admin-set-doc-mdp-config", + "summary": "Configure DocMDP signature restrictions", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether to enable DocMDP restrictions" + }, + "defaultLevel": { + "type": "integer", + "format": "int64", + "default": 2, + "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" + } + } + } + } + } + }, + "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": "Configuration saved successfully", + "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/MessageResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid DocMDP level provided", + "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}/admin/active-signings": { + "get": { + "operationId": "admin-get-active-signings", + "summary": "Get list of files currently being signed (status = SIGNING_IN_PROGRESS)", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "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": "List of active signings", + "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/ActiveSigningsResponse" + } + } + } + } + } + } + } + }, + "500": { + "description": "", + "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}/crl/list": { + "get": { + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", + "description": "This endpoint requires admin access", + "tags": [ + "crl_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "default": "v1" + } + }, + { + "name": "page", + "in": "query", + "description": "Page number (1-based)", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "length", + "in": "query", + "description": "Number of items per page", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "status", + "in": "query", + "description": "Filter by status (issued, revoked, expired)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "engine", + "in": "query", + "description": "Filter by engine type", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "instanceId", + "in": "query", + "description": "Filter by instance ID", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "generation", + "in": "query", + "description": "Filter by generation", + "schema": { + "type": "integer", + "format": "int64", + "nullable": true + } + }, + { + "name": "owner", + "in": "query", + "description": "Filter by owner", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "serialNumber", + "in": "query", + "description": "Filter by serial number (partial match)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "revokedBy", + "in": "query", + "description": "Filter by who revoked the certificate", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortBy", + "in": "query", + "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "name": "sortOrder", + "in": "query", + "description": "Sort order (ASC or DESC)", + "schema": { + "type": "string", + "nullable": true + } + }, + { + "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": "CRL entries retrieved successfully", + "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/CrlListResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { + "post": { + "operationId": "crl_api-revoke", + "summary": "Revoke a certificate by serial number", + "description": "This endpoint requires admin access", + "tags": [ + "crl_api" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "serialNumber" + ], + "properties": { + "serialNumber": { + "type": "string", + "description": "Certificate serial number to revoke" + }, + "reasonCode": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Revocation reason code (0-10, see RFC 5280)" + }, + "reasonText": { + "type": "string", + "nullable": true, + "description": "Optional text describing the reason" + } + } + } + } + } + }, + "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": "Certificate revoked successfully", + "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/CrlRevokeResponse" + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid parameters", + "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/CrlRevokeResponse" + } + } + } + } + } + } + } + }, + "404": { + "description": "Certificate not found", + "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/CrlRevokeResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/system/{policyKey}": { + "get": { + "operationId": "policy-get-system", + "summary": "Read explicit system policy configuration", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ { "bearer_auth": [] }, @@ -13011,105 +14277,256 @@ } }, { - "name": "page", - "in": "query", - "description": "Page number (1-based)", + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read from the system layer.", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "nullable": true + "type": "string", + "pattern": "^[a-z0-9_]+$" } }, { - "name": "length", - "in": "query", - "description": "Number of items per page", + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "nullable": true + "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/SystemPolicyResponse" + } + } + } + } + } + } } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] }, { - "name": "status", - "in": "query", - "description": "Filter by status (issued, revoked, expired)", - "schema": { - "type": "string", - "nullable": true + "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" + } + ] + }, + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether lower layers may override this system default." + } + } + } } - }, + } + }, + "parameters": [ { - "name": "engine", - "in": "query", - "description": "Filter by engine type", + "name": "apiVersion", + "in": "path", + "required": true, "schema": { "type": "string", - "nullable": true + "enum": [ + "v1" + ], + "default": "v1" } }, { - "name": "instanceId", - "in": "query", - "description": "Filter by instance ID", + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist at the system layer.", + "required": true, "schema": { "type": "string", - "nullable": true + "pattern": "^[a-z0-9_]+$" } }, { - "name": "generation", - "in": "query", - "description": "Filter by generation", + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, "schema": { - "type": "integer", - "format": "int64", - "nullable": true + "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" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/policies/user/{userId}/{policyKey}": { + "get": { + "operationId": "policy-get-user-policy-for-user", + "summary": "Read an explicit user-level policy for a target user (admin scope)", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ { - "name": "owner", - "in": "query", - "description": "Filter by owner", - "schema": { - "type": "string", - "nullable": true - } + "bearer_auth": [] }, { - "name": "serialNumber", - "in": "query", - "description": "Filter by serial number (partial match)", - "schema": { - "type": "string", - "nullable": true - } - }, + "basic_auth": [] + } + ], + "parameters": [ { - "name": "revokedBy", - "in": "query", - "description": "Filter by who revoked the certificate", + "name": "apiVersion", + "in": "path", + "required": true, "schema": { "type": "string", - "nullable": true + "enum": [ + "v1" + ], + "default": "v1" } }, { - "name": "sortBy", - "in": "query", - "description": "Sort field (e.g., 'revoked_at', 'issued_at', 'serial_number')", + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, "schema": { "type": "string", - "nullable": true + "pattern": "^[^/]+$" } }, { - "name": "sortOrder", - "in": "query", - "description": "Sort order (ASC or DESC)", + "name": "policyKey", + "in": "path", + "description": "Policy identifier to read for the selected user.", + "required": true, "schema": { "type": "string", - "nullable": true + "pattern": "^[a-z0-9_]+$" } }, { @@ -13125,7 +14542,7 @@ ], "responses": { "200": { - "description": "CRL entries retrieved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -13145,7 +14562,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlListResponse" + "$ref": "#/components/schemas/UserPolicyResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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" } } } @@ -13155,15 +14602,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { - "post": { - "operationId": "crl_api-revoke", - "summary": "Revoke a certificate by serial number", + }, + "put": { + "operationId": "policy-set-user-policy-for-user", + "summary": "Save an explicit user policy for a target user (admin scope)", "description": "This endpoint requires admin access", "tags": [ - "crl_api" + "policy" ], "security": [ { @@ -13174,29 +14619,36 @@ } ], "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", - "required": [ - "serialNumber" - ], "properties": { - "serialNumber": { - "type": "string", - "description": "Certificate serial number to revoke" - }, - "reasonCode": { - "type": "integer", - "format": "int64", + "value": { "nullable": true, - "description": "Revocation reason code (0-10, see RFC 5280)" + "description": "Policy value to persist as assigned target user policy.", + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] }, - "reasonText": { - "type": "string", - "nullable": true, - "description": "Optional text describing the reason" + "allowChildOverride": { + "type": "boolean", + "default": false, + "description": "Whether the target user may still override the assigned value in personal preferences." } } } @@ -13216,6 +14668,26 @@ "default": "v1" } }, + { + "name": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to persist for the target user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -13229,7 +14701,7 @@ ], "responses": { "200": { - "description": "Certificate revoked successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -13249,7 +14721,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -13259,7 +14731,7 @@ } }, "400": { - "description": "Invalid parameters", + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -13279,7 +14751,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13288,8 +14760,8 @@ } } }, - "404": { - "description": "Certificate not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -13309,7 +14781,129 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "policy-clear-user-policy-for-user", + "summary": "Clear an explicit user policy for a target user (admin scope)", + "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": "userId", + "in": "path", + "description": "Target user identifier that receives the policy assignment removal.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[^/]+$" + } + }, + { + "name": "policyKey", + "in": "path", + "description": "Policy identifier to clear for the target 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/UserPolicyWriteResponse" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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" } } } diff --git a/openapi.json b/openapi.json index e3fac16bee..e1853996e7 100644 --- a/openapi.json +++ b/openapi.json @@ -620,6 +620,113 @@ } ] }, + "EffectivePoliciesResponse": { + "type": "object", + "required": [ + "policies" + ], + "properties": { + "policies": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/EffectivePolicyState" + } + } + } + }, + "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", + "groupCount", + "userCount" + ], + "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 + }, + "groupCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "userCount": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + }, + "EffectivePolicyValue": { + "nullable": true, + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + } + ] + }, "ErrorItem": { "type": "object", "required": [ @@ -1023,6 +1130,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" + } + ] + }, "IdDocs": { "type": "object", "required": [ @@ -1386,6 +1556,37 @@ } } }, + "PolicySnapshotEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "string" + }, + "sourceScope": { + "type": "string" + } + } + }, + "PolicySnapshotNumericEntry": { + "type": "object", + "required": [ + "effectiveValue", + "sourceScope" + ], + "properties": { + "effectiveValue": { + "type": "integer", + "format": "int64" + }, + "sourceScope": { + "type": "string" + } + } + }, "ProgressError": { "type": "object", "required": [ @@ -1904,6 +2105,16 @@ } } }, + "SystemPolicyWriteResponse": { + "allOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/EffectivePolicyResponse" + } + ] + }, "UserElement": { "type": "object", "required": [ @@ -2023,6 +2234,9 @@ "original_file_deleted": { "type": "boolean" }, + "policy_snapshot": { + "$ref": "#/components/schemas/ValidatePolicySnapshot" + }, "pdfVersion": { "type": "string" }, @@ -2031,6 +2245,17 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -7407,6 +7632,737 @@ } } }, + "/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}/policies/group/{groupId}/{policyKey}": { + "get": { + "operationId": "policy-get-group", + "summary": "Read a group-level policy value", + "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" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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" + } + } + } + } + } + } + } + } + } + }, + "put": { + "operationId": "policy-set-group", + "summary": "Save a group-level policy value", + "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" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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", + "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" + } + } + } + } + } + } + } + }, + "403": { + "description": "Forbidden", + "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/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" + } + } + } + } + } + } + } + } + } + }, + "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" + } + } + } + } + } + } + } + } + } + } + }, "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { "post": { "operationId": "request_signature-request", @@ -7476,7 +8432,7 @@ "signatureFlow": { "type": "string", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution." } } } @@ -7644,7 +8600,7 @@ "signatureFlow": { "type": "string", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses the effective policy resolution." }, "name": { "type": "string", diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8cfbc241f5..00be3dd384 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -3,12 +3,28 @@ * SPDX-License-Identifier: AGPL-3.0-or-later */ -import type { Page } from '@playwright/test' -import { expect, test } from '@playwright/test' +import type { APIRequestContext, Page } from '@playwright/test' +import { expect, request, test } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, deleteAppConfig, getAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink } from '../support/mailpit' +const FOOTER_POLICY_KEY = 'add_footer' +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) + +test.setTimeout(120_000) + async function addEmailSigner( page: Page, email: string, @@ -27,106 +43,166 @@ async function addEmailSigner( await page.getByRole('button', { name: 'Save' }).click() } -test('request signatures from two signers in sequential order', async ({ page }) => { - await login( - page.request, - process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', - process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', - ) - - await configureOpenSsl(page.request, 'LibreSign Test', { - C: 'BR', - OU: ['Organization Unit'], - ST: 'Rio de Janeiro', - O: 'LibreSign', - L: 'Rio de Janeiro', +async function makeAdminContext(): Promise { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${adminUser}:${adminPassword}`).toString('base64'), + 'Content-Type': 'application/json', + }, + }) +} + +async function setSystemFooterPolicy( + ctx: APIRequestContext, + value: string, +): Promise { + const response = await ctx.post(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${FOOTER_POLICY_KEY}`, { + data: { + value, + allowChildOverride: true, + }, + failOnStatusCode: false, }) - await setAppConfig( - page.request, - 'libresign', - 'identify_methods', - JSON.stringify([ - { name: 'account', enabled: false, mandatory: false }, - { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, - ]), - ) - await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') - await deleteAppConfig(page.request, 'libresign', 'tsa_url') + expect(response.status(), `setSystemFooterPolicy: expected 200 but got ${response.status()}`).toBe(200) +} + +async function restoreAppConfig( + requestContext: APIRequestContext, + key: string, + value: string | null, +): Promise { + if (value === null) { + await deleteAppConfig(requestContext, 'libresign', key) + return + } + + await setAppConfig(requestContext, 'libresign', key, value) +} + +test('request signatures from two signers in sequential order', async ({ page }) => { + const adminContext = await makeAdminContext() + const originalIdentifyMethods = await getAppConfig(page.request, 'libresign', 'identify_methods') + const originalSignatureEngine = await getAppConfig(page.request, 'libresign', 'signature_engine') + const originalTsaUrl = await getAppConfig(page.request, 'libresign', 'tsa_url') + + await test.step('configure signing environment', async () => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await configureOpenSsl(page.request, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + page.request, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') + await setSystemFooterPolicy(adminContext, FOOTER_DISABLED_VALUE) + }) const mailpit = createMailpitClient() await mailpit.deleteMessages() - await page.goto('./apps/libresign') - await page.getByRole('button', { name: 'Upload from URL' }).click() - await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') - await page.getByRole('button', { name: 'Send' }).click() - - // Add first signer — only email method is active, so the field appears directly (no tabs) - await addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01') - - // Add second signer - await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') - - // Enable sequential signing. - // The checkbox input is hidden by CSS; click the visible label text to toggle it. - await expect(page.getByLabel('Sign in order')).toBeVisible() - await page.getByText('Sign in order').click() - await expect(page.getByLabel('Sign in order')).toBeChecked() - - // Send the signature request - await page.getByRole('button', { name: 'Request signatures' }).click() - await page.getByRole('button', { name: 'Send' }).click() - - // In sequential mode only signer01 (order 1) gets the email immediately. - // Proof: signer01's email arrives, but signer02's does NOT at this point. - const email01 = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') - - const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) - expect(afterFirst.messages).toHaveLength(1) - - // Keep the browser unauthenticated before opening a public sign link. - // This avoids logout redirects to absolute hosts that may differ per environment. - await page.context().clearCookies() - await page.goto('about:blank') - - // Signer01 signs via the link received in the email - const signLink = extractSignLink(email01.Text) - if (!signLink) throw new Error('Sign link not found in email') - await page.goto(signLink) - await page.getByRole('button', { name: 'Sign the document.' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') - await expect(page.getByText('This document is valid')).toBeVisible() - // Signer01 signed; signer02 is still waiting (sequential mode proof at this point) - await expect(page.getByText('Signer 01')).toBeVisible() - await page.getByRole('button', { name: 'Expand details of Signer 01' }).click() - await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); - await page.getByRole('link', { name: 'Document integrity verified' }).click(); - await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); - await page.getByRole('link', { name: 'Document has not been' }).click(); - - await expect(page.getByText('Signer 02')).toBeVisible() - await expect(page.getByText('Not signed yet')).toBeVisible() - - // Now that signer01 has signed, signer02 must receive their notification. - const email02 = await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign') - - const afterSecond = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) - expect(afterSecond.messages).toHaveLength(2) - - // Signer02 signs via their email link. - // The admin is still logged out from the signer01 step, so this is unauthenticated. - const signLink02 = extractSignLink(email02.Text) - if (!signLink02) throw new Error('Sign link for signer02 not found in email') - await page.goto(signLink02) - await page.getByRole('button', { name: 'Sign the document.' }).click() - await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') - await expect(page.getByText('This document is valid')).toBeVisible() - - // Both signers must appear as signed in the final validation view. - await expect(page.getByText('Signer 01')).toBeVisible() - await expect(page.getByText('Signer 02')).toBeVisible() - await expect(page.getByText('Not signed yet')).not.toBeVisible() + try { + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + + // Add first signer — only email method is active, so the field appears directly (no tabs) + await addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01') + + // Add second signer + await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') + + // Enable sequential signing. + // The checkbox input is hidden by CSS; click the visible label text to toggle it. + await expect(page.getByLabel('Sign in order')).toBeVisible() + await page.getByText('Sign in order').click() + await expect(page.getByLabel('Sign in order')).toBeChecked() + + // Send the signature request + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + // In sequential mode only signer01 (order 1) gets the email immediately. + // Proof: signer01's email arrives, but signer02's does NOT at this point. + const email01 = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: There is a file for you to sign') + + const afterFirst = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) + expect(afterFirst.messages).toHaveLength(1) + + // Keep the browser unauthenticated before opening a public sign link. + // This avoids logout redirects to absolute hosts that may differ per environment. + await page.context().clearCookies() + await page.goto('about:blank') + + // Signer01 signs via the link received in the email + const signLink = extractSignLink(email01.Text) + if (!signLink) throw new Error('Sign link not found in email') + await page.goto(signLink) + await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.getByRole('button', { name: 'Sign document' }).click() + await page.waitForURL('**/validation/**') + await expect(page.getByText('This document is valid')).toBeVisible() + // Signer01 signed; signer02 is still waiting (sequential mode proof at this point) + await expect(page.getByText('Signer 01')).toBeVisible() + await page.getByRole('button', { name: 'Expand details of Signer 01' }).click() + await page.getByRole('button', { name: 'Expand validation status', exact: true }).click(); + await page.getByRole('link', { name: 'Document integrity verified' }).click(); + await page.getByRole('button', { name: 'Expand document certification', exact: true }).click(); + await page.getByRole('link', { name: 'Document has not been' }).click(); + + await expect(page.getByText('Signer 02')).toBeVisible() + await expect(page.getByText('Not signed yet')).toBeVisible() + + // Now that signer01 has signed, signer02 must receive their notification. + const email02 = await waitForEmailTo(mailpit, 'signer02@libresign.coop', 'LibreSign: There is a file for you to sign') + + const afterSecond = await mailpit.searchMessages({ query: 'subject:"LibreSign: There is a file for you to sign"' }) + expect(afterSecond.messages).toHaveLength(2) + + // Signer02 signs via their email link. + // The admin is still logged out from the signer01 step, so this is unauthenticated. + const signLink02 = extractSignLink(email02.Text) + if (!signLink02) throw new Error('Sign link for signer02 not found in email') + await page.goto(signLink02) + await page.getByRole('button', { name: 'Sign the document.' }).click() + await page.getByRole('button', { name: 'Sign document' }).click() + await page.waitForURL('**/validation/**') + await expect(page.getByText('This document is valid')).toBeVisible() + + // Both signers must appear as signed in the final validation view. + await expect(page.getByText('Signer 01')).toBeVisible() + await expect(page.getByText('Signer 02')).toBeVisible() + await expect(page.getByText('Not signed yet')).not.toBeVisible() + } finally { + await restoreAppConfig(page.request, 'identify_methods', originalIdentifyMethods) + await restoreAppConfig(page.request, 'signature_engine', originalSignatureEngine) + await restoreAppConfig(page.request, 'tsa_url', originalTsaUrl) + await setSystemFooterPolicy(adminContext, FOOTER_ENABLED_VALUE) + await adminContext.dispose() + } }) diff --git a/playwright/e2e/policy-settings-menu-visibility.spec.ts b/playwright/e2e/policy-settings-menu-visibility.spec.ts new file mode 100644 index 0000000000..2b70beec7b --- /dev/null +++ b/playwright/e2e/policy-settings-menu-visibility.spec.ts @@ -0,0 +1,287 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Scenario: Policies menu visibility follows delegated group rules. + * + * 1. (API) Instance admin creates a group policy for GROUP_ID with + * allowChildOverride:true, so the group admin can manage rules. + * 2. (Browser) Log in as group admin → "Policies" nav item must be visible. + * 3. (Browser) Navigate to Policies → see the editable policy card. + * 4. (Browser) Click "Configure" → setting dialog opens. + * 5. (Browser) Click "Create rule" inside dialog → scope-selector dialog opens. + * 6. (API) Instance admin deletes the group policy. + * 7. (Browser) Reload as group admin → "Policies" nav item must be hidden. + * + * All admin-side operations are performed via the OCS API so no admin browser + * session is needed, keeping the test as fast as possible. + */ + +import { expect, request, test, type APIRequestContext } from '@playwright/test' +import { login } from '../support/nc-login' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' + +// One serial block: a single browser session for the group admin +// across both phases avoids repeated login overhead. +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const GROUP_ID = 'policy-menu-visibility-group' +const GROUP_ADMIN = 'policy-menu-visibility-admin' +const GROUP_ADMIN_PASSWORD = '123456' + +const POLICY_KEY = 'add_footer' +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) + +// ─── Admin API helpers (no browser needed) ──────────────────────────────────── + +async function makeAdminContext(): Promise { + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASSWORD}`).toString('base64'), + 'Content-Type': 'application/json', + }, + }) +} + +/** + * POST /policies/system/{key} — establish the instance-wide default and allow + * group admins to override it (allowChildOverride: true). + */ +async function setSystemPolicy( + ctx: APIRequestContext, + value: string | null, + allowChildOverride: boolean, +): Promise { + const resp = await ctx.post( + `./ocs/v2.php/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { data: { value, allowChildOverride }, failOnStatusCode: false }, + ) + expect(resp.status(), `setSystemPolicy: expected 200 but got ${resp.status()}`).toBe(200) +} + +/** + * PUT /policies/group/{group}/{key} — create/update a rule scoped to GROUP_ID. + * This increments groupCount in effective-policies so the menu visibility check + * passes in Settings.vue. + */ +async function setGroupPolicy( + ctx: APIRequestContext, + value: string, + allowChildOverride: boolean, +): Promise { + const resp = await ctx.put( + `./ocs/v2.php/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { data: { value, allowChildOverride }, failOnStatusCode: false }, + ) + expect(resp.status(), `setGroupPolicy: expected 200 but got ${resp.status()}`).toBe(200) +} + +async function getEffectivePolicy( + ctx: APIRequestContext, +): Promise<{ editableByCurrentActor?: boolean, groupCount?: number } | null> { + const response = await ctx.get('./ocs/v2.php/apps/libresign/api/v1/policies/effective', { + failOnStatusCode: false, + }) + expect(response.status(), `getEffectivePolicy: expected 200 but got ${response.status()}`).toBe(200) + const data = await response.json() as { + ocs?: { + data?: { + policies?: Record + } + } + } + + return data.ocs?.data?.policies?.[POLICY_KEY] ?? null +} + +/** + * DELETE /policies/group/{group}/{key} — remove the rule so groupCount drops to 0 + * and the Policies nav item disappears for the group admin. + */ +async function deleteGroupPolicy(ctx: APIRequestContext): Promise { + await ctx.delete( + `./ocs/v2.php/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { failOnStatusCode: false }, + ) +} + +async function expandSettingsMenu(page: import('@playwright/test').Page): Promise { + await page.keyboard.press('Escape').catch(() => {}) + const sidebar = page.locator('#app-navigation-vue') + const settingsLink = sidebar.getByRole('link', { name: 'Account' }) + if (await settingsLink.count()) { + return + } + + const settingsToggle = sidebar.getByRole('button', { name: 'Settings' }) + if (await settingsToggle.count()) { + await settingsToggle.first().click() + } +} + +// ─── Test ───────────────────────────────────────────────────────────────────── + +test('policies nav item is visible only when group admin can edit at least one delegated rule', async ({ page }) => { + const adminCtx = await makeAdminContext() + + try { + // ── 0. Provision users/groups (idempotent; safe to call on every run) ── + + await ensureUserExists(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, GROUP_ADMIN, GROUP_ID) + // subadmin role → can_manage_group_policies: true in initial state + await ensureSubadminOfGroup(page.request, GROUP_ADMIN, GROUP_ID) + + // ── 1. Admin: create group policy ───────────────────────────────────── + // + // System policy must allow child overrides so the workbench unlocks the + // "Create rule" button for the group admin. + await setSystemPolicy(adminCtx, FOOTER_ENABLED_VALUE, true) + // Group-scoped rule → groupCount becomes ≥ 1 in the effective-policies + // API response seen by the group admin. + await setGroupPolicy(adminCtx, FOOTER_ENABLED_VALUE, true) + + const groupAdminCtx = await request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${GROUP_ADMIN}:${GROUP_ADMIN_PASSWORD}`).toString('base64'), + }, + }) + + const editablePolicy = await getEffectivePolicy(groupAdminCtx) + expect(editablePolicy?.groupCount).toBeGreaterThan(0) + expect(editablePolicy?.editableByCurrentActor).toBe(true) + await groupAdminCtx.dispose() + + // ── 2. Log in as group admin ─────────────────────────────────────────── + + await login(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + + // Preferences page mounts Preferences.vue which calls fetchEffectivePolicies() + // on onMounted, populating the Pinia store that Settings.vue reads reactively. + await page.goto('./apps/libresign/f/preferences') + + // ── 3. "Policies" must appear in the settings sidebar ───────────────── + await expandSettingsMenu(page) + + const policiesNavItem = page.getByRole('link', { name: 'Policies' }) + await expect(policiesNavItem, 'Policies link should be visible when a delegated group rule exists').toBeVisible({ timeout: 20000 }) + + // ── 4. Navigate to the Policies page ────────────────────────────────── + + await policiesNavItem.click() + await expect(page).toHaveURL(/\/f\/policies/, { timeout: 10000 }) + + // ── 5. The editable policy card must be visible in the workbench ────── + // + // In group-admin viewMode, only policies satisfying + // groupCount > 0 AND editableByCurrentActor === true + // are rendered. "Signing order" (signature_flow) matches because we set + // system allowChildOverride:true and a group rule for GROUP_ID. + + const configureButton = page + .getByRole('button', { name: /Configure/i }) + .first() + await expect(configureButton, 'At least one Configure button should be visible for the group admin').toBeVisible({ timeout: 15000 }) + + // ── 6. Open the setting dialog ("Signing order") ────────────────────── + + await configureButton.click() + + const settingDialog = page.getByRole('dialog', { name: /Signature footer/i }) + await expect(settingDialog, '"Signature footer" dialog should open on click').toBeVisible({ timeout: 10000 }) + + // ── 7. "Create rule" button must be available inside the dialog ─────── + + const createRuleButton = settingDialog.getByRole('button', { name: /Create rule/i }) + await expect(createRuleButton, '"Create rule" button should be enabled in the policy dialog').toBeVisible() + await expect(createRuleButton).toBeEnabled() + + // ── 8. Clicking "Create rule" opens the scope-selector ("create policy modal") ── + + await createRuleButton.click() + + // The scope-selector dialog title is "What do you want to create?" + // (falls back to "Create rule" if the workbench skips the selector) + const createPolicyDialog = page + .getByRole('dialog', { name: /What do you want to create\?|Create rule/i }) + .last() + await expect(createPolicyDialog, 'Create-policy modal should appear after clicking Create rule').toBeVisible({ timeout: 10000 }) + + // Close with Escape — no actual rule is created + await page.keyboard.press('Escape') + await expect(createPolicyDialog).toBeHidden({ timeout: 5000 }) + + // ── 9. Admin: lock the only group rule (no lower-level override) ────── + // + // With only one group rule and allowChildOverride:false, lower layers are + // locked, but the group admin still governs that group layer itself. + // The menu must remain visible while at least one delegated rule exists. + await setGroupPolicy(adminCtx, FOOTER_ENABLED_VALUE, false) + + const lockedGroupAdminCtx = await request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: 'Basic ' + Buffer.from(`${GROUP_ADMIN}:${GROUP_ADMIN_PASSWORD}`).toString('base64'), + }, + }) + + const lockedPolicy = await getEffectivePolicy(lockedGroupAdminCtx) + expect(lockedPolicy?.groupCount).toBeGreaterThan(0) + expect(lockedPolicy?.editableByCurrentActor).toBe(true) + await lockedGroupAdminCtx.dispose() + + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + await expect(page.getByRole('link', { name: 'Policies' }), 'Policies link should stay visible while the group admin can manage the delegated rule').toBeVisible({ timeout: 20000 }) + + // ── 10. Admin: remove the group policy ──────────────────────────────── + + await deleteGroupPolicy(adminCtx) + + // ── 11. Reload as group admin to refresh effective-policies state ────── + // + // A full navigation re-triggers fetchEffectivePolicies() in Preferences.vue, + // causing Settings.vue's hasDelegatedPolicies computed to update reactively. + + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + // ── 12. "Policies" must no longer appear in the settings sidebar ─────── + + await expect(page.getByRole('link', { name: 'Policies' }), 'Policies link should be gone after the group rule is removed').toBeHidden({ timeout: 20000 }) + } finally { + // Always restore the environment so other tests are not affected. + await deleteGroupPolicy(adminCtx).catch(() => {}) + await setSystemPolicy(adminCtx, null, true).catch(() => {}) + await adminCtx.dispose() + } +}) diff --git a/playwright/e2e/policy-workbench-personas-permissions.spec.ts b/playwright/e2e/policy-workbench-personas-permissions.spec.ts new file mode 100644 index 0000000000..3975074d84 --- /dev/null +++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts @@ -0,0 +1,251 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, request, test, type APIRequestContext } from '@playwright/test' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' + +test.describe.configure({ retries: 0, timeout: 90000 }) + +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' +const DEFAULT_TEST_PASSWORD = '123456' + +const GROUP_ID = 'policy-e2e-group' +const GROUP_ADMIN_USER = 'policy-e2e-group-admin' +const END_USER = 'policy-e2e-end-user' +const INSTANCE_RESET_USER = 'policy-e2e-instance-reset-user' +const POLICY_KEY = 'signature_flow' + +type OcsPolicyResponse = { + ocs?: { + meta?: { + statuscode?: number + message?: string + } + data?: Record + } +} + +async function policyRequest( + requestContext: APIRequestContext, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record, +) { + const requestUrl = `./ocs/v2.php${path}` + const requestOptions = { + data: body, + failOnStatusCode: false, + } + + const response = method === 'GET' + ? await requestContext.get(requestUrl, requestOptions) + : method === 'POST' + ? await requestContext.post(requestUrl, requestOptions) + : method === 'PUT' + ? await requestContext.put(requestUrl, requestOptions) + : await requestContext.delete(requestUrl, requestOptions) + + const text = await response.text() + const parsed = text ? JSON.parse(text) as OcsPolicyResponse : { ocs: { data: {} } } + + return { + httpStatus: response.status(), + statusCode: parsed.ocs?.meta?.statuscode ?? response.status(), + message: parsed.ocs?.meta?.message ?? '', + data: parsed.ocs?.data ?? {}, + } +} + +async function getEffectivePolicy( + requestContext: APIRequestContext, +) { + const result = await policyRequest(requestContext, 'GET', `/apps/libresign/api/v1/policies/effective`) + const policies = (result.data.policies ?? {}) as Record + + return policies[POLICY_KEY] ?? null +} + +async function clearOwnUserPreference( + requestContext: APIRequestContext, +) { + const result = await policyRequest(requestContext, 'DELETE', `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`) + expect([200, 500]).toContain(result.httpStatus) +} + +async function createAuthenticatedRequestContext(authUser: string, authPassword: string): Promise { + const auth = 'Basic ' + Buffer.from(`${authUser}:${authPassword}`).toString('base64') + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + 'Content-Type': 'application/json', + }, + }) +} + +test('personas can manage policies according to permissions and override toggles', async ({ page }) => { + await ensureUserExists(page.request, GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, GROUP_ADMIN_USER, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await ensureSubadminOfGroup(page.request, GROUP_ADMIN_USER, GROUP_ID) + + const adminRequest = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + const groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + const endUserRequest = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + + try { + + // Normalize user-level state before assertions. + await clearOwnUserPreference(groupAdminRequest) + await clearOwnUserPreference(endUserRequest) + + // Global admin defines baseline and group policy with override enabled. + let result = await policyRequest( + adminRequest, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + result = await policyRequest( + adminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + // Group admin can edit own group rule. + result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + const groupPolicyReadback = await policyRequest( + groupAdminRequest, + 'GET', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + ) + expect(groupPolicyReadback.httpStatus).toBe(200) + expect(groupPolicyReadback.data?.policy).toMatchObject({ + targetId: GROUP_ID, + policyKey: POLICY_KEY, + value: 'ordered_numeric', + allowChildOverride: false, + }) + + // End user cannot manage group policy and cannot save user preference while group blocks lower layers. + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(403) + + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`, + { value: 'parallel' }, + ) + expect(result.httpStatus).toBe(400) + + let endUserEffective = await getEffectivePolicy(endUserRequest) + expect(endUserEffective?.effectiveValue).toBe('ordered_numeric') + expect(endUserEffective?.canSaveAsUserDefault).toBe(false) + + // Group admin enables lower-layer overrides again. + result = await policyRequest( + groupAdminRequest, + 'PUT', + `/apps/libresign/api/v1/policies/group/${GROUP_ID}/${POLICY_KEY}`, + { value: 'ordered_numeric', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + // End user can now save personal preference and it becomes effective. + result = await policyRequest( + endUserRequest, + 'PUT', + `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`, + { value: 'parallel' }, + ) + expect(result.httpStatus).toBe(200) + + endUserEffective = await getEffectivePolicy(endUserRequest) + expect(endUserEffective?.effectiveValue).toBe('parallel') + expect(endUserEffective?.sourceScope).toBe('user') + expect(endUserEffective?.canSaveAsUserDefault).toBe(true) + } finally { + await Promise.all([ + adminRequest.dispose(), + groupAdminRequest.dispose(), + endUserRequest.dispose(), + ]) + } +}) + +test('admin can remove explicit instance policy and restore system baseline', async ({ page }) => { + await ensureUserExists(page.request, INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + const adminRequest = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + const instanceResetUserRequest = await createAuthenticatedRequestContext(INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + try { + await clearOwnUserPreference(instanceResetUserRequest) + + let result = await policyRequest( + adminRequest, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + let effectivePolicy = await getEffectivePolicy(instanceResetUserRequest) + expect(effectivePolicy?.effectiveValue).toBe('parallel') + expect(effectivePolicy?.sourceScope).toBe('global') + + result = await policyRequest( + adminRequest, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + effectivePolicy = await getEffectivePolicy(instanceResetUserRequest) + expect(effectivePolicy?.effectiveValue).toBe('none') + expect(effectivePolicy?.sourceScope).toBe('system') + } finally { + await Promise.all([ + adminRequest.dispose(), + instanceResetUserRequest.dispose(), + ]) + } +}) 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..79fc518b97 --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,434 @@ +/** + * 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({ mode: 'serial', retries: 0, timeout: 45000 }) + +const changeDefaultButtonName = /^Change$/i +const removeExceptionButtonName = /Remove exception|Remove rule/i +const userRuleTargetLabel = 'policy-e2e-user' +const instanceWideTargetLabel = 'Default (instance-wide)' +const ruleDialogName = /Create rule|Edit rule|What do you want to create\?/i + +async function getActiveRuleDialog(page: Page): Promise { + const roleDialog = page.getByRole('dialog', { name: ruleDialogName }).last() + if (await roleDialog.isVisible().catch(() => false)) { + return roleDialog + } + + const headingDialog = page.locator('[role="dialog"]').filter({ + has: page.getByRole('heading', { name: ruleDialogName }), + }).last() + await expect(headingDialog).toBeVisible({ timeout: 8000 }) + return headingDialog +} + +async function openSigningOrderDialog(page: Page) { + const signingOrderCardButton = page.getByRole('button', { name: /Signing order/i }).first() + await expect(signingOrderCardButton).toBeVisible({ timeout: 20000 }) + await signingOrderCardButton.click() + await expect(page.getByLabel('Signing order')).toBeVisible({ timeout: 10000 }) +} + +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.page().locator('[aria-busy="true"]') + await savingOverlays.first().waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {}) +} + +async function setSigningFlow(dialog: Locator, flow: 'parallel' | 'ordered_numeric' | 'none'): Promise { + const label = flow === 'parallel' + ? /Simultaneous \(Parallel\)/i + : flow === 'ordered_numeric' + ? /Sequential/i + : /Let users choose/i + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + const flowRadio = root.getByRole('radio', { name: label }).first() + + if (!(await flowRadio.count())) { + return false + } + + if (!(await flowRadio.isChecked())) { + await flowRadio.click({ force: true }) + if (!(await flowRadio.isChecked())) { + const optionRow = root.locator('.checkbox-radio-switch').filter({ hasText: label }).first() + if (await optionRow.count()) { + await optionRow.click({ force: true }) + } + } + } + return true +} + +async function submitRule(dialog: Locator) { + await waitForEditorIdle(dialog) + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + + const createButton = root.getByRole('button', { name: /Create rule|Create policy rule/i }).last() + if (await createButton.isVisible().catch(() => false)) { + await expect(createButton).toBeEnabled({ timeout: 8000 }) + await createButton.click() + await waitForEditorIdle(dialog) + return + } + + const saveButton = root.getByRole('button', { name: /Save changes|Save policy rule changes|Save rule changes/i }).last() + await expect(saveButton).toBeVisible({ timeout: 8000 }) + await expect(saveButton).toBeEnabled({ timeout: 8000 }) + await saveButton.click() + await waitForEditorIdle(dialog) +} + +async function submitSystemRuleAndWait(dialog: Locator) { + const page = dialog.page() + const saveSystemPolicyResponse = page.waitForResponse((response) => { + return ['POST', 'PUT', 'PATCH'].includes(response.request().method()) + && response.url().includes('/apps/libresign/api/v1/policies/system/signature_flow') + }) + + await submitRule(dialog) + const response = await saveSystemPolicyResponse + expect(response.status(), 'Expected system policy save request to succeed').toBe(200) +} + +async function getSystemSignatureFlowValue(page: Page): Promise { + const response = await page.request.get('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + }) + expect(response.status(), 'Expected system policy fetch request to succeed').toBe(200) + const data = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: unknown + } + } + } + } + + return data.ocs?.data?.policy?.value ?? null +} + +async function clearSystemSignatureFlowValue(page: Page): Promise { + const response = await page.request.post('./ocs/v2.php/apps/libresign/api/v1/policies/system/signature_flow', { + headers: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + }, + data: { + value: null, + allowChildOverride: true, + }, + }) + expect(response.status(), 'Expected system policy reset request to succeed').toBe(200) +} + +function getRuleRow(dialog: Locator, _scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + return dialog.locator('tbody tr').filter({ + hasText: targetLabel, + }).first() +} + +async function openSystemDefaultEditor(dialog: Locator) { + await dialog.getByRole('button', { name: changeDefaultButtonName }).first().click() + await getActiveRuleDialog(dialog.page()) +} + +async function getCreateScopeDialog(page: Page): Promise { + const dialog = await getActiveRuleDialog(page) + await expect(dialog.getByRole('heading', { name: /What do you want to create\?/i })).toBeVisible() + return dialog +} + +async function getCreateScopeOption(page: Page, scopeLabel: 'User' | 'Group' | 'Instance') { + const dialog = await getCreateScopeDialog(page) + return dialog.getByRole('option', { name: new RegExp(`^${scopeLabel}\\b`, 'i') }).first() +} + +async function openRuleActions(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + const row = getRuleRow(dialog, scope, targetLabel) + await expect(row).toBeVisible({ timeout: 8000 }) + await row.getByRole('button', { name: 'Rule actions' }).first().click() + return row +} + +async function clickRuleMenuAction(dialog: Locator, actionName: 'Edit' | 'Remove'): Promise { + const page = dialog.page() + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: new RegExp(`^${actionName}$`, 'i') }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + await actionItem.click() + return true +} + +async function editRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await openRuleActions(dialog, scope, targetLabel) + if (await clickRuleMenuAction(dialog, 'Edit')) { + return + } + await dialog.page().waitForTimeout(200) + } + + expect(false, 'Expected Edit action to be visible in rule menu').toBe(true) +} + +async function removeRule(dialog: Locator, scope: 'Instance' | 'Group' | 'User', targetLabel: string) { + for (let attempt = 0; attempt < 3; attempt += 1) { + await openRuleActions(dialog, scope, targetLabel) + if (await clickRuleMenuAction(dialog, 'Remove')) { + const page = dialog.page() + const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first() + if (await removeExceptionButton.isVisible().catch(() => false)) { + await removeExceptionButton.click() + } else { + const removeExceptionText = page.getByText(/^Remove exception$/i).first() + if (await removeExceptionText.isVisible().catch(() => false)) { + await removeExceptionText.click() + } + } + await waitForEditorIdle(dialog) + await dialog.page().waitForTimeout(150) + return + } + await dialog.page().waitForTimeout(200) + } + + expect(false, 'Expected Remove action to be visible in rule menu').toBe(true) +} + +async function chooseTarget(dialog: Locator, ariaLabel: 'Target groups' | 'Target users', optionText: string) { + await waitForEditorIdle(dialog) + const page = dialog.page() + const activeDialog = await getActiveRuleDialog(page).catch(() => null) + const root = activeDialog ?? dialog + + const combobox = root.getByRole('combobox', { name: ariaLabel }).first() + const labeledInput = root.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) + await page.waitForTimeout(250) + const matchingOption = page.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() + await searchInput.press('Tab').catch(() => {}) + return + } + + const exactTextOption = page.getByText(new RegExp(`^${optionText}$`, 'i')).last() + const exactTextVisible = await exactTextOption.waitFor({ state: 'visible', timeout: 1500 }).then(() => true).catch(() => false) + if (exactTextVisible) { + await exactTextOption.click() + await searchInput.press('Tab').catch(() => {}) + return + } + + const anyOption = page.getByRole('option').first() + const anyVisible = await anyOption.waitFor({ state: 'visible', timeout: 3000 }).then(() => true).catch(() => false) + if (anyVisible) { + await anyOption.click() + await searchInput.press('Tab').catch(() => {}) + return + } + + await searchInput.press('ArrowDown') + await searchInput.press('Enter') + await searchInput.press('Tab').catch(() => {}) + } else { + const fallbackTextbox = root.getByRole('textbox').first() + await fallbackTextbox.fill(optionText) + await fallbackTextbox.press('ArrowDown') + await fallbackTextbox.press('Enter') + await fallbackTextbox.press('Tab').catch(() => {}) + } +} + +async function resetSystemRuleToBaseline(dialog: Locator) { + await clearSystemSignatureFlowValue(dialog.page()) +} + +async function clearExistingRules(dialog: Locator) { + const page = dialog.page() + + for (let round = 0; round < 6; round += 1) { + let removedInRound = false + const actions = dialog.getByRole('button', { name: 'Rule actions' }) + + while ((await actions.count()) > 0) { + const firstAction = actions.first() + if (!(await firstAction.isVisible().catch(() => false))) { + break + } + + const clickedAction = await firstAction.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (!clickedAction) { + await page.waitForTimeout(150) + continue + } + const hasRemoveAction = await clickRuleMenuAction(dialog, 'Remove') + if (!hasRemoveAction) { + break + } + + const removeExceptionButton = page.getByRole('button', { name: removeExceptionButtonName }).first() + if (await removeExceptionButton.isVisible().catch(() => false)) { + await removeExceptionButton.click() + } else { + const removeExceptionText = page.getByText(/^Remove exception$/i).first() + if (await removeExceptionText.isVisible().catch(() => false)) { + await removeExceptionText.click() + } + } + await waitForEditorIdle(dialog) + await page.waitForTimeout(150) + removedInRound = true + } + + if (!removedInRound) { + await page.waitForTimeout(700) + if ((await actions.count()) === 0) { + break + } + } + } + + if (await dialog.getByText(/\(custom\)/i).first().isVisible().catch(() => false)) { + await resetSystemRuleToBaseline(dialog) + } + + await expect(dialog).toBeVisible() +} + +test('system default persists across edit cycles and can be reset to the system baseline', 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 openSigningOrderDialog(page) + + const signingOrderDialog = await getSigningOrderDialog(page) + await clearExistingRules(signingOrderDialog) + + await page.reload() + await openSigningOrderDialog(page) + const stableDialog = await getSigningOrderDialog(page) + + await openSystemDefaultEditor(stableDialog) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in system editor').toBe(true) + await submitSystemRuleAndWait(stableDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + await page.reload() + await openSigningOrderDialog(page) + const reloadedDialog = await getSigningOrderDialog(page) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + await openSystemDefaultEditor(reloadedDialog) + expect(await setSigningFlow(reloadedDialog, 'parallel'), 'Expected signing-flow radios in system editor').toBe(true) + await submitSystemRuleAndWait(reloadedDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('parallel') + + await resetSystemRuleToBaseline(reloadedDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) +}) + +test('admin can manage instance, group, and user rules when system default is fixed', async ({ page }) => { + const userTarget = userRuleTargetLabel + + 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) + await clearExistingRules(dialog) + + await page.reload() + await openSigningOrderDialog(page) + const stableDialog = await getSigningOrderDialog(page) + + // Global rule: edit + await openSystemDefaultEditor(stableDialog) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in global editor').toBe(true) + await submitSystemRuleAndWait(stableDialog) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + + // Instance admins can still create group-level exceptions even when the system default is fixed. + await stableDialog.getByRole('button', { name: 'Create rule' }).first().click() + const groupScopeOption = await getCreateScopeOption(stableDialog.page(), 'Group') + await expect(groupScopeOption).toBeEnabled() + + // User rule: create + const userScopeOption = await getCreateScopeOption(stableDialog.page(), 'User') + await expect(userScopeOption).toBeEnabled() + await userScopeOption.click() + await chooseTarget(stableDialog, 'Target users', userTarget) + expect(await setSigningFlow(stableDialog, 'parallel'), 'Expected signing-flow radios in user editor').toBe(true) + await submitRule(stableDialog) + await expect(stableDialog).toContainText(userTarget) + await expect(stableDialog).toContainText('Simultaneous (Parallel)') + + // User rule: edit + await editRule(stableDialog, 'User', userTarget) + expect(await setSigningFlow(stableDialog, 'ordered_numeric'), 'Expected signing-flow radios in user editor').toBe(true) + await submitRule(stableDialog) + await expect(stableDialog).toContainText(userTarget) + await expect(stableDialog).toContainText('Sequential') + + await page.reload() + await openSigningOrderDialog(page) + const reloadedDialog = await getSigningOrderDialog(page) + expect(await getSystemSignatureFlowValue(page)).toBe('ordered_numeric') + await expect(reloadedDialog).toContainText(userTarget) + await expect(reloadedDialog).toContainText('Sequential') + + // User rule: delete + await removeRule(reloadedDialog, 'User', userTarget) + await expect(reloadedDialog).not.toContainText(userTarget) + + // Global rule: reset to explicit "let users choose" baseline + await resetSystemRuleToBaseline(reloadedDialog) + expect([null, 'none']).toContain(await getSystemSignatureFlowValue(page)) +}) diff --git a/playwright/e2e/send-reminder.spec.ts b/playwright/e2e/send-reminder.spec.ts index 040c5b5133..fe53050b3b 100644 --- a/playwright/e2e/send-reminder.spec.ts +++ b/playwright/e2e/send-reminder.spec.ts @@ -67,7 +67,9 @@ test('admin can send a reminder to a pending signer', async ({ page }) => { // The signer row renders as NcListItem with force-display-actions, so the // three-dots NcActions toggle is always visible (aria-label="Actions"). await page.locator('li').filter({ hasText: 'Signer 01' }).getByRole('button', { name: 'Actions' }).click() - await page.getByRole('menuitem', { name: 'Send reminder' }).click() + const sendReminderAction = page.locator('[role="menuitem"], [role="dialog"] button').filter({ hasText: /^Send reminder$/i }).first() + await expect(sendReminderAction).toBeVisible({ timeout: 8000 }) + await sendReminderAction.click() // The reminder uses a different subject: "LibreSign: Changes into a file for you to sign". await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Changes into a file for you to sign') diff --git a/playwright/e2e/sign-email-token-authenticated.spec.ts b/playwright/e2e/sign-email-token-authenticated.spec.ts index ab5d2f4219..c04ad5d205 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,7 +5,7 @@ import { test, expect } from '@playwright/test' import { login } from '../support/nc-login' -import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { configureOpenSsl, setAppConfig, deleteAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' /** @@ -41,6 +41,10 @@ test('sign document with email token as authenticated signer', async ({ page }) { name: 'email', enabled: true, mandatory: true, signatureMethods: { emailToken: { enabled: true } }, can_create_account: false }, ]), ) + await setAppConfig(page.request, 'libresign', 'signature_engine', 'PhpNative') + await deleteAppConfig(page.request, 'libresign', 'tsa_url') + await setAppConfig(page.request, 'libresign', 'java_path', '/usr/bin/java') + await setAppConfig(page.request, 'libresign', 'pdftk_path', '/usr/bin/pdftk') const mailpit = createMailpitClient() await mailpit.deleteMessages() @@ -87,8 +91,17 @@ test('sign document with email token as authenticated signer', async ({ page }) await expect(page.getByRole('heading', { name: 'Signature confirmation' })).toBeVisible() await expect(page.getByText('Your identity has been')).toBeVisible() + const signResponsePromise = page.waitForResponse((response) => + response.request().method() === 'POST' + && response.url().includes('/apps/libresign/api/v1/sign/'), + ) await page.getByRole('button', { name: 'Sign document' }).click() - await page.waitForURL('**/validation/**') + const signResponse = await signResponsePromise + const signResponseBody = await signResponse.text() + expect( + signResponse.ok(), + `Sign API failed with status ${signResponse.status()}: ${signResponseBody}`, + ).toBeTruthy() await expect(page.getByText('This document is valid')).toBeVisible() await expect(page.getByText('Congratulations you have')).toBeVisible() }) diff --git a/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts new file mode 100644 index 0000000000..81867fe7e0 --- /dev/null +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -0,0 +1,278 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, request, test, type APIRequestContext, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setAppConfig, +} from '../support/nc-provisioning' + +const POLICY_KEY = 'signature_flow' +const GROUP_ADMIN_USER = 'signature-flow-e2e-group-admin' +const GROUP_ADMIN_PASSWORD = '123456' +const GROUP_ADMIN_GROUP = 'signature-flow-e2e-group' + +test.setTimeout(120_000) +test.describe.configure({ mode: 'serial' }) + +type OcsPolicyResponse = { + ocs?: { + meta?: { + statuscode?: number + message?: string + } + data?: Record + } +} + +async function createAuthenticatedRequestContext(authUser: string, authPassword: string): Promise { + const auth = 'Basic ' + Buffer.from(`${authUser}:${authPassword}`).toString('base64') + + return request.newContext({ + baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://localhost', + ignoreHTTPSErrors: true, + extraHTTPHeaders: { + 'OCS-ApiRequest': 'true', + Accept: 'application/json', + Authorization: auth, + }, + }) +} + +async function policyRequest( + requestContext: APIRequestContext, + method: 'POST' | 'DELETE', + path: string, + body?: Record, +) { + const response = method === 'POST' + ? await requestContext.post(`./ocs/v2.php${path}`, { + data: body, + headers: { + 'Content-Type': 'application/json', + }, + failOnStatusCode: false, + }) + : await requestContext.delete(`./ocs/v2.php${path}`, { failOnStatusCode: false }) + + const text = await response.text() + const parsed = text ? JSON.parse(text) as OcsPolicyResponse : { ocs: { data: {} } } + + return { + httpStatus: response.status(), + statusCode: parsed.ocs?.meta?.statuscode ?? response.status(), + message: parsed.ocs?.meta?.message ?? '', + } +} + +async function setSystemSignatureFlowPolicy( + requestContext: APIRequestContext, + value: 'none' | 'parallel' | 'ordered_numeric', + allowChildOverride: boolean, +) { + const result = await policyRequest( + requestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value, allowChildOverride }, + ) + + expect(result.httpStatus, `Failed to set system signature flow policy: ${result.message}`).toBe(200) +} + +async function clearOwnPreference(requestContext: APIRequestContext) { + const result = await policyRequest( + requestContext, + 'DELETE', + `/apps/libresign/api/v1/policies/user/${POLICY_KEY}`, + ) + // Can be 200 (cleared) or 500 when preference doesn't exist in some environments. + expect([200, 500]).toContain(result.httpStatus) +} + +async function addEmailSigner(page: Page, email: string, name: string) { + const dialog = page.getByRole('dialog', { name: 'Add new signer' }) + await page.getByRole('button', { name: 'Add signer' }).click() + await dialog.getByPlaceholder('Email').click() + await dialog.getByPlaceholder('Email').pressSequentially(email, { delay: 50 }) + await expect(page.getByRole('option', { name: email })).toBeVisible({ timeout: 10_000 }) + await page.getByRole('option', { name: email }).click() + await dialog.getByRole('textbox', { name: 'Signer name' }).fill(name) + + const saveSignerResponsePromise = page.waitForResponse((response) => { + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(response.request().method()) + }) + + await dialog.getByRole('button', { name: 'Save' }).click() + const saveSignerResponse = await saveSignerResponsePromise + expect(saveSignerResponse.status()).toBe(200) + await expect(dialog).toBeHidden() +} + +test('request sidebar persists signature flow preference through policies endpoint', async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + const adminRequest = await createAuthenticatedRequestContext(adminUser, adminPassword) + + await login(page.request, adminUser, adminPassword) + + await configureOpenSsl(adminRequest, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequest, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + + try { + await setSystemSignatureFlowPolicy(adminRequest, 'parallel', true) + await clearOwnPreference(adminRequest) + + await page.goto('./apps/libresign') + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + + await addEmailSigner(page, 'signer01@libresign.coop', 'Signer 01') + await addEmailSigner(page, 'signer02@libresign.coop', 'Signer 02') + + // Enable remember preference first, then switch to ordered mode. + // The second action must persist ordered_numeric via policies endpoint. + await expect(page.getByLabel('Use this as my default signing order')).toBeVisible() + await page.getByText('Use this as my default signing order').click() + + const saveOrderedPreference = page.waitForResponse((response) => { + const req = response.request() + return req.method() === 'PUT' + && req.url().includes('/apps/libresign/api/v1/policies/user/signature_flow') + && (req.postData() ?? '').includes('ordered_numeric') + }) + + await expect(page.getByLabel('Sign in order')).toBeVisible() + await page.getByText('Sign in order').click() + await expect(page.getByLabel('Sign in order')).toBeChecked() + + const saveOrderedPreferenceResponse = await saveOrderedPreference + expect(saveOrderedPreferenceResponse.status()).toBe(200) + } finally { + await clearOwnPreference(adminRequest) + await setSystemSignatureFlowPolicy(adminRequest, 'none', true) + await adminRequest.dispose() + } +}) + +for (const systemFlow of ['ordered_numeric', 'parallel'] as const) { + test(`fixed system ${systemFlow} signature flow hides request toggles for groupadmin`, async ({ page }) => { + const adminUser = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' + const adminPassword = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + + const adminRequest = await createAuthenticatedRequestContext(adminUser, adminPassword) + const groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + + await ensureUserExists(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequest, GROUP_ADMIN_GROUP) + await ensureUserInGroup(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + await ensureSubadminOfGroup(adminRequest, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + + await configureOpenSsl(adminRequest, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequest, + 'libresign', + 'identify_methods', + JSON.stringify([ + { name: 'account', enabled: false, mandatory: false }, + { name: 'email', enabled: true, mandatory: true, signatureMethods: { clickToSign: { enabled: true } }, can_create_account: false }, + ]), + ) + + await setAppConfig( + adminRequest, + 'libresign', + 'groups_request_sign', + JSON.stringify(['admin', GROUP_ADMIN_GROUP]), + ) + + try { + await setSystemSignatureFlowPolicy(adminRequest, systemFlow, false) + await clearOwnPreference(groupAdminRequest) + + await login(page.request, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await page.goto('./apps/libresign/f/request') + await expect(page.getByRole('heading', { name: 'Request Signatures' })).toBeVisible() + await page.getByRole('button', { name: 'Upload from URL' }).click() + await page.getByRole('textbox', { name: 'URL of a PDF file' }).fill('https://raw.githubusercontent.com/LibreSign/libresign/main/tests/php/fixtures/pdfs/small_valid.pdf') + await page.getByRole('button', { name: 'Send' }).click() + + await addEmailSigner(page, 'signer11@libresign.coop', 'Signer 11') + await addEmailSigner(page, 'signer12@libresign.coop', 'Signer 12') + + await expect(page.getByLabel('Sign in order')).toBeHidden() + await expect(page.getByLabel('Use this as my default signing order')).toBeHidden() + + const sendRequestResponsePromise = page.waitForResponse((response) => { + const request = response.request() + const body = request.postData() ?? '' + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(request.method()) + && body.includes('"status":1') + }) + + await page.getByRole('button', { name: 'Request signatures' }).click() + await page.getByRole('button', { name: 'Send' }).click() + + const sendRequestResponse = await sendRequestResponsePromise + expect(sendRequestResponse.status()).toBe(200) + const sendRequestPayload = JSON.parse(sendRequestResponse.request().postData() ?? '{}') as { + signatureFlow?: string + } + expect(sendRequestPayload.signatureFlow).toBeUndefined() + + const sendRequestBody = await sendRequestResponse.json() as { + ocs?: { + data?: { + signatureFlow?: string + signers?: Array<{ signingOrder?: number }> + } + } + } + expect(sendRequestBody.ocs?.data?.signatureFlow).toBe(systemFlow) + + if (systemFlow === 'ordered_numeric') { + expect(sendRequestBody.ocs?.data?.signers?.map((signer) => signer.signingOrder)).toEqual([1, 2]) + } + } finally { + await clearOwnPreference(groupAdminRequest) + await setSystemSignatureFlowPolicy(adminRequest, 'none', true) + await setAppConfig(adminRequest, 'libresign', 'groups_request_sign', JSON.stringify(['admin'])) + await Promise.all([ + adminRequest.dispose(), + groupAdminRequest.dispose(), + ]) + } + }) +} diff --git a/playwright/e2e/signature-footer-template-editor.spec.ts b/playwright/e2e/signature-footer-template-editor.spec.ts new file mode 100644 index 0000000000..1c4f63dbff --- /dev/null +++ b/playwright/e2e/signature-footer-template-editor.spec.ts @@ -0,0 +1,101 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test } from '@playwright/test' +import type { Page } from '@playwright/test' +import { login } from '../support/nc-login' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +async function waitForFooterTemplateRequest(page: Page, action: () => Promise) { + const requestPromise = page.waitForRequest((request) => { + return request.method() === 'POST' + && request.url().includes('/apps/libresign/api/v1/admin/footer-template') + }) + + await action() + const request = await requestPromise + return request.postDataJSON() as { + template: string + width: number + height: number + } +} + +test('signature footer template editor updates preview and controls correctly', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + await page.goto('./settings/admin/libresign') + + const addFooterSwitch = page.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer with signature details/i }).first() + await expect(addFooterSwitch).toBeVisible({ timeout: 20000 }) + + const customizeSwitch = page.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }).first() + let customizeVisible = await customizeSwitch.isVisible().catch(() => false) + if (!customizeVisible) { + await addFooterSwitch.click() + customizeVisible = await customizeSwitch.isVisible().catch(() => false) + } + test.skip(!customizeVisible, 'Customize footer template control is not available in this environment.') + await customizeSwitch.click() + + const editorSection = page.locator('.footer-template-section').first() + await expect(editorSection).toBeVisible({ timeout: 20000 }) + + const templateEditor = editorSection.getByRole('textbox', { name: 'Footer template' }).first() + const initialTemplate = `
Playwright bootstrap ${Date.now()}
` + await waitForFooterTemplateRequest(page, async () => { + await templateEditor.click() + await templateEditor.press('Control+a') + await templateEditor.fill(initialTemplate) + }) + + await expect(editorSection.locator('.footer-preview')).toBeVisible({ timeout: 15000 }) + await expect(editorSection.locator('.footer-preview__loading')).toBeHidden({ timeout: 15000 }) + await expect(editorSection.getByText(/Page 1 of 1\./i)).toBeVisible({ timeout: 15000 }) + + const zoomField = editorSection.getByRole('spinbutton', { name: 'Zoom level' }).first() + await expect(zoomField).toHaveValue('100') + + await editorSection.getByRole('button', { name: 'Increase zoom level' }).click() + await expect(zoomField).toHaveValue('110') + + await editorSection.getByRole('button', { name: 'Decrease zoom level' }).click() + await expect(zoomField).toHaveValue('100') + + await zoomField.fill('140') + await zoomField.press('Tab') + await expect(zoomField).toHaveValue('140') + + const widthField = editorSection.getByRole('spinbutton', { name: 'Width' }).first() + const widthPayload = await waitForFooterTemplateRequest(page, async () => { + await widthField.fill('620') + await widthField.press('Tab') + }) + await expect(widthField).toHaveValue('620') + await expect(widthPayload.width).toBe(620) + + const heightField = editorSection.getByRole('spinbutton', { name: 'Height' }).first() + const heightPayload = await waitForFooterTemplateRequest(page, async () => { + await heightField.fill('130') + await heightField.press('Tab') + }) + await expect(heightField).toHaveValue('130') + await expect(heightPayload.height).toBe(130) + + const uniqueTemplate = `
Playwright footer ${Date.now()}
` + const templatePayload = await waitForFooterTemplateRequest(page, async () => { + await templateEditor.click() + await templateEditor.press('Control+a') + await templateEditor.fill(uniqueTemplate) + }) + await expect(templatePayload.template).toContain('Playwright footer') + await expect(editorSection.locator('.footer-preview__loading')).toBeHidden({ timeout: 15000 }) + await expect(editorSection.getByText(/Page 1 of 1\./i)).toBeVisible({ timeout: 15000 }) +}) diff --git a/playwright/support/nc-login.ts b/playwright/support/nc-login.ts index f97f681ac6..775de89217 100644 --- a/playwright/support/nc-login.ts +++ b/playwright/support/nc-login.ts @@ -25,6 +25,12 @@ export async function login( user: string, password: string, ): Promise { + // Ensure a previous authenticated session does not leak across persona switches. + await request.get('./logout', { + failOnStatusCode: false, + maxRedirects: 0, + }).catch(() => {}) + const tokenResponse = await request.get('./csrftoken', { failOnStatusCode: true, }) diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index 39e5665c37..41565609a5 100644 --- a/playwright/support/nc-provisioning.ts +++ b/playwright/support/nc-provisioning.ts @@ -27,6 +27,29 @@ type SignatureElementResponse = { }> } +type HasRootCertResponse = { + hasRootCert?: boolean +} + +type AppConfigResponse = { + data?: string +} + +function toStringList(data: unknown): string[] { + if (Array.isArray(data)) { + return data.filter((item): item is string => typeof item === 'string') + } + + if (data && typeof data === 'object') { + const nested = data as { groups?: unknown[] } + if (Array.isArray(nested.groups)) { + return nested.groups.filter((item): item is string => typeof item === 'string') + } + } + + return [] +} + async function ocsRequest( request: APIRequestContext, method: 'GET' | 'POST' | 'PUT' | 'DELETE', @@ -124,6 +147,95 @@ export async function deleteUser( await ocsRequest(request, 'DELETE', `/cloud/users/${userId}`) } +// --------------------------------------------------------------------------- +// Groups and delegated administration +// --------------------------------------------------------------------------- + +/** + * Creates a group if it does not exist. + */ +export async function ensureGroupExists( + request: APIRequestContext, + groupId: string, +): Promise { + const check = await ocsRequest(request, 'GET', `/cloud/groups?search=${encodeURIComponent(groupId)}`) + const groups = toStringList(check.ocs.data) + if (groups.includes(groupId)) { + return + } + + const create = await ocsRequest(request, 'POST', '/cloud/groups', undefined, undefined, { + groupid: groupId, + }) + if (create.ocs.meta.statuscode !== 200 && create.ocs.meta.statuscode !== 102) { + throw new Error(`Failed to create group "${groupId}": ${create.ocs.meta.message}`) + } +} + +/** + * Adds a user to a group. + */ +export async function ensureUserInGroup( + request: APIRequestContext, + userId: string, + groupId: string, +): Promise { + const groupsResponse = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`) + const groups = toStringList(groupsResponse.ocs.data) + if (groups.includes(groupId)) { + return + } + + const add = await ocsRequest( + request, + 'POST', + `/cloud/users/${encodeURIComponent(userId)}/groups`, + undefined, + undefined, + { groupid: groupId }, + ) + if (add.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to add user "${userId}" to group "${groupId}": ${add.ocs.meta.message}`) + } + + const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/groups`) + if (!toStringList(verify.ocs.data).includes(groupId)) { + throw new Error(`User "${userId}" is not in group "${groupId}" after assignment.`) + } +} + +/** + * Grants subadmin rights for a specific group. + */ +export async function ensureSubadminOfGroup( + request: APIRequestContext, + userId: string, + groupId: string, +): Promise { + const subadmins = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`) + const groups = toStringList(subadmins.ocs.data) + if (groups.includes(groupId)) { + return + } + + const grant = await ocsRequest( + request, + 'POST', + `/cloud/users/${encodeURIComponent(userId)}/subadmins`, + undefined, + undefined, + { groupid: groupId }, + ) + if (grant.ocs.meta.statuscode !== 200) { + throw new Error(`Failed to grant subadmin for user "${userId}" in group "${groupId}": ${grant.ocs.meta.message}`) + } + + const verify = await ocsRequest(request, 'GET', `/cloud/users/${encodeURIComponent(userId)}/subadmins`) + if (!toStringList(verify.ocs.data).includes(groupId)) { + throw new Error(`User "${userId}" was not granted subadmin rights for group "${groupId}".`) + } +} + // --------------------------------------------------------------------------- // App config (equivalent to `occ config:app:set`) // --------------------------------------------------------------------------- @@ -151,6 +263,26 @@ export async function setAppConfig( } } +export async function getAppConfig( + request: APIRequestContext, + appId: string, + key: string, +): Promise { + const result = await ocsRequest( + request, + 'GET', + `/apps/provisioning_api/api/v1/config/apps/${appId}/${key}`, + ) + + if (result.ocs.meta.statuscode === 404) { + return null + } + + return typeof result.ocs.data?.data === 'string' + ? result.ocs.data.data + : null +} + /** * Deletes an app config value. * Equivalent to: `occ config:app:delete ` @@ -197,6 +329,17 @@ export async function configureOpenSsl( commonName: string, names: OpenSslCertNames = {}, ): Promise { + const rootCertCheck = await ocsRequest( + request, + 'GET', + '/apps/libresign/api/v1/setting/has-root-cert', + ) + + if (rootCertCheck.ocs.data?.hasRootCert) { + await clearSignatureElements(request) + return + } + const normalised: OpenSslCertNames = { ...names } if (typeof normalised.OU === 'string') { normalised.OU = [normalised.OU] diff --git a/src/components/FooterTemplateEditor.vue b/src/components/FooterTemplateEditor.vue index 4eb0e78178..4804a6d685 100644 --- a/src/components/FooterTemplateEditor.vue +++ b/src/components/FooterTemplateEditor.vue @@ -14,12 +14,20 @@ {{ t('libresign', 'Available variables') }} + + + + @update:modelValue="onTemplateChange" /> -