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..375c313e90 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -24,12 +24,14 @@ 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; use OCA\Libresign\Settings\Admin; use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\ApiRoute; +use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\ContentSecurityPolicy; use OCP\AppFramework\Http\DataDownloadResponse; @@ -83,6 +85,7 @@ public function __construct( private ReminderService $reminderService, private FooterService $footerService, private DocMdpConfigService $docMdpConfigService, + private PolicyService $policyService, private IdentifyMethodService $identifyMethodService, private FileMapper $fileMapper, ) { @@ -875,7 +878,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) { @@ -894,15 +897,18 @@ public function saveFooterTemplate(string $template = '', int $width = 595, int * @param string $template Template to preview * @param int $width Width of preview in points (default: 595 - A4 width) * @param int $height Height of preview in points (default: 50) + * @param ?bool $writeQrcodeOnFooter Whether to force QR code rendering in footer preview (null uses policy) * @return DataDownloadResponse|DataResponse * * 200: OK * 400: Bad request */ + #[NoAdminRequired] + #[NoCSRFRequired] #[ApiRoute(verb: 'POST', url: '/api/{apiVersion}/admin/footer-template/preview-pdf', requirements: ['apiVersion' => '(v1)'])] - public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50) { + public function footerTemplatePreviewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null) { try { - $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height); + $pdf = $this->footerService->renderPreviewPdf($template ?: null, $width, $height, $writeQrcodeOnFooter); return new DataDownloadResponse($pdf, 'footer-preview.pdf', 'application/pdf'); } catch (\Exception $e) { return new DataResponse([ @@ -960,57 +966,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..a802eaa2fb 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -12,6 +12,7 @@ use OCA\Libresign\Db\FileMapper; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Exception\LibresignException; +use OCA\Libresign\Handler\FooterHandler; use OCA\Libresign\Helper\JSActions; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Middleware\Attribute\PrivateValidation; @@ -23,6 +24,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 +60,8 @@ public function __construct( private AccountService $accountService, protected SignFileService $signFileService, protected RequestSignatureService $requestSignatureService, + private PolicyService $policyService, + private FooterHandler $footerHandler, private SignerElementsService $signerElementsService, protected IL10N $l10n, private IdentifyMethodService $identifyMethodService, @@ -106,7 +110,14 @@ 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('footer_template', $this->footerHandler->getTemplate()); $this->initialState->provideInitialState('docmdp_config', $this->docMdpConfigService->getConfig()); $this->initialState->provideInitialState('legal_information', $this->appConfig->getValueString(Application::APP_ID, 'legal_information')); @@ -120,7 +131,6 @@ public function index(): TemplateResponse { $response = new TemplateResponse(Application::APP_ID, 'main'); $policy = new ContentSecurityPolicy(); - $policy->allowEvalScript(true); $policy->addAllowedFrameDomain('\'self\''); $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); @@ -387,7 +397,6 @@ public function sign(string $uuid): TemplateResponse { $response = new TemplateResponse(Application::APP_ID, 'external', [], TemplateResponse::RENDER_AS_BASE); $policy = new ContentSecurityPolicy(); - $policy->allowEvalScript(true); $policy->addAllowedWorkerSrcDomain("'self'"); $response->setContentSecurityPolicy($policy); diff --git a/lib/Controller/PolicyController.php b/lib/Controller/PolicyController.php new file mode 100644 index 0000000000..3c066e7fa3 --- /dev/null +++ b/lib/Controller/PolicyController.php @@ -0,0 +1,573 @@ + + * + * 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 { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value); + $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 { + $value = $this->requestSignGroupsPolicyGuard->normalizeManagedValue($policyKey, $value); + $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 { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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 { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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|DataResponse + * + * 200: OK + * 400: User-scope not supported + */ + #[NoAdminRequired] + #[ApiRoute(verb: 'DELETE', url: '/api/{apiVersion}/policies/user/{policyKey}', requirements: ['apiVersion' => '(v1)', 'policyKey' => '[a-z0-9_]+'])] + public function clearUserPreference(string $policyKey): DataResponse { + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $policy = $this->policyService->clearUserPreference($policyKey); + /** @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); + } + } + + /** + * 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|DataResponse + * + * 200: OK + * 400: User-scope not supported + * 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(); + } + + try { + $this->requestSignGroupsPolicyGuard->assertUserScopeSupported($policyKey); + $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); + } catch (\InvalidArgumentException $exception) { + /** @var LibresignErrorResponse $data */ + $data = [ + 'error' => $exception->getMessage(), + ]; + + return new DataResponse($data, Http::STATUS_BAD_REQUEST); + } + } + + /** @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..9ff2805058 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 array|null $policy Structured policy payload with request-level overrides and active context. * @return DataResponse|DataResponse * * 200: OK @@ -85,10 +85,13 @@ public function request( array $files = [], ?string $callback = null, ?int $status = 1, - ?string $signatureFlow = null, + ?array $policy = null, ): DataResponse { try { $user = $this->userSession->getUser(); + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); + return $this->createSignatureRequest( $user, $file, @@ -98,7 +101,8 @@ public function request( $signers, $status, $callback, - $signatureFlow + $policyOverrides, + $policyActiveContext, ); } catch (LibresignException $e) { $errorMessage = $e->getMessage(); @@ -133,7 +137,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 array|null $policy Structured policy payload with request-level overrides and active context. * @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. @@ -152,7 +156,7 @@ public function updateSign( ?array $visibleElements = null, ?array $file = [], ?int $status = null, - ?string $signatureFlow = null, + ?array $policy = null, ?string $name = null, array $settings = [], array $files = [], @@ -160,6 +164,8 @@ public function updateSign( try { $user = $this->userSession->getUser(); $signers = is_array($signers) ? $signers : []; + $policyOverrides = $this->extractPolicyOverrides($policy); + $policyActiveContext = $this->extractPolicyActiveContext($policy); if (empty($uuid)) { return $this->createSignatureRequest( @@ -171,7 +177,8 @@ public function updateSign( $signers, $status, null, - $signatureFlow, + $policyOverrides, + $policyActiveContext, $visibleElements ); } @@ -182,7 +189,8 @@ public function updateSign( 'signers' => $signers, 'userManager' => $user, 'visibleElements' => $visibleElements, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'name' => $name, 'settings' => $settings, ]; @@ -226,7 +234,8 @@ private function createSignatureRequest( array $signers, ?int $status, ?string $callback, - ?string $signatureFlow, + array $policyOverrides = [], + ?array $policyActiveContext = null, ?array $visibleElements = null, ): DataResponse { $isEnvelope = !empty($files); @@ -243,7 +252,8 @@ private function createSignatureRequest( 'signers' => $signers, 'callback' => $callback, 'userManager' => $user, - 'signatureFlow' => $signatureFlow, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, 'settings' => !empty($settings) ? $settings : ($file['settings'] ?? []), ]; @@ -364,4 +374,18 @@ private function loadChildFilesIfEnvelope($fileEntity): array { ? $this->fileMapper->getChildrenFiles($fileEntity->getId()) : []; } + + /** @return array */ + private function extractPolicyOverrides(?array $policy): array { + $overrides = $policy['overrides'] ?? null; + + return is_array($overrides) ? $overrides : []; + } + + /** @return array|null */ + private function extractPolicyActiveContext(?array $policy): ?array { + $activeContext = $policy['activeContext'] ?? null; + + return is_array($activeContext) ? $activeContext : null; + } } 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..f4d4cc82d1 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; @@ -31,6 +34,9 @@ class FooterHandler { private QrCode $qrCode; + /** @var array */ + private array $requestPolicyOverrides = []; + private ?bool $writeQrcodeOnFooterOverride = null; private const MIN_QRCODE_SIZE = 100; private const POINT_TO_MILIMETER = 0.3527777778; @@ -41,17 +47,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 +100,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 +118,27 @@ public function setTemplateVar(string $name, mixed $value): self { return $this; } - private function prepareTemplateVars(): array { + /** @param array $requestPolicyOverrides */ + public function setRequestPolicyOverrides(array $requestPolicyOverrides): self { + $this->requestPolicyOverrides = $requestPolicyOverrides; + return $this; + } + + public function setWriteQrcodeOnFooterOverride(?bool $value): self { + $this->writeQrcodeOnFooterOverride = $value; + return $this; + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + private function resolveFooterPolicy(): array { + return FooterPolicyValue::normalize( + $this->policyService->resolve(FooterPolicy::KEY, $this->requestPolicyOverrides)->getEffectiveValue() + ); + } + + private function prepareTemplateVars(bool $forceEnabled = false): array { + $footerPolicy = $this->resolveFooterPolicy(); + 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 +158,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 +181,8 @@ private function prepareTemplateVars(): array { } } - if ($this->appConfig->getValueBool(Application::APP_ID, 'write_qrcode_on_footer', true) && $this->templateVars->getValidationSite()) { + $shouldWriteQrcode = $this->writeQrcodeOnFooterOverride ?? $footerPolicy['writeQrcodeOnFooter']; + if ($shouldWriteQrcode && $this->templateVars->getValidationSite()) { $this->templateVars->setQrcode($this->getQrCodeImageBase64($this->templateVars->getValidationSite())); } @@ -170,6 +197,15 @@ private function prepareTemplateVars(): array { } public function getTemplate(): string { + $footerPolicy = $this->resolveFooterPolicy(); + + if ($footerPolicy['customizeFooterTemplate']) { + $policyTemplate = trim((string)($footerPolicy['footerTemplate'] ?? '')); + if ($policyTemplate !== '') { + return $policyTemplate; + } + } + $footerTemplate = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); if ($footerTemplate) { return $footerTemplate; @@ -204,4 +240,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, $this->requestPolicyOverrides)->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/Helper/ValidateHelper.php b/lib/Helper/ValidateHelper.php index c58327cd1d..91d14116f0 100644 --- a/lib/Helper/ValidateHelper.php +++ b/lib/Helper/ValidateHelper.php @@ -28,6 +28,7 @@ use OCA\Libresign\Service\FileService; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethodService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Libresign\Service\SequentialSigningService; use OCA\Libresign\Service\SignerElementsService; use OCP\AppFramework\Db\DoesNotExistException; @@ -74,6 +75,7 @@ public function __construct( private IUserManager $userManager, private IRootFolder $root, private DocMdpValidator $docMdpValidator, + private RequestSignAuthorizationService $requestSignAuthorizationService, ) { } @@ -505,21 +507,7 @@ private function getLibreSignFileByNodeId(int $nodeId): ?\OCP\Files\File { } public function canRequestSign(IUser $user): void { - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - $authorized = ['admin']; - } - if (!is_array($authorized)) { - throw new LibresignException( - json_encode([ - 'action' => JSActions::ACTION_DO_NOTHING, - 'errors' => [['message' => $this->l10n->t('You are not allowed to request signing')]], - ]), - Http::STATUS_UNPROCESSABLE_ENTITY, - ); - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { + if (!$this->requestSignAuthorizationService->canRequestSign($user)) { throw new LibresignException( json_encode([ 'action' => JSActions::ACTION_DO_NOTHING, diff --git a/lib/Middleware/InjectionMiddleware.php b/lib/Middleware/InjectionMiddleware.php index bf1d3eb9f0..9fc59fa415 100644 --- a/lib/Middleware/InjectionMiddleware.php +++ b/lib/Middleware/InjectionMiddleware.php @@ -326,7 +326,6 @@ public function afterException($controller, $methodName, \Exception $exception): ); $policy = new ContentSecurityPolicy(); - $policy->allowEvalScript(true); $policy->addAllowedFrameDomain('\'self\''); $response->setContentSecurityPolicy($policy); return $response; 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..4fb9febd8c --- /dev/null +++ b/lib/Migration/Version18001Date20260320000000.php @@ -0,0 +1,188 @@ +migrateLegacyFooterSettings(); + $this->migrateDocMdpLevelType(); + $this->migrateGroupsRequestSignType(); + $this->migrateIdentifyMethodsType(); + } + + private function migrateGroupsRequestSignType(): void { + $legacyValue = $this->readLegacyString(RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + if ($legacyValue !== null) { + if ($legacyValue === '') { + return; + } + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($legacyValue), + ); + return; + } + + $typedValue = $this->appConfig->getValueArray( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::DEFAULT_GROUPS, + ); + + $this->appConfig->deleteKey(Application::APP_ID, RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY); + $this->appConfig->setValueString( + Application::APP_ID, + RequestSignGroupsPolicy::SYSTEM_APP_CONFIG_KEY, + RequestSignGroupsPolicyValue::encode($typedValue), + ); + } + + 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..0d101180fc 100644 --- a/lib/ResponseDefinitions.php +++ b/lib/ResponseDefinitions.php @@ -354,11 +354,89 @@ * * 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, + * add_footer?: 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..715908c528 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -26,6 +26,8 @@ use OCA\Libresign\Helper\FileUploadHelper; use OCA\Libresign\Helper\ValidateHelper; use OCA\Libresign\Service\Crl\CrlService; +use OCA\Libresign\Service\Policy\PolicyAuthorizationService; +use OCA\Libresign\Service\Policy\RequestSignAuthorizationService; use OCA\Settings\Mailer\NewUserMailHelper; use OCP\Accounts\IAccountManager; use OCP\AppFramework\Db\DoesNotExistException; @@ -73,6 +75,7 @@ public function __construct( private IURLGenerator $urlGenerator, private Pkcs12Handler $pkcs12Handler, private IGroupManager $groupManager, + private PolicyAuthorizationService $policyAuthorizationService, private IdDocsService $idDocsService, private SignerElementsService $signerElementsService, private UserElementMapper $userElementMapper, @@ -81,6 +84,7 @@ public function __construct( private ITimeFactory $timeFactory, private FileUploadHelper $uploadHelper, private CrlService $crlService, + private RequestSignAuthorizationService $requestSignAuthorizationService, ) { } @@ -207,8 +211,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'] = $this->policyAuthorizationService->canUserManageGroupPolicies($user); + $info['manageable_policy_group_ids'] = $this->policyAuthorizationService->getManageablePolicyGroupIds($user); - return array_filter($info); + return array_filter($info, static fn (mixed $value): bool => $value !== null && $value !== ''); } public function getConfigFilters(?IUser $user = null): array { @@ -354,18 +361,7 @@ public function getFileByNodeId(int $nodeId): File { } public function canRequestSign(?IUser $user = null): bool { - if (!$user) { - return false; - } - $authorized = $this->appConfig->getValueArray(Application::APP_ID, 'groups_request_sign', ['admin']); - if (empty($authorized)) { - return false; - } - $userGroups = $this->groupManager->getUserGroupIds($user); - if (!array_intersect($userGroups, $authorized)) { - return false; - } - return true; + return $this->requestSignAuthorizationService->canRequestSign($user); } public function getSettings(?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..d822139a09 100644 --- a/lib/Service/FooterService.php +++ b/lib/Service/FooterService.php @@ -41,7 +41,7 @@ public function saveTemplate(string $template = ''): void { } } - public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50): string { + public function renderPreviewPdf(string $template = '', int $width = 595, int $height = 50, ?bool $writeQrcodeOnFooter = null): string { if (!empty($template)) { $this->saveTemplate($template); } @@ -56,15 +56,20 @@ public function renderPreviewPdf(string $template = '', int $width = 595, int $h random_int(0, 0xffffffffffff) ); - return $this->footerHandler + $handler = $this->footerHandler ->setTemplateVar('uuid', $previewUuid) ->setTemplateVar('signers', [ [ 'displayName' => 'Preview Signer', 'signed' => date('c'), ], - ]) - ->getFooter([['w' => $width, 'h' => $height]]); + ]); + + if ($writeQrcodeOnFooter !== null) { + $handler->setWriteQrcodeOnFooterOverride($writeQrcodeOnFooter); + } + + return $handler->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/IFilePolicyApplier.php b/lib/Service/Policy/Contract/IFilePolicyApplier.php new file mode 100644 index 0000000000..29fb035b9b --- /dev/null +++ b/lib/Service/Policy/Contract/IFilePolicyApplier.php @@ -0,0 +1,25 @@ + $data */ + public function apply(FileEntity $file, array $data): void; + + /** @param array $data */ + public function sync(FileEntity $file, array $data): void; + + /** + * Core flow sync is used on the UUID update path where only core flow policies + * should trigger recomputation. + */ + public function supportsCoreFlowSync(): bool; +} 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/FilePolicyApplier.php b/lib/Service/Policy/FilePolicyApplier.php new file mode 100644 index 0000000000..0d9b075bcc --- /dev/null +++ b/lib/Service/Policy/FilePolicyApplier.php @@ -0,0 +1,96 @@ + */ + private readonly array $appliers; + + public function __construct( + private readonly PolicyService $policyService, + private readonly FileService $fileService, + private readonly IL10N $l10n, + ) { + $this->appliers = $this->discoverAppliers(); + } + + /** + * Apply all policies to a freshly built FileEntity before the first insert. + */ + public function applyAll(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->apply($file, $data); + } + } + + /** + * Re-evaluate and persist signature_flow + docmdp on an existing file. + * Use this when updating a file located by UUID. + */ + public function syncCoreFlowPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + if ($applier->supportsCoreFlowSync()) { + $applier->sync($file, $data); + } + } + } + + /** + * Re-evaluate and persist all three policies on an existing file. + * Use this when updating a file located by node ID. + */ + public function syncAllPolicies(FileEntity $file, array $data): void { + foreach ($this->appliers as $applier) { + $applier->sync($file, $data); + } + } + + /** @return list */ + private function discoverAppliers(): array { + $appliers = []; + + foreach (PolicyProviders::BY_KEY as $providerClass) { + $applierClass = $this->buildFileApplierClassFromProvider($providerClass); + if ($applierClass === null || !class_exists($applierClass)) { + continue; + } + + $instance = new $applierClass($this->policyService, $this->fileService, $this->l10n); + if (!$instance instanceof IFilePolicyApplier) { + continue; + } + + $appliers[] = $instance; + } + + return $appliers; + } + + /** @param class-string $providerClass */ + private function buildFileApplierClassFromProvider(string $providerClass): ?string { + $lastSeparator = strrpos($providerClass, '\\'); + if ($lastSeparator === false) { + return null; + } + + $namespace = substr($providerClass, 0, $lastSeparator); + $shortName = substr($providerClass, $lastSeparator + 1); + $baseName = str_ends_with($shortName, 'Policy') + ? substr($shortName, 0, -strlen('Policy')) + : $shortName; + + return $namespace . '\\FilePolicy\\' . $baseName . 'FilePolicyApplier'; + } +} diff --git a/lib/Service/Policy/IPolicyAuthorizationService.php b/lib/Service/Policy/IPolicyAuthorizationService.php new file mode 100644 index 0000000000..19ff45fc8b --- /dev/null +++ b/lib/Service/Policy/IPolicyAuthorizationService.php @@ -0,0 +1,32 @@ + + */ + public function getManageablePolicyGroupIds(?IUser $user): array; +} 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..d2eb1fdb41 --- /dev/null +++ b/lib/Service/Policy/Model/ResolvedPolicy.php @@ -0,0 +1,142 @@ + */ + 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 setInheritedValue(mixed $inheritedValue): self { + $this->inheritedValue = $inheritedValue; + return $this; + } + + public function getInheritedValue(): mixed { + return $this->inheritedValue; + } + + 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(), + 'inheritedValue' => $this->getInheritedValue(), + '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/PolicyAuthorizationService.php b/lib/Service/Policy/PolicyAuthorizationService.php new file mode 100644 index 0000000000..a561539ebf --- /dev/null +++ b/lib/Service/Policy/PolicyAuthorizationService.php @@ -0,0 +1,65 @@ +groupManager->isAdmin($user->getUID()) + || $this->subAdmin->isSubAdmin($user); + } + + /** + * Get list of group IDs manageable by the given user through subadmin scope. + * + * For instance admins: returns empty (they manage all groups at policy level). + * For subadmins: returns groups they are subadmin of. + * For regular users: returns empty. + * + * @return list + */ + #[\Override] + public function getManageablePolicyGroupIds(?IUser $user): array { + if ($user === null) { + return []; + } + + // Instance admins do not need a restricted group list + // (they have access to all groups at the policy layer) + if ($this->groupManager->isAdmin($user->getUID())) { + return []; + } + + // Only subadmins have a restricted manageable group scope + return array_values(array_map( + static fn ($group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + } +} 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/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php new file mode 100644 index 0000000000..102e4906b1 --- /dev/null +++ b/lib/Service/Policy/Provider/DocMdp/FilePolicy/DocMdpFilePolicyApplier.php @@ -0,0 +1,104 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(DocMdpPolicy::KEY, $user, $requestOverrides, $activeContext); + $file->setDocmdpLevelEnum(DocMdpLevel::tryFrom((int)$resolvedPolicy->getEffectiveValue()) ?? DocMdpLevel::NOT_CERTIFIED); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(DocMdpPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $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); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(DocMdpPolicy::KEY, $data['policyOverrides'])) { + return [DocMdpPolicy::KEY => $data['policyOverrides'][DocMdpPolicy::KEY]]; + } + + return []; + } + + 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); + } +} diff --git a/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php new file mode 100644 index 0000000000..400443c7c8 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FilePolicy/FooterFilePolicyApplier.php @@ -0,0 +1,113 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(FooterPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(FooterPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $metadataBeforeUpdate = $file->getMetadata() ?? []; + $this->storePolicySnapshot($file, $resolvedPolicy); + $metadataChanged = ($file->getMetadata() ?? []) !== $metadataBeforeUpdate; + + if ($metadataChanged) { + $this->fileService->update($file); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return false; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(FooterPolicy::KEY, $data['policyOverrides'])) { + return [FooterPolicy::KEY => $data['policyOverrides'][FooterPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Footer template override is blocked by %s.', [$blockedBy]), 422); + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicy.php b/lib/Service/Policy/Provider/Footer/FooterPolicy.php new file mode 100644 index 0000000000..8ba7b9af28 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicy.php @@ -0,0 +1,98 @@ +resolveInstanceBaseTemplate(); + return match ($this->normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: FooterPolicyValue::encode(FooterPolicyValue::defaults($instanceBaseTemplate)), + allowedValues: static fn (): array => [], + normalizer: function (mixed $rawValue) use ($instanceBaseTemplate): mixed { + return FooterPolicyValue::encode(FooterPolicyValue::normalize($rawValue, $instanceBaseTemplate)); + }, + validator: function (mixed $value, PolicyContext $context) use ($instanceBaseTemplate): 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); + } + + if (!self::canManageTechnicalFooterSettings($context)) { + $normalized = FooterPolicyValue::normalize($decoded, $instanceBaseTemplate); + if ($normalized['validationSite'] !== '') { + throw new \InvalidArgumentException('Validation URL override is not allowed for this actor'); + } + } + }, + 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; + } + + private static function canManageTechnicalFooterSettings(PolicyContext $context): bool { + $capabilities = $context->getActorCapabilities(); + + return ($capabilities['canManageSystemPolicies'] ?? false) === true + || ($capabilities['canManageGroupPolicies'] ?? false) === true; + } + + private function resolveInstanceBaseTemplate(): string { + if ($this->appConfig === null) { + return ''; + } + + $templateFromConfig = $this->appConfig->getValueString(Application::APP_ID, 'footer_template', ''); + if ($templateFromConfig !== '') { + return $templateFromConfig; + } + + $defaultTemplatePath = __DIR__ . '/../../../../Handler/Templates/footer.twig'; + $defaultTemplate = @file_get_contents($defaultTemplatePath); + + return is_string($defaultTemplate) ? $defaultTemplate : ''; + } +} diff --git a/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php new file mode 100644 index 0000000000..214c0610c1 --- /dev/null +++ b/lib/Service/Policy/Provider/Footer/FooterPolicyValue.php @@ -0,0 +1,128 @@ + true, + 'writeQrcodeOnFooter' => true, + 'validationSite' => '', + 'customizeFooterTemplate' => false, + 'footerTemplate' => $defaultTemplate, + 'previewWidth' => 595, + 'previewHeight' => 100, + 'previewZoom' => 100, + ]; + } + + /** @return array{enabled: bool, writeQrcodeOnFooter: bool, validationSite: string, customizeFooterTemplate: bool, footerTemplate: string, previewWidth: int, previewHeight: int, previewZoom: int} */ + public static function normalize(mixed $rawValue, string $defaultTemplate = ''): array { + $defaults = self::defaults($defaultTemplate); + + if (is_array($rawValue)) { + $normalized = [ + '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']), + 'footerTemplate' => self::toTemplateString($rawValue['footerTemplate'] ?? $rawValue['footer_template'] ?? $defaults['footerTemplate']), + 'previewWidth' => self::toInt($rawValue['previewWidth'] ?? $rawValue['preview_width'] ?? null, $defaults['previewWidth']), + 'previewHeight' => self::toInt($rawValue['previewHeight'] ?? $rawValue['preview_height'] ?? null, $defaults['previewHeight']), + 'previewZoom' => self::toInt($rawValue['previewZoom'] ?? $rawValue['preview_zoom'] ?? null, $defaults['previewZoom']), + ]; + + return $normalized; + } + + 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); + } + + private static function toTemplateString(mixed $rawValue): string { + if (is_string($rawValue)) { + return $rawValue; + } + + if (is_scalar($rawValue)) { + return (string)$rawValue; + } + + return ''; + } + + private static function toInt(mixed $rawValue, int $fallback): int { + if (is_int($rawValue)) { + return $rawValue; + } + + if (is_numeric($rawValue)) { + return (int)$rawValue; + } + + return $fallback; + } +} diff --git a/lib/Service/Policy/Provider/PolicyProviders.php b/lib/Service/Policy/Provider/PolicyProviders.php new file mode 100644 index 0000000000..2d828e7c1b --- /dev/null +++ b/lib/Service/Policy/Provider/PolicyProviders.php @@ -0,0 +1,24 @@ + */ + public const BY_KEY = [ + FooterPolicy::KEY => FooterPolicy::class, + DocMdpPolicy::KEY => DocMdpPolicy::class, + RequestSignGroupsPolicy::KEY => RequestSignGroupsPolicy::class, + SignatureFlowPolicy::KEY => SignatureFlowPolicy::class, + ]; +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php new file mode 100644 index 0000000000..6d2535a877 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicy.php @@ -0,0 +1,58 @@ +normalizePolicyKey($policyKey)) { + self::KEY => new PolicySpec( + key: self::KEY, + defaultSystemValue: RequestSignGroupsPolicyValue::encode(RequestSignGroupsPolicyValue::DEFAULT_GROUPS), + allowedValues: static fn (PolicyContext $context): array => [], + normalizer: static fn (mixed $rawValue): mixed => RequestSignGroupsPolicyValue::encode($rawValue), + validator: static function (mixed $value): void { + if (!is_string($value)) { + throw new \InvalidArgumentException('Invalid value for ' . self::KEY); + } + + $decoded = RequestSignGroupsPolicyValue::decode($value); + if ($decoded === []) { + throw new \InvalidArgumentException('At least one authorized group is required 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/RequestSignGroups/RequestSignGroupsPolicyGuard.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php new file mode 100644 index 0000000000..3b735db74c --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyGuard.php @@ -0,0 +1,79 @@ +l10n->t(self::USER_SCOPE_NOT_SUPPORTED_MESSAGE)); + } + + public function normalizeManagedValue(string $policyKey, null|bool|int|float|string $value): null|bool|int|float|string { + if ($policyKey !== RequestSignGroupsPolicy::KEY) { + return $value; + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + throw new \InvalidArgumentException($this->l10n->t('Not allowed to manage this policy')); + } + + $groupIds = RequestSignGroupsPolicyValue::decode($value); + if ($groupIds === []) { + throw new \InvalidArgumentException($this->l10n->t('At least one authorized group is required')); + } + + $allowedGroupIds = $this->resolveAllowedGroupIdsForActor($user); + $unknownGroupIds = array_values(array_diff($groupIds, $allowedGroupIds)); + if ($unknownGroupIds !== []) { + throw new \InvalidArgumentException($this->l10n->t('One or more selected groups are not allowed for your administration scope')); + } + + return RequestSignGroupsPolicyValue::encode($groupIds); + } + + /** @return list */ + private function resolveAllowedGroupIdsForActor(IUser $user): array { + if ($this->groupManager->isAdmin($user->getUID())) { + return array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->groupManager->search(''), + )); + } + + if (!$this->subAdmin->isSubAdmin($user)) { + return []; + } + + return array_values(array_map( + static fn (IGroup $group): string => $group->getGID(), + $this->subAdmin->getSubAdminsGroups($user), + )); + } +} diff --git a/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php new file mode 100644 index 0000000000..8698853df9 --- /dev/null +++ b/lib/Service/Policy/Provider/RequestSignGroups/RequestSignGroupsPolicyValue.php @@ -0,0 +1,65 @@ + */ + public const DEFAULT_GROUPS = ['admin']; + + /** @return list */ + public static function decode(mixed $rawValue): array { + if (is_array($rawValue)) { + return self::normalizeGroupIds($rawValue); + } + + if (!is_string($rawValue)) { + return []; + } + + $trimmed = trim($rawValue); + if ($trimmed === '') { + return []; + } + + $decoded = json_decode($trimmed, true); + if (is_array($decoded)) { + return self::normalizeGroupIds($decoded); + } + + return self::normalizeGroupIds(array_map('trim', explode(',', $trimmed))); + } + + public static function encode(mixed $rawValue): string { + return json_encode(self::decode($rawValue), JSON_THROW_ON_ERROR); + } + + /** @param array $rawGroups + * @return list + */ + private static function normalizeGroupIds(array $rawGroups): array { + $normalized = []; + foreach ($rawGroups as $groupId) { + if (!is_string($groupId)) { + continue; + } + + $trimmed = trim($groupId); + if ($trimmed === '') { + continue; + } + + $normalized[] = $trimmed; + } + + $unique = array_values(array_unique($normalized)); + sort($unique); + + return $unique; + } +} diff --git a/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php new file mode 100644 index 0000000000..87f8efebb6 --- /dev/null +++ b/lib/Service/Policy/Provider/Signature/FilePolicy/SignatureFlowFilePolicyApplier.php @@ -0,0 +1,117 @@ +getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides) + : $this->policyService->resolveForUser(SignatureFlowPolicy::KEY, $user, $requestOverrides, $activeContext); + $this->assertOverrideAllowed($requestOverrides, $resolvedPolicy); + $file->setSignatureFlowEnum(SignatureFlow::from((string)$resolvedPolicy->getEffectiveValue())); + $this->storePolicySnapshot($file, $resolvedPolicy); + } + + #[\Override] + public function sync(FileEntity $file, array $data): void { + $requestOverrides = $this->getOverrides($data); + $activeContext = $this->extractActiveContext($data); + $resolvedPolicy = $activeContext === null + ? $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides) + : $this->policyService->resolveForUserId(SignatureFlowPolicy::KEY, $file->getUserId(), $requestOverrides, $activeContext); + $this->assertOverrideAllowed($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); + } + } + + #[\Override] + public function supportsCoreFlowSync(): bool { + return true; + } + + /** + * @param array{policyActiveContext?: array} $data + * @return array{type: string, id: string}|null + */ + private function extractActiveContext(array $data): ?array { + if (!isset($data['policyActiveContext']) || !is_array($data['policyActiveContext'])) { + return null; + } + + $type = $data['policyActiveContext']['type'] ?? null; + $id = $data['policyActiveContext']['id'] ?? null; + if (!is_string($type) || !is_string($id) || $type === '' || $id === '') { + return null; + } + + return [ + 'type' => $type, + 'id' => $id, + ]; + } + + /** @return array */ + private function getOverrides(array $data): array { + if (isset($data['policyOverrides']) && is_array($data['policyOverrides']) && array_key_exists(SignatureFlowPolicy::KEY, $data['policyOverrides'])) { + return [SignatureFlowPolicy::KEY => $data['policyOverrides'][SignatureFlowPolicy::KEY]]; + } + + return []; + } + + /** @param array $requestOverrides */ + private function assertOverrideAllowed(array $requestOverrides, ResolvedPolicy $resolvedPolicy): void { + if ($requestOverrides === [] || $resolvedPolicy->canUseAsRequestOverride()) { + return; + } + + $blockedBy = $resolvedPolicy->getBlockedBy() ?? $resolvedPolicy->getSourceScope(); + throw new LibresignException($this->l10n->t('Signature flow override is blocked by %s.', [$blockedBy]), 422); + } + + private function storePolicySnapshot(FileEntity $file, ResolvedPolicy $resolvedPolicy): void { + $metadata = $file->getMetadata() ?? []; + $policySnapshot = $metadata['policy_snapshot'] ?? []; + $policySnapshot[$resolvedPolicy->getPolicyKey()] = [ + 'effectiveValue' => $resolvedPolicy->getEffectiveValue(), + 'sourceScope' => $resolvedPolicy->getSourceScope(), + ]; + $metadata['policy_snapshot'] = $policySnapshot; + $file->setMetadata($metadata); + } +} 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/RequestSignAuthorizationService.php b/lib/Service/Policy/RequestSignAuthorizationService.php new file mode 100644 index 0000000000..e5eaed8d2e --- /dev/null +++ b/lib/Service/Policy/RequestSignAuthorizationService.php @@ -0,0 +1,37 @@ +policyService->resolveForUser(RequestSignGroupsPolicy::KEY, $user); + $authorizedGroups = RequestSignGroupsPolicyValue::decode($resolvedPolicy->getEffectiveValue()); + if ($authorizedGroups === []) { + return false; + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + return array_intersect($userGroups, $authorizedGroups) !== []; + } +} diff --git a/lib/Service/Policy/Runtime/DefaultPolicyResolver.php b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php new file mode 100644 index 0000000000..89848f171e --- /dev/null +++ b/lib/Service/Policy/Runtime/DefaultPolicyResolver.php @@ -0,0 +1,389 @@ +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, + ); + } + + $inheritedValue = $currentValue; + + 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) + ->setInheritedValue($inheritedValue) + ->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..5b7f5d7b7e --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicyContextFactory.php @@ -0,0 +1,123 @@ + $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 { + $validatedActiveContext = $this->validateActiveContext($activeContext, $currentActor); + + $context = (new PolicyContext()) + ->setRequestOverrides($requestOverrides) + ->setActiveContext($validatedActiveContext) + ->setActorCapabilities($this->resolveActorCapabilities($currentActor)); + + if ($userId !== null && $userId !== '') { + $context->setUserId($userId); + if ($user instanceof IUser) { + $context->setGroups($this->groupManager->getUserGroupIds($user)); + } + } + + return $context; + } + + /** @param array|null $activeContext + * @return array|null + */ + private function validateActiveContext(?array $activeContext, ?IUser $currentActor): ?array { + if ($activeContext === null) { + return null; + } + + $type = $activeContext['type'] ?? null; + $id = $activeContext['id'] ?? null; + if ($type !== 'group' || !is_string($id) || trim($id) === '') { + throw new LibresignException('Only group active context is supported for policy overrides.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + $groupId = trim($id); + if (!$currentActor instanceof IUser || !in_array($groupId, $this->groupManager->getUserGroupIds($currentActor), true)) { + throw new LibresignException('You are not allowed to use this policy context.', Http::STATUS_UNPROCESSABLE_ENTITY); + } + + return [ + 'type' => 'group', + 'id' => $groupId, + ]; + } + + /** @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..2502daff33 --- /dev/null +++ b/lib/Service/Policy/Runtime/PolicySource.php @@ -0,0 +1,823 @@ +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)) { + try { + $arrayValue = $this->appConfig->getAppValueArray($key, []); + return json_encode($arrayValue, JSON_THROW_ON_ERROR); + } catch (AppConfigTypeConflictException) { + return $this->appConfig->getAppValueBool($key, in_array(strtolower(trim($defaultValue)), ['1', 'true', 'yes', 'on'], true)); + } catch (\JsonException) { + return (string)$defaultValue; + } + } + + 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..f4ea362a06 100644 --- a/lib/Service/RequestSignatureService.php +++ b/lib/Service/RequestSignatureService.php @@ -8,7 +8,6 @@ namespace OCA\Libresign\Service; -use OCA\Libresign\AppInfo\Application; use OCA\Libresign\Db\File as FileEntity; use OCA\Libresign\Db\FileElementMapper; use OCA\Libresign\Db\FileMapper; @@ -16,7 +15,6 @@ use OCA\Libresign\Db\SignRequest as SignRequestEntity; use OCA\Libresign\Db\SignRequestMapper; use OCA\Libresign\Enum\FileStatus; -use OCA\Libresign\Enum\SignatureFlow; use OCA\Libresign\Events\SignRequestCanceledEvent; use OCA\Libresign\Exception\LibresignException; use OCA\Libresign\Handler\DocMdpHandler; @@ -27,6 +25,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeService; use OCA\Libresign\Service\File\Pdf\PdfMetadataExtractor; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; +use OCA\Libresign\Service\Policy\FilePolicyApplier; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IMimeTypeDetector; @@ -67,6 +66,7 @@ public function __construct( protected EnvelopeFileRelocator $envelopeFileRelocator, protected FileUploadHelper $uploadHelper, protected SignRequestService $signRequestService, + protected FilePolicyApplier $filePolicyApplier, ) { } @@ -88,6 +88,8 @@ public function saveFiles(array $data): array { 'userManager' => $data['userManager'], 'status' => FileStatus::DRAFT->value, 'settings' => $data['settings'], + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]; if (isset($fileData['uploadedFile'])) { @@ -114,7 +116,8 @@ public function saveFiles(array $data): array { 'signers' => $data['signers'] ?? [], 'status' => $data['status'] ?? FileStatus::DRAFT->value, 'visibleElements' => $data['visibleElements'] ?? [], - 'signatureFlow' => $data['signatureFlow'] ?? null, + 'policyOverrides' => $data['policyOverrides'] ?? [], + 'policyActiveContext' => $data['policyActiveContext'] ?? null, ]); return [ @@ -185,7 +188,13 @@ public function saveEnvelope(array $data): array { $createdNodes[] = $node; $fileData['node'] = $node; - $fileEntity = $this->createFileForEnvelope($fileData, $userManager, $envelopeSettings); + $fileEntity = $this->createFileForEnvelope( + $fileData, + $userManager, + $envelopeSettings, + $data['policyOverrides'] ?? [], + $data['policyActiveContext'] ?? null, + ); $this->envelopeService->addFileToEnvelope($envelope->getId(), $fileEntity); $files[] = $fileEntity; } @@ -291,7 +300,13 @@ private function rollbackEnvelope(?FileEntity $envelope): void { } } - private function createFileForEnvelope(array $fileData, ?IUser $userManager, array $settings): FileEntity { + private function createFileForEnvelope( + array $fileData, + ?IUser $userManager, + array $settings, + array $policyOverrides = [], + ?array $policyActiveContext = null, + ): FileEntity { if (!isset($fileData['node'])) { throw new \InvalidArgumentException('Node not provided in file data'); } @@ -305,6 +320,8 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr 'userManager' => $userManager, 'status' => FileStatus::DRAFT->value, 'settings' => $settings, + 'policyOverrides' => $policyOverrides, + 'policyActiveContext' => $policyActiveContext, ]); } @@ -316,7 +333,7 @@ private function createFileForEnvelope(array $fileData, ?IUser $userManager, arr public function saveFile(array $data): FileEntity { if (!empty($data['uuid'])) { $file = $this->fileMapper->getByUuid($data['uuid']); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncCoreFlowPolicies($file, $data); if (!empty($data['name'])) { $file->setName($data['name']); $this->fileService->update($file); @@ -332,7 +349,7 @@ public function saveFile(array $data): FileEntity { if (!is_null($fileId)) { try { $file = $this->fileMapper->getByNodeId($fileId); - $this->updateSignatureFlowIfAllowed($file, $data); + $this->filePolicyApplier->syncAllPolicies($file, $data); return $this->fileStatusService->updateFileStatusIfUpgrade($file, $data['status'] ?? 0); } catch (\Throwable) { } @@ -373,54 +390,12 @@ public function saveFile(array $data): FileEntity { $file->setParentFileId($data['parentFileId']); } - $this->setSignatureFlow($file, $data); - $this->setDocMdpLevelFromGlobalConfig($file); + $this->filePolicyApplier->applyAll($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; - - if ($adminForcedConfig) { - $adminFlowEnum = SignatureFlow::from($adminFlow); - if ($file->getSignatureFlowEnum() !== $adminFlowEnum) { - $file->setSignatureFlowEnum($adminFlowEnum); - $this->fileService->update($file); - } - return; - } - - if (isset($data['signatureFlow']) && !empty($data['signatureFlow'])) { - $newFlow = SignatureFlow::from($data['signatureFlow']); - if ($file->getSignatureFlowEnum() !== $newFlow) { - $file->setSignatureFlowEnum($newFlow); - $this->fileService->update($file); - } - } - } - - private function setSignatureFlow(FileEntity $file, array $data): void { - $adminFlow = $this->appConfig->getValueString(Application::APP_ID, 'signature_flow', SignatureFlow::NONE->value); - - 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 setDocMdpLevelFromGlobalConfig(FileEntity $file): void { - if ($this->docMdpConfigService->isEnabled()) { - $docmdpLevel = $this->docMdpConfigService->getLevel(); - $file->setDocmdpLevelEnum($docmdpLevel); - } - } - private function getFileMetadata(\OCP\Files\Node $node): array { $metadata = []; if ($extension = strtolower($node->getExtension())) { diff --git a/lib/Service/SignFileService.php b/lib/Service/SignFileService.php index 93f243bdb2..32507c9175 100644 --- a/lib/Service/SignFileService.php +++ b/lib/Service/SignFileService.php @@ -41,6 +41,7 @@ use OCA\Libresign\Service\Envelope\EnvelopeStatusDeterminer; use OCA\Libresign\Service\IdentifyMethod\IIdentifyMethod; use OCA\Libresign\Service\IdentifyMethod\SignatureMethod\IToken; +use OCA\Libresign\Service\Policy\Provider\Footer\FooterPolicy; use OCA\Libresign\Service\SignRequest\SignRequestService; use OCA\Libresign\Service\SignRequest\StatusService; use OCP\AppFramework\Db\DoesNotExistException; @@ -594,6 +595,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 +631,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); @@ -1321,6 +1342,7 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $originalContent); } $metadata = $this->footerHandler->getMetadata($originalFile, $this->libreSignFile); + $this->footerHandler->setRequestPolicyOverrides($this->resolveFooterPolicyRequestOverridesFromFileMetadata()); $footer = $this->footerHandler ->setTemplateVar('uuid', $this->libreSignFile->getUuid()) ->setTemplateVar('signers', array_map(fn (SignRequestEntity $signer) => [ @@ -1348,6 +1370,33 @@ protected function getPdfToSign(File $originalFile): File { return $this->createSignedFile($originalFile, $pdfContent); } + /** @return array */ + private function resolveFooterPolicyRequestOverridesFromFileMetadata(): array { + $metadata = $this->libreSignFile->getMetadata(); + if (!is_array($metadata)) { + return []; + } + + $policySnapshot = $metadata['policy_snapshot'] ?? null; + if (!is_array($policySnapshot)) { + return []; + } + + $footerSnapshot = $policySnapshot[FooterPolicy::KEY] ?? null; + if (!is_array($footerSnapshot)) { + return []; + } + + $effectiveValue = $footerSnapshot['effectiveValue'] ?? null; + if (!is_string($effectiveValue) || trim($effectiveValue) === '') { + return []; + } + + return [ + FooterPolicy::KEY => $effectiveValue, + ]; + } + protected function getSignedFile(): ?File { $nodeId = $this->libreSignFile->getSignedNodeId(); if (!$nodeId) { @@ -1439,7 +1488,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 +1497,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..6083e3deb9 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,704 @@ } } }, + "/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" + } + } + } + } + } + } + } + }, + "400": { + "description": "User-scope not supported", + "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" + } + } + } + } + } + } + } + } + } + } + }, "/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..55fc0d5479 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,20 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -4772,6 +5128,11 @@ "format": "int64", "default": 50, "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" } } } @@ -8019,13 +8380,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 +8395,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 +8441,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 +8451,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 +8468,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 +8481,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 +8534,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/DetailedFileResponse" + "$ref": "#/components/schemas/GroupPolicyResponse" } } } @@ -8336,8 +8543,8 @@ } } }, - "422": { - "description": "Unauthorized", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8357,14 +8564,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "anyOf": [ - { - "$ref": "#/components/schemas/MessageResponse" - }, - { - "$ref": "#/components/schemas/ActionErrorResponse" - } - ] + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8374,15 +8574,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 +8589,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 +8640,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 +8692,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/GroupPolicyWriteResponse" } } } @@ -8467,8 +8701,8 @@ } } }, - "401": { - "description": "Failed", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8488,7 +8722,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8497,8 +8731,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8518,7 +8752,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8528,15 +8762,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 +8791,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 +8843,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 +8852,8 @@ } } }, - "422": { - "description": "Failed", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -8662,7 +8873,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8672,15 +8883,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 +8901,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 +8946,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 +8988,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8795,8 +8997,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -8816,7 +9018,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8826,17 +9028,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 +9043,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 +9057,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,7 +9099,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -8950,8 +9108,8 @@ } } }, - "422": { - "description": "Error", + "400": { + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -8971,7 +9129,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignActionErrorResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -8983,15 +9141,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": [] }, @@ -8999,6 +9157,69 @@ "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" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -9013,26 +9234,9 @@ } }, { - "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", + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", "required": true, "schema": { "type": "boolean", @@ -9062,7 +9266,44 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$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" + } + ] } } } @@ -9072,17 +9313,15 @@ } } } - } - }, - "/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", + }, + "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": [] }, @@ -9097,24 +9336,73 @@ "schema": { "type": "object", "properties": { - "identifyMethod": { - "type": "string", + "signers": { + "type": "array", "nullable": true, - "enum": [ - "account", - "email" - ], - "description": "Identify signer method" + "default": [], + "description": "Collection of signers who must sign the document. Use identifyMethods as the canonical format.", + "items": { + "$ref": "#/components/schemas/NewSigner" + } }, - "signMethod": { + "uuid": { "type": "string", "nullable": true, - "description": "Method used to sign the document, i.e. emailToken, account, clickToSign, smsToken, signalToken, telegramToken, whatsappToken, xmppToken" + "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." }, - "identify": { + "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" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + }, + "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" + } } } } @@ -9134,15 +9422,6 @@ "default": "v1" } }, - { - "name": "uuid", - "in": "path", - "description": "UUID of LibreSign file", - "required": true, - "schema": { - "type": "string" - } - }, { "name": "OCS-APIRequest", "in": "header", @@ -9176,7 +9455,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/DetailedFileResponse" } } } @@ -9186,7 +9465,7 @@ } }, "422": { - "description": "Error", + "description": "Unauthorized", "content": { "application/json": { "schema": { @@ -9206,7 +9485,14 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "anyOf": [ + { + "$ref": "#/components/schemas/MessageResponse" + }, + { + "$ref": "#/components/schemas/ActionErrorResponse" + } + ] } } } @@ -9218,15 +9504,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", + "/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": [ - "sign_file" + "request_signature" ], "security": [ - {}, { "bearer_auth": [] }, @@ -9234,37 +9520,6 @@ "basic_auth": [] } ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "identifyMethod": { - "type": "string", - "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" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -9281,7 +9536,17 @@ { "name": "fileId", "in": "path", - "description": "Id of LibreSign file", + "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", @@ -9330,8 +9595,8 @@ } } }, - "422": { - "description": "Error", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9359,19 +9624,49 @@ } } } + }, + "422": { + "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/ActionErrorResponse" + } + } + } + } + } + } + } } } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { - "post": { - "operationId": "signature_elements-create-signature-element", - "summary": "Create signature element", - "tags": [ - "signature_elements" - ], + "/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": [ + "request_signature" + ], "security": [ - {}, { "bearer_auth": [] }, @@ -9379,28 +9674,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", @@ -9414,6 +9687,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "LibreSign file ID", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9447,7 +9730,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9456,8 +9739,8 @@ } } }, - "422": { - "description": "Invalid data", + "401": { + "description": "Failed", "content": { "application/json": { "schema": { @@ -9485,14 +9768,44 @@ } } } + }, + "422": { + "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/ActionErrorResponse" + } + } + } + } + } + } + } } } }, - "get": { - "operationId": "signature_elements-get-signature-elements", - "summary": "Get signature elements", + "post": { + "operationId": "sign_file-sign-using-file-id", + "summary": "Sign a file using file Id", "tags": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9503,6 +9816,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", @@ -9516,6 +9871,16 @@ "default": "v1" } }, + { + "name": "fileId", + "in": "path", + "description": "Id of LibreSign file", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -9549,7 +9914,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsResponse" + "$ref": "#/components/schemas/SignActionResponse" } } } @@ -9558,8 +9923,8 @@ } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9579,7 +9944,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9591,12 +9956,12 @@ } } }, - "/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/uuid/{uuid}": { + "post": { + "operationId": "sign_file-sign-using-uuid", + "summary": "Sign a file using file UUID", "tags": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9607,6 +9972,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", @@ -9621,13 +10028,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" } }, { @@ -9645,16 +10051,35 @@ "200": { "description": "OK", "content": { - "*/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SignActionResponse" + } + } + } + } } } } }, - "404": { - "description": "Invalid data", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -9674,7 +10099,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/SignActionErrorResponse" } } } @@ -9686,14 +10111,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { - "get": { - "operationId": "signature_elements-get-signature-element", - "summary": "Get signature element of signer", + "/ocs/v2.php/apps/libresign/api/{apiVersion}/sign/uuid/{uuid}/renew/{method}": { + "post": { + "operationId": "sign_file-sign-renew", + "summary": "Renew the signature method", "tags": [ - "signature_elements" + "sign_file" ], "security": [ + {}, { "bearer_auth": [] }, @@ -9715,20 +10141,27 @@ } }, { - "name": "nodeId", + "name": "uuid", "in": "path", - "description": "Node id of a Nextcloud file", "required": true, "schema": { - "type": "integer", - "format": "int64" + "type": "string" } }, { - "name": "OCS-APIRequest", - "in": "header", - "description": "Required to be true for the API request to pass", - "required": true, + "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 @@ -9738,36 +10171,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/UserElement" - } - } - } - } - } - } - } - }, - "404": { - "description": "Invalid data", "content": { "application/json": { "schema": { @@ -9797,12 +10200,14 @@ } } } - }, - "patch": { - "operationId": "signature_elements-patch-signature-element", - "summary": "Update signature element", + } + }, + "/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": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9820,18 +10225,24 @@ "schema": { "type": "object", "properties": { - "type": { + "identifyMethod": { "type": "string", - "default": "", - "description": "The type of signature element" + "nullable": true, + "enum": [ + "account", + "email" + ], + "description": "Identify signer method" }, - "file": { - "type": "object", - "default": {}, - "description": "Element object", - "additionalProperties": { - "type": "object" - } + "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" } } } @@ -9852,13 +10263,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" } }, { @@ -9894,7 +10304,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/UserElementsMessageResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -9934,12 +10344,14 @@ } } } - }, - "delete": { - "operationId": "signature_elements-delete-signature-element", - "summary": "Delete signature element", + } + }, + "/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", "tags": [ - "signature_elements" + "sign_file" ], "security": [ {}, @@ -9950,6 +10362,37 @@ "basic_auth": [] } ], + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "identifyMethod": { + "type": "string", + "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" + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -9964,9 +10407,9 @@ } }, { - "name": "nodeId", + "name": "fileId", "in": "path", - "description": "Node id of a Nextcloud file", + "description": "Id of LibreSign file", "required": true, "schema": { "type": "integer", @@ -10015,8 +10458,8 @@ } } }, - "404": { - "description": "Not found", + "422": { + "description": "Error", "content": { "application/json": { "schema": { @@ -10048,15 +10491,15 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements": { "post": { - "operationId": "admin-generate-certificate-cfssl", - "summary": "Generate certificate using CFSSL engine", - "description": "This endpoint requires admin access", + "operationId": "signature_elements-create-signature-element", + "summary": "Create signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10071,55 +10514,15 @@ "schema": { "type": "object", "required": [ - "rootCert" + "elements" ], "properties": { - "rootCert": { + "elements": { "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" - } - } - ] - } - } - } - } + "description": "Element object", + "additionalProperties": { + "type": "object" } - }, - "cfsslUri": { - "type": "string", - "default": "", - "description": "URI of CFSSL API" - }, - "configPath": { - "type": "string", - "default": "", - "description": "Path of config files of CFSSL" } } } @@ -10172,7 +10575,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/UserElementsMessageResponse" } } } @@ -10181,8 +10584,8 @@ } } }, - "401": { - "description": "Account not found", + "422": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10212,17 +10615,15 @@ } } } - } - }, - "/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", + }, + "get": { + "operationId": "signature_elements-get-signature-elements", + "summary": "Get signature elements", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10230,63 +10631,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" - } - } - ] - } - } - } - } - } - }, - "configPath": { - "type": "string", - "default": "", - "description": "Path of config files of CFSSL" - } - } - } - } - } - }, "parameters": [ { "name": "apiVersion", @@ -10333,7 +10677,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/EngineHandlerResponse" + "$ref": "#/components/schemas/UserElementsResponse" } } } @@ -10342,8 +10686,8 @@ } } }, - "401": { - "description": "Account not found", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10375,15 +10719,15 @@ } } }, - "/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", + "/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", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10391,25 +10735,6 @@ "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", @@ -10423,6 +10748,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", @@ -10438,35 +10773,16 @@ "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/CertificateEngineConfigResponse" - } - } - } - } + "type": "string", + "format": "binary" } } } }, - "400": { - "description": "Invalid engine", + "404": { + "description": "Invalid data", "content": { "application/json": { "schema": { @@ -10486,7 +10802,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "type": "object" } } } @@ -10498,13 +10814,12 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/signature/elements/{nodeId}": { "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", + "operationId": "signature_elements-get-signature-element", + "summary": "Get signature element of signer", "tags": [ - "admin" + "signature_elements" ], "security": [ { @@ -10527,6 +10842,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", @@ -10560,7 +10885,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CetificateDataGenerated" + "$ref": "#/components/schemas/UserElement" + } + } + } + } + } + } + } + }, + "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" } } } @@ -10570,17 +10925,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", + }, + "patch": { + "operationId": "signature_elements-patch-signature-element", + "summary": "Update signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10588,6 +10941,31 @@ "basic_auth": [] } ], + "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" + } + } + } + } + } + } + }, "parameters": [ { "name": "apiVersion", @@ -10601,6 +10979,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", @@ -10634,7 +11022,37 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ConfigureChecksResponse" + "$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" } } } @@ -10644,17 +11062,15 @@ } } } - } - }, - "/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", + }, + "delete": { + "operationId": "signature_elements-delete-signature-element", + "summary": "Delete signature element", "tags": [ - "admin" + "signature_elements" ], "security": [ + {}, { "bearer_auth": [] }, @@ -10675,6 +11091,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", @@ -10707,7 +11133,39 @@ "meta": { "$ref": "#/components/schemas/OCSMeta" }, - "data": {} + "data": { + "$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" + } } } } @@ -10718,10 +11176,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-background": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate/cfssl": { "post": { - "operationId": "admin-signature-background-save", - "summary": "Add custom background image", + "operationId": "admin-generate-certificate-cfssl", + "summary": "Generate certificate using CFSSL engine", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10734,17 +11192,1395 @@ "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", "in": "path", "required": true, "schema": { - "type": "string", - "enum": [ - "v1" - ], - "default": "v1" + "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/EngineHandlerResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Account 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" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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" + } + } + ] + } + } + } + } + } + }, + "configPath": { + "type": "string", + "default": "", + "description": "Path of config files of CFSSL" + } + } + } + } + } + }, + "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/EngineHandlerResponse" + } + } + } + } + } + } + } + }, + "401": { + "description": "Account 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" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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", + "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/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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", + "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/CetificateDataGenerated" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "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/ConfigureChecksResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "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": {} + } + } + } + } + } + } + } + } + } + }, + "/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", + "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" + } + } + } + } + } + } + } + }, + "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/FailureStatusResponse" + } + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "admin-signature-background-get", + "summary": "Get custom background image", + "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 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": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SuccessStatusResponse" + } + } + } + } + } + } + } + } + } + }, + "delete": { + "operationId": "admin-signature-background-delete", + "summary": "Delete background image", + "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": "Deleted with success", + "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/signature-text": { + "post": { + "operationId": "admin-signature-text-save", + "summary": "Save signature text service", + "description": "This endpoint requires admin access", + "tags": [ + "admin" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "template" + ], + "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" + }, + "signatureHeight": { + "type": "number", + "format": "double", + "default": 100, + "description": "Signature box height, minimum 1" + }, + "renderMode": { + "type": "string", + "default": "GRAPHIC_AND_DESCRIPTION", + "description": "Signature render mode" + } + } + } + } + } + }, + "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/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" + } + } + } + } + } + } + } + } + } + }, + "get": { + "operationId": "admin-signature-text-get", + "summary": "Get parsed signature text service", + "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": "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 + } + } + ], + "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/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" + } + } + } + } + } + } + } + } + } + } + }, + "/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", + "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/SignatureTemplateSettingsResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/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" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v1" + ], + "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" } }, { @@ -10761,36 +12597,27 @@ "responses": { "200": { "description": "OK", + "headers": { + "Content-Disposition": { + "schema": { + "type": "string", + "enum": [ + "inline; filename=\"signer-name.png\"" + ] + } + } + }, "content": { - "application/json": { + "image/png": { "schema": { - "type": "object", - "required": [ - "ocs" - ], - "properties": { - "ocs": { - "type": "object", - "required": [ - "meta", - "data" - ], - "properties": { - "meta": { - "$ref": "#/components/schemas/OCSMeta" - }, - "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" - } - } - } - } + "type": "string", + "format": "binary" } } } }, - "422": { - "description": "Error", + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -10810,7 +12637,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -10820,10 +12647,12 @@ } } } - }, - "get": { - "operationId": "admin-signature-background-get", - "summary": "Get custom background image", + } + }, + "/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", "tags": [ "admin" @@ -10862,60 +12691,37 @@ ], "responses": { "200": { - "description": "Image returned", + "description": "OK", "content": { - "*/*": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/CertificatePolicyResponse" + } + } + } + } } } } - } - } - }, - "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", + "422": { + "description": "Not found", "content": { "application/json": { "schema": { @@ -10935,7 +12741,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -10947,8 +12753,8 @@ } }, "delete": { - "operationId": "admin-signature-background-delete", - "summary": "Delete background image", + "operationId": "admin-delete-certificate-policy", + "summary": "Delete certificate policy of this instance", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -10987,7 +12793,7 @@ ], "responses": { "200": { - "description": "Deleted with success", + "description": "OK", "content": { "application/json": { "schema": { @@ -11007,7 +12813,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "type": "object" } } } @@ -11019,10 +12825,10 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-text": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { "post": { - "operationId": "admin-signature-text-save", - "summary": "Save signature text service", + "operationId": "admin-updateoid", + "summary": "Update OID", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11042,41 +12848,12 @@ "schema": { "type": "object", "required": [ - "template" + "oid" ], "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" - }, - "signatureHeight": { - "type": "number", - "format": "double", - "default": 100, - "description": "Signature box height, minimum 1" - }, - "renderMode": { + "oid": { "type": "string", - "default": "GRAPHIC_AND_DESCRIPTION", - "description": "Signature render mode" + "description": "OID is a unique numeric identifier for certificate policies in digital certificates." } } } @@ -11129,7 +12906,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTextSettingsResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11138,8 +12915,8 @@ } } }, - "400": { - "description": "Bad request", + "422": { + "description": "Validation error", "content": { "application/json": { "schema": { @@ -11159,7 +12936,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/FailureStatusResponse" } } } @@ -11169,10 +12946,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { "get": { - "operationId": "admin-signature-text-get", - "summary": "Get parsed signature text service", + "operationId": "admin-reminder-fetch", + "summary": "Get reminder settings", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11198,68 +12977,20 @@ "default": "v1" } }, - { - "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 - } - } - ], - "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/SignatureTextSettingsResponse" - } - } - } - } - } - } + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true } - }, - "400": { - "description": "Bad request", + } + ], + "responses": { + "200": { + "description": "OK", "content": { "application/json": { "schema": { @@ -11279,7 +13010,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -11289,12 +13020,10 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-settings": { - "get": { - "operationId": "admin-get-signature-settings", - "summary": "Get signature settings", + }, + "post": { + "operationId": "admin-reminder-save", + "summary": "Save reminder", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11307,6 +13036,43 @@ "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", @@ -11353,7 +13119,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SignatureTemplateSettingsResponse" + "$ref": "#/components/schemas/ReminderSettings" } } } @@ -11365,11 +13131,11 @@ } } }, - "/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", + "/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" ], @@ -11381,6 +13147,43 @@ "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", @@ -11394,67 +13197,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 +13211,36 @@ "responses": { "200": { "description": "OK", - "headers": { - "Content-Disposition": { - "schema": { - "type": "string", - "enum": [ - "inline; filename=\"signer-name.png\"" - ] - } - } - }, "content": { - "image/png": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/SuccessStatusResponse" + } + } + } + } } } } }, "400": { - "description": "Bad request", + "description": "Validation error", "content": { "application/json": { "schema": { @@ -11509,7 +13260,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/ErrorStatusResponse" } } } @@ -11519,13 +13270,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", + }, + "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" ], @@ -11583,7 +13332,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CertificatePolicyResponse" + "$ref": "#/components/schemas/SuccessStatusResponse" } } } @@ -11591,9 +13340,53 @@ } } } + } + } + } + }, + "/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" + } }, - "422": { - "description": "Not found", + { + "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": { @@ -11613,7 +13406,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/FooterTemplateResponse" } } } @@ -11624,10 +13417,10 @@ } } }, - "delete": { - "operationId": "admin-delete-certificate-policy", - "summary": "Delete certificate policy of this instance", - "description": "This endpoint requires admin access", + "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" ], @@ -11639,6 +13432,35 @@ "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", @@ -11666,6 +13488,17 @@ "responses": { "200": { "description": "OK", + "content": { + "application/pdf": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Bad request", "content": { "application/json": { "schema": { @@ -11685,7 +13518,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "type": "object" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11697,11 +13530,11 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/certificate-policy/oid": { + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signing-mode/config": { "post": { - "operationId": "admin-updateoid", - "summary": "Update OID", - "description": "This endpoint requires admin access", + "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" ], @@ -11720,12 +13553,17 @@ "schema": { "type": "object", "required": [ - "oid" + "mode" ], "properties": { - "oid": { + "mode": { "type": "string", - "description": "OID is a unique numeric identifier for certificate policies in digital certificates." + "description": "Signing mode: \"sync\" or \"async\"" + }, + "workerType": { + "type": "string", + "nullable": true, + "description": "Worker type when async: \"local\" or \"external\" (optional)" } } } @@ -11758,7 +13596,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Settings saved", "content": { "application/json": { "schema": { @@ -11778,7 +13616,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/MessageResponse" } } } @@ -11787,8 +13625,8 @@ } } }, - "422": { - "description": "Validation error", + "400": { + "description": "Invalid parameters", "content": { "application/json": { "schema": { @@ -11808,7 +13646,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FailureStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11816,53 +13654,9 @@ } } } - } - } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/reminder": { - "get": { - "operationId": "admin-reminder-fetch", - "summary": "Get reminder settings", - "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": "OK", + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { @@ -11882,7 +13676,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -11892,10 +13686,12 @@ } } } - }, + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/docmdp/config": { "post": { - "operationId": "admin-reminder-save", - "summary": "Save reminder", + "operationId": "admin-set-doc-mdp-config", + "summary": "Configure DocMDP signature restrictions", "description": "This endpoint requires admin access", "tags": [ "admin" @@ -11915,30 +13711,18 @@ "schema": { "type": "object", "required": [ - "daysBefore", - "daysBetween", - "max", - "sendTimer" + "enabled" ], "properties": { - "daysBefore": { - "type": "integer", - "format": "int64", - "description": "First reminder after (days)" - }, - "daysBetween": { - "type": "integer", - "format": "int64", - "description": "Days between reminders" + "enabled": { + "type": "boolean", + "description": "Whether to enable DocMDP restrictions" }, - "max": { + "defaultLevel": { "type": "integer", "format": "int64", - "description": "Max reminders per signer" - }, - "sendTimer": { - "type": "string", - "description": "Send time (HH:mm)" + "default": 2, + "description": "DocMDP level: 1 (no changes), 2 (fill forms), 3 (add annotations)" } } } @@ -11971,7 +13755,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "Configuration saved successfully", "content": { "application/json": { "schema": { @@ -11991,98 +13775,17 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ReminderSettings" + "$ref": "#/components/schemas/MessageResponse" } } } } } - } - } - } - } - } - }, - "/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", + "400": { + "description": "Invalid DocMDP level provided", "content": { "application/json": { "schema": { @@ -12102,7 +13805,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12111,8 +13814,8 @@ } } }, - "400": { - "description": "Validation error", + "500": { + "description": "Internal server error", "content": { "application/json": { "schema": { @@ -12132,7 +13835,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorStatusResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12142,11 +13845,13 @@ } } } - }, - "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", + } + }, + "/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" ], @@ -12184,7 +13889,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "List of active signings", "content": { "application/json": { "schema": { @@ -12204,7 +13909,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/SuccessStatusResponse" + "$ref": "#/components/schemas/ActiveSigningsResponse" } } } @@ -12212,53 +13917,9 @@ } } } - } - } - } - }, - "/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", + "500": { + "description": "", "content": { "application/json": { "schema": { @@ -12278,7 +13939,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/FooterTemplateResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -12288,13 +13949,15 @@ } } } - }, - "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}/crl/list": { + "get": { + "operationId": "crl_api-list", + "summary": "List CRL entries with pagination and filters", + "description": "This endpoint requires admin access", "tags": [ - "admin" + "crl_api" ], "security": [ { @@ -12304,35 +13967,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 +13980,108 @@ "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", @@ -12359,18 +14095,7 @@ ], "responses": { "200": { - "description": "OK", - "content": { - "application/pdf": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - }, - "400": { - "description": "Bad request", + "description": "CRL entries retrieved successfully", "content": { "application/json": { "schema": { @@ -12390,7 +14115,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlListResponse" } } } @@ -12402,13 +14127,13 @@ } } }, - "/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", + "/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": [ - "admin" + "crl_api" ], "security": [ { @@ -12425,17 +14150,23 @@ "schema": { "type": "object", "required": [ - "mode" + "serialNumber" ], "properties": { - "mode": { + "serialNumber": { "type": "string", - "description": "Signing mode: \"sync\" or \"async\"" + "description": "Certificate serial number to revoke" }, - "workerType": { + "reasonCode": { + "type": "integer", + "format": "int64", + "nullable": true, + "description": "Revocation reason code (0-10, see RFC 5280)" + }, + "reasonText": { "type": "string", "nullable": true, - "description": "Worker type when async: \"local\" or \"external\" (optional)" + "description": "Optional text describing the reason" } } } @@ -12468,7 +14199,7 @@ ], "responses": { "200": { - "description": "Settings saved", + "description": "Certificate revoked successfully", "content": { "application/json": { "schema": { @@ -12488,7 +14219,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12518,7 +14249,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12527,8 +14258,8 @@ } } }, - "500": { - "description": "Internal server error", + "404": { + "description": "Certificate not found", "content": { "application/json": { "schema": { @@ -12548,7 +14279,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/CrlRevokeResponse" } } } @@ -12560,13 +14291,13 @@ } } }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/admin/signature-flow/config": { - "post": { - "operationId": "admin-set-signature-flow-config", - "summary": "Set signature flow configuration", + "/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": [ - "admin" + "policy" ], "security": [ { @@ -12576,30 +14307,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", @@ -12613,6 +14320,16 @@ "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", @@ -12626,7 +14343,7 @@ ], "responses": { "200": { - "description": "Configuration saved successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -12646,7 +14363,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/MessageResponse" + "$ref": "#/components/schemas/SystemPolicyResponse" } } } @@ -12654,9 +14371,98 @@ } } } + } + } + }, + "post": { + "operationId": "policy-set-system", + "summary": "Save a system-level policy value", + "description": "This endpoint requires admin access", + "tags": [ + "policy" + ], + "security": [ + { + "bearer_auth": [] }, - "400": { - "description": "Invalid signature flow mode provided", + { + "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": { @@ -12676,7 +14482,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/SystemPolicyWriteResponse" } } } @@ -12685,8 +14491,8 @@ } } }, - "500": { - "description": "Internal server error", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -12718,13 +14524,13 @@ } } }, - "/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}/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": [ - "admin" + "policy" ], "security": [ { @@ -12734,31 +14540,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", @@ -12772,6 +14553,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 read for the selected user.", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-z0-9_]+$" + } + }, { "name": "OCS-APIRequest", "in": "header", @@ -12785,37 +14586,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 +14606,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ErrorResponse" + "$ref": "#/components/schemas/UserPolicyResponse" } } } @@ -12844,8 +14615,8 @@ } } }, - "500": { - "description": "Internal server error", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -12875,15 +14646,13 @@ } } } - } - }, - "/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)", + }, + "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": [ - "admin" + "policy" ], "security": [ { @@ -12892,7 +14661,44 @@ { "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", @@ -12906,6 +14712,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", @@ -12919,7 +14745,7 @@ ], "responses": { "200": { - "description": "List of active signings", + "description": "OK", "content": { "application/json": { "schema": { @@ -12939,7 +14765,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/ActiveSigningsResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -12948,8 +14774,8 @@ } } }, - "500": { - "description": "", + "400": { + "description": "Invalid policy value", "content": { "application/json": { "schema": { @@ -12977,155 +14803,9 @@ } } } - } - } - } - }, - "/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", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -13145,7 +14825,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlListResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13155,15 +14835,13 @@ } } } - } - }, - "/ocs/v2.php/apps/libresign/api/{apiVersion}/crl/revoke": { - "post": { - "operationId": "crl_api-revoke", - "summary": "Revoke a certificate by serial number", + }, + "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": [ - "crl_api" + "policy" ], "security": [ { @@ -13173,36 +14851,6 @@ "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", @@ -13216,6 +14864,26 @@ "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", @@ -13229,7 +14897,7 @@ ], "responses": { "200": { - "description": "Certificate revoked successfully", + "description": "OK", "content": { "application/json": { "schema": { @@ -13249,7 +14917,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/UserPolicyWriteResponse" } } } @@ -13259,7 +14927,7 @@ } }, "400": { - "description": "Invalid parameters", + "description": "User-scope not supported", "content": { "application/json": { "schema": { @@ -13279,7 +14947,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } @@ -13288,8 +14956,8 @@ } } }, - "404": { - "description": "Certificate not found", + "403": { + "description": "Forbidden", "content": { "application/json": { "schema": { @@ -13309,7 +14977,7 @@ "$ref": "#/components/schemas/OCSMeta" }, "data": { - "$ref": "#/components/schemas/CrlRevokeResponse" + "$ref": "#/components/schemas/ErrorResponse" } } } diff --git a/openapi.json b/openapi.json index e3fac16bee..b6e4e46933 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,20 @@ } } }, + "ValidatePolicySnapshot": { + "type": "object", + "properties": { + "docmdp": { + "$ref": "#/components/schemas/PolicySnapshotNumericEntry" + }, + "signature_flow": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + }, + "add_footer": { + "$ref": "#/components/schemas/PolicySnapshotEntry" + } + } + }, "ValidatedChildFile": { "type": "object", "required": [ @@ -4160,6 +4388,11 @@ "format": "int64", "default": 50, "description": "Height of preview in points (default: 50)" + }, + "writeQrcodeOnFooter": { + "type": "boolean", + "nullable": true, + "description": "Whether to force QR code rendering in footer preview (null uses policy)" } } } @@ -7407,13 +7640,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": [ { @@ -7423,66 +7655,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", @@ -7529,44 +7701,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" } } } @@ -7576,11 +7711,873 @@ } } } - }, - "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": [ + "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" + } + } + } + } + } + } + } + }, + "400": { + "description": "User-scope not supported", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + } + } + } + }, + "/ocs/v2.php/apps/libresign/api/{apiVersion}/request-signature": { + "post": { + "operationId": "request_signature-request", + "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": [ + "request_signature" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "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" + }, + "policy": { + "type": "object", + "nullable": true, + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } + } + } + } + } + } + }, + "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/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" + } + ] + } + } + } + } + } + } + } + } + } + }, + "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": [ "request_signature" ], @@ -7641,10 +8638,13 @@ "nullable": true, "description": "Numeric code of status * 0 - no signers * 1 - signed * 2 - pending" }, - "signatureFlow": { - "type": "string", + "policy": { + "type": "object", "nullable": true, - "description": "Signature flow mode: 'parallel' or 'ordered_numeric'. If not provided, uses global configuration" + "description": "Structured policy payload with request-level overrides and active context.", + "additionalProperties": { + "type": "object" + } }, "name": { "type": "string", diff --git a/playwright/e2e/multi-signer-sequential.spec.ts b/playwright/e2e/multi-signer-sequential.spec.ts index 8cfbc241f5..066e44ee3c 100644 --- a/playwright/e2e/multi-signer-sequential.spec.ts +++ b/playwright/e2e/multi-signer-sequential.spec.ts @@ -3,11 +3,76 @@ * 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, test as base } 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' +import { makeAdminContext } from '../support/system-policies' +import { setSystemPolicyEntry } from '../support/policy-api' + +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, +}) + +type OriginalConfigSnapshot = { + identifyMethods: string | null + signatureEngine: string | null + tsaUrl: string | null + footerPolicy: string | null +} + +const test = base.extend<{ + adminContext: APIRequestContext + originalConfigSnapshot: OriginalConfigSnapshot +}>({ + adminContext: async ({}, use) => { + const ctx = await makeAdminContext() + await use(ctx) + await ctx.dispose() + }, + originalConfigSnapshot: async ({ request, adminContext }, use) => { + const response = await adminContext.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${FOOTER_POLICY_KEY}`, { + failOnStatusCode: false, + }) + expect(response.status(), `getSystemFooterPolicy: expected 200 but got ${response.status()}`).toBe(200) + const policyBody = await response.json() as { + ocs?: { + data?: { + policy?: { + value?: string | null + } + } + } + } + + await use({ + identifyMethods: await getAppConfig(request, 'libresign', 'identify_methods'), + signatureEngine: await getAppConfig(request, 'libresign', 'signature_engine'), + tsaUrl: await getAppConfig(request, 'libresign', 'tsa_url'), + footerPolicy: policyBody.ocs?.data?.policy?.value ?? null, + }) + }, +}) + +test.setTimeout(120_000) + +test.afterEach(async ({ page, adminContext, originalConfigSnapshot }) => { + await restoreAppConfig(page.request, 'identify_methods', originalConfigSnapshot.identifyMethods) + await restoreAppConfig(page.request, 'signature_engine', originalConfigSnapshot.signatureEngine) + await restoreAppConfig(page.request, 'tsa_url', originalConfigSnapshot.tsaUrl) + await setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, originalConfigSnapshot.footerPolicy ?? FOOTER_DISABLED_VALUE, true) +}) async function addEmailSigner( page: Page, @@ -27,32 +92,49 @@ 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', - }) - 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') +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, adminContext }) => { + 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 setSystemPolicyEntry(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) + }) const mailpit = createMailpitClient() await mailpit.deleteMessages() diff --git a/playwright/e2e/policy-preferences-visibility.spec.ts b/playwright/e2e/policy-preferences-visibility.spec.ts new file mode 100644 index 0000000000..69b5a4ca9f --- /dev/null +++ b/playwright/e2e/policy-preferences-visibility.spec.ts @@ -0,0 +1,129 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + configureOpenSsl, + ensureGroupExists, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + setGroupPolicyEntry, + setSystemPolicyEntry, + waitForPolicyCanSaveAsUserDefault, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + endUserRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + endUserRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +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-preferences-group' +const END_USER = 'policy-preferences-member' +const POLICY_KEY = 'signature_flow' +const FOOTER_POLICY_KEY = 'add_footer' +const FOOTER_ENABLED_VALUE = JSON.stringify({ + enabled: true, + writeQrcodeOnFooter: true, + validationSite: '', + customizeFooterTemplate: false, +}) +const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +async function resetPolicyPreferencesState( + adminRequestContext: APIRequestContext, + endUserRequestContext: APIRequestContext, +): Promise { + await clearUserPolicyPreference(endUserRequestContext, POLICY_KEY) + await clearUserPolicyPreference(endUserRequestContext, FOOTER_POLICY_KEY) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE, true) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) +} + +test.beforeEach(async ({ page, adminRequestContext, endUserRequestContext }) => { + await ensureUserExists(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await ensureGroupExists(page.request, GROUP_ID) + await ensureUserInGroup(page.request, END_USER, GROUP_ID) + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) +}) + +test.afterEach(async ({ adminRequestContext, endUserRequestContext }) => { + await resetPolicyPreferencesState(adminRequestContext, endUserRequestContext) +}) + +test('group member sees Preferences controls only when lower-layer customization is allowed', async ({ page, adminRequestContext, endUserRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', false) + await setSystemPolicyEntry(adminRequestContext, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, false) + + let effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('ordered_numeric') + expect(effectivePolicy?.canSaveAsUserDefault).toBe(false) + + await login(page.request, END_USER, DEFAULT_TEST_PASSWORD) + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, POLICY_KEY, 'ordered_numeric', true) + + effectivePolicy = await getEffectivePolicy(endUserRequestContext, POLICY_KEY) + expect(effectivePolicy?.canSaveAsUserDefault).toBe(true) + + await setGroupPolicyEntry(adminRequestContext, GROUP_ID, FOOTER_POLICY_KEY, FOOTER_ENABLED_VALUE, true) + await waitForPolicyCanSaveAsUserDefault(endUserRequestContext, FOOTER_POLICY_KEY, true) + + await page.goto('./apps/libresign/f/preferences') + await expandSettingsMenu(page) + + const customizeTemplateToggle = page.getByText('Customize footer template', { exact: true }) + if (await customizeTemplateToggle.count() === 0) { + const enableFooterToggle = page.getByText('Add visible footer with signature details', { exact: true }) + await expect(enableFooterToggle).toBeVisible() + await enableFooterToggle.click() + } + await expect(customizeTemplateToggle).toBeVisible() + const footerTemplateLabel = page.getByText('Footer template', { exact: true }) + await customizeTemplateToggle.click() + await expect(footerTemplateLabel).toBeVisible() + + await customizeTemplateToggle.click() + await expect(footerTemplateLabel).toHaveCount(0) +}) 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..3a91748367 --- /dev/null +++ b/playwright/e2e/policy-settings-menu-visibility.spec.ts @@ -0,0 +1,140 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +/** + * Scenario: Policies menu visibility follows delegated customization capability. + * + * 1. (API) Instance admin enables allowChildOverride on system policy. + * 2. (API) No group rule exists yet. + * 3. (Browser) Log in as group admin → "Policies" nav item must be visible. + * 4. (Browser) Navigate to Policies → editable policy card must be visible. + * 5. (Browser) Click "Configure" → setting dialog opens. + * 6. (Browser) Click "Create rule" inside dialog → scope-selector dialog opens. + * 7. (Browser) Group admin can open "Create rule" and start creating a delegated rule. + * + * 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, test as base, type APIRequestContext } from '@playwright/test' +import { login } from '../support/nc-login' +import { expandSettingsMenu } from '../support/nc-navigation' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + createAuthenticatedRequestContext, + getEffectivePolicy, + setGroupPolicyEntry, + setSystemPolicyEntry, +} from '../support/policy-api' + +// One serial block: a single browser session for the group admin +// across both phases avoids repeated login overhead. +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +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, +}) + + +test.afterEach(async ({ adminRequestContext }) => { + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, null, true) +}) + +test('policies nav item is visible when group admin can customize policies even before first group rule exists', async ({ page, adminRequestContext, groupAdminRequestContext }) => { + // ── 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) + await ensureSubadminOfGroup(page.request, GROUP_ADMIN, GROUP_ID) + + // ── 1. Admin: enable delegated customization at system layer ─────────── + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, FOOTER_ENABLED_VALUE, true) + + const editablePolicy = await getEffectivePolicy(groupAdminRequestContext, POLICY_KEY) + expect(editablePolicy?.editableByCurrentActor).toBe(true) + await setGroupPolicyEntry(groupAdminRequestContext, GROUP_ID, POLICY_KEY, FOOTER_ENABLED_VALUE, true) + + // ── 2. Log in as group admin ─────────────────────────────────────────── + await login(page.request, GROUP_ADMIN, GROUP_ADMIN_PASSWORD) + 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 delegated customization is allowed').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 ────── + 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|Signing order/i }) + await expect(settingDialog, 'Policy 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({ timeout: 10000 }) + await expect(createRuleButton).toBeEnabled() + + // ── 8. Clicking "Create rule" opens the scope-selector ("create policy modal") ── + await createRuleButton.click() + + 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 }) + + await createPolicyDialog.getByRole('option', { name: /^Group/ }).click() + + const targetGroupsField = page.getByLabel('Target groups') + await expect(targetGroupsField).toBeVisible({ timeout: 10000 }) + await page.getByPlaceholder('Search groups').fill(GROUP_ID) + await page.getByRole('option', { name: GROUP_ID }).first().click() + + await Promise.any([ + createPolicyDialog.getByRole('option', { name: /^Group/ }).waitFor({ state: 'visible', timeout: 10000 }), + page.getByLabel('Target groups').waitFor({ state: 'visible', timeout: 10000 }), + ]) +}) 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..2e12957a60 --- /dev/null +++ b/playwright/e2e/policy-workbench-personas-permissions.spec.ts @@ -0,0 +1,186 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext } from '@playwright/test' +import { + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + getEffectivePolicy, + policyRequest, +} from '../support/policy-api' + +const test = base.extend<{ + adminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +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' + + +test.afterEach(async ({ adminRequestContext }) => { + await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: true }, + ) +}) + +test('personas can manage policies according to permissions and override toggles', async ({ page, adminRequestContext }) => { + 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 groupAdminRequest = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, DEFAULT_TEST_PASSWORD) + const endUserRequest = await createAuthenticatedRequestContext(END_USER, DEFAULT_TEST_PASSWORD) + + // Normalize user-level state before assertions. + await clearUserPolicyPreference(groupAdminRequest, POLICY_KEY) + await clearUserPolicyPreference(endUserRequest, POLICY_KEY) + + // Global admin defines baseline and group policy with override enabled. + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + result = await policyRequest( + adminRequestContext, + '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, POLICY_KEY) + 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, POLICY_KEY) + expect(endUserEffective?.effectiveValue).toBe('parallel') + expect(endUserEffective?.sourceScope).toBe('user') + expect(endUserEffective?.canSaveAsUserDefault).toBe(true) + await Promise.all([ + groupAdminRequest.dispose(), + endUserRequest.dispose(), + ]) +}) + +test('admin can remove explicit instance policy and restore system baseline', async ({ page, adminRequestContext }) => { + await ensureUserExists(page.request, INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + const instanceResetUserRequest = await createAuthenticatedRequestContext(INSTANCE_RESET_USER, DEFAULT_TEST_PASSWORD) + + await clearUserPolicyPreference(instanceResetUserRequest, POLICY_KEY) + + let result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: 'parallel', allowChildOverride: true }, + ) + expect(result.httpStatus).toBe(200) + + let effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('parallel') + expect(effectivePolicy?.sourceScope).toBe('global') + + result = await policyRequest( + adminRequestContext, + 'POST', + `/apps/libresign/api/v1/policies/system/${POLICY_KEY}`, + { value: null, allowChildOverride: false }, + ) + expect(result.httpStatus).toBe(200) + + effectivePolicy = await getEffectivePolicy(instanceResetUserRequest, POLICY_KEY) + expect(effectivePolicy?.effectiveValue).toBe('none') + expect(effectivePolicy?.sourceScope).toBe('system') + await 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..b8060a7828 --- /dev/null +++ b/playwright/e2e/policy-workbench-system-default-persistence.spec.ts @@ -0,0 +1,441 @@ +/** + * 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 actionPattern = actionName === 'Remove' + ? /^(Remove|Delete)$/i + : /^Edit$/i + const actionItem = page + .locator('.action-item:visible, [role="menuitem"]:visible, li.action:visible') + .filter({ hasText: actionPattern }) + .first() + + if (!(await actionItem.isVisible().catch(() => false))) { + return false + } + + const clicked = await actionItem.click({ timeout: 1500 }).then(() => true).catch(() => false) + if (!clicked) { + return false + } + + 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..10f712a280 100644 --- a/playwright/e2e/sign-email-token-authenticated.spec.ts +++ b/playwright/e2e/sign-email-token-authenticated.spec.ts @@ -5,8 +5,11 @@ 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' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() /** * An authenticated Nextcloud user can sign a document via the email+token @@ -41,22 +44,21 @@ 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') const mailpit = createMailpitClient() await mailpit.deleteMessages() - - await page.goto('./apps/libresign') + await page.goto('./apps/libresign/f/request') 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 the admin's own email as the signer. - // Only the email method is active so there are no tabs in the Add signer dialog. + // Add signer by email to exercise the email-token flow deterministically. await page.getByRole('button', { name: 'Add signer' }).click() await page.getByPlaceholder('Email').click() await page.getByPlaceholder('Email').pressSequentially('admin@email.tld', { delay: 50 }) - await page.getByRole('option', { name: 'admin@email.tld' }).click() - await page.getByRole('textbox', { name: 'Signer name' }).fill('Admin') + await page.getByRole('option', { name: 'admin@email.tld' }).first().click() + await page.getByRole('textbox', { name: 'Signer name' }).first().fill('Admin') await page.getByRole('button', { name: 'Save' }).click() await page.getByRole('button', { name: 'Request signatures' }).click() @@ -72,11 +74,19 @@ test('sign document with email token as authenticated signer', async ({ page }) // throwIfIsAuthenticatedWithDifferentAccount allows this because // admin@email.tld === the signer's email address. await page.goto(signLink) - await page.getByRole('button', { name: 'Sign the document.' }).click() + const openSignButton = page.getByRole('button', { name: 'Sign the document.' }).first() + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + await Promise.any([ + openSignButton.waitFor({ state: 'visible', timeout: 10_000 }), + emailTextbox.waitFor({ state: 'visible', timeout: 10_000 }), + ]) + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click() + } - // Complete the email token identification flow. - // The email field may be pre-filled with the admin's address; fill() is safe either way. - await page.getByRole('textbox', { name: 'Email' }).fill('admin@email.tld') + // Email-token verification must happen in this scenario. + await expect(emailTextbox).toBeVisible() + await emailTextbox.fill('admin@email.tld') await page.getByRole('button', { name: 'Send verification code' }).click() const tokenEmail = await waitForEmailTo(mailpit, 'admin@email.tld', 'LibreSign: Code to sign file') @@ -87,8 +97,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/sign-email-token-unauthenticated.spec.ts b/playwright/e2e/sign-email-token-unauthenticated.spec.ts index c618412eef..06d7cf711d 100644 --- a/playwright/e2e/sign-email-token-unauthenticated.spec.ts +++ b/playwright/e2e/sign-email-token-unauthenticated.spec.ts @@ -7,6 +7,9 @@ import { test, expect } from '@playwright/test'; import { login } from '../support/nc-login' import { configureOpenSsl, deleteAppConfig, setAppConfig } from '../support/nc-provisioning' import { createMailpitClient, waitForEmailTo, extractSignLink, extractTokenFromEmail } from '../support/mailpit' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() test('sign document with email token as unauthenticated signer', async ({ page }) => { await login( @@ -65,12 +68,18 @@ test('sign document with email token as unauthenticated signer', async ({ page } const signLink = extractSignLink(email.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('textbox', { name: 'Email' }).click(); - await page.getByRole('textbox', { name: 'Email' }).fill('signer01@libresign.coop'); - await page.getByRole('button', { name: 'Send verification code' }).click(); + const openSignButton = page.getByRole('button', { name: 'Sign the document.' }).first() + if (await openSignButton.isVisible().catch(() => false)) { + await openSignButton.click(); + } + const emailTextbox = page.getByRole('textbox', { name: 'Email' }).first() + if (await emailTextbox.isVisible().catch(() => false)) { + await emailTextbox.click(); + await emailTextbox.fill('signer01@libresign.coop'); + await page.getByRole('button', { name: 'Send verification code' }).click(); + } - const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file') + const tokenEmail = await waitForEmailTo(mailpit, 'signer01@libresign.coop', 'LibreSign: Code to sign file', { timeout: 60_000 }) const token = extractTokenFromEmail(tokenEmail.Text) if (!token) throw new Error('Token not found in email') await page.getByRole('textbox', { name: 'Enter your code' }).click(); @@ -81,8 +90,17 @@ test('sign document with email token as unauthenticated signer', async ({ page } await expect(page.getByText('Step 3 of 3 - Signature')).toBeVisible(); await expect(page.getByText('Your identity has been')).toBeVisible(); await expect(page.getByText('You can now sign the document.')).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(); await expect(page.getByRole('button', { name: 'Sign the document.' })).not.toBeVisible(); diff --git a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts index f4f950a46f..bab1d12d6c 100644 --- a/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts +++ b/playwright/e2e/sign-herself-updates-files-list-with-native-engine.spec.ts @@ -6,6 +6,9 @@ import { expect, test, type Page } from '@playwright/test' import { login } from '../support/nc-login' import { configureOpenSsl, setAppConfig } from '../support/nc-provisioning' +import { useFooterPolicyGuard } from '../support/system-policies' + +useFooterPolicyGuard() async function sortByCreatedAtDescending(page: Page) { const createdAtTh = page.getByRole('columnheader', { name: 'Created at' }) @@ -77,8 +80,17 @@ test('updates files list status after signing with native engine', async ({ page const signButton = page.getByRole('button', { name: 'Sign the document.' }) await expect(signButton).toBeVisible() await signButton.click() + 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 page.locator('#fileslist').getByRole('link', { name: 'Files' }).click() 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..68903728e7 --- /dev/null +++ b/playwright/e2e/signature-flow-policy-request-sidebar.spec.ts @@ -0,0 +1,208 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { expect, test as base, type APIRequestContext, type Page } from '@playwright/test' +import { login } from '../support/nc-login' +import { + configureOpenSsl, + ensureGroupExists, + ensureSubadminOfGroup, + ensureUserExists, + ensureUserInGroup, + setAppConfig, +} from '../support/nc-provisioning' +import { + clearUserPolicyPreference, + createAuthenticatedRequestContext, + setSystemPolicyEntry, +} from '../support/policy-api' + +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' +const ADMIN_USER = process.env.NEXTCLOUD_ADMIN_USER ?? 'admin' +const ADMIN_PASSWORD = process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin' + +const test = base.extend<{ + adminRequestContext: APIRequestContext + groupAdminRequestContext: APIRequestContext +}>({ + adminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(ADMIN_USER, ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, + groupAdminRequestContext: async ({}, use) => { + const ctx = await createAuthenticatedRequestContext(GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await use(ctx) + await ctx.dispose() + }, +}) + +test.setTimeout(120_000) +test.describe.configure({ mode: 'serial' }) + + + +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.afterEach(async ({ adminRequestContext, groupAdminRequestContext }) => { + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'none', true) + await setAppConfig(adminRequestContext, 'libresign', 'groups_request_sign', JSON.stringify(['admin'])) +}) + +test('request sidebar persists signature flow preference through policies endpoint', async ({ page, adminRequestContext }) => { + await login(page.request, ADMIN_USER, ADMIN_PASSWORD) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequestContext, + '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 setSystemPolicyEntry(adminRequestContext, POLICY_KEY, 'parallel', true) + await clearUserPolicyPreference(adminRequestContext, POLICY_KEY, [200, 401, 500]) + + 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') + + 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) +}) + +for (const systemFlow of ['ordered_numeric', 'parallel'] as const) { + test(`fixed system ${systemFlow} signature flow hides request toggles for groupadmin`, async ({ page, adminRequestContext, groupAdminRequestContext }) => { + await ensureUserExists(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_PASSWORD) + await ensureGroupExists(adminRequestContext, GROUP_ADMIN_GROUP) + await ensureUserInGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + await ensureSubadminOfGroup(adminRequestContext, GROUP_ADMIN_USER, GROUP_ADMIN_GROUP) + + await configureOpenSsl(adminRequestContext, 'LibreSign Test', { + C: 'BR', + OU: ['Organization Unit'], + ST: 'Rio de Janeiro', + O: 'LibreSign', + L: 'Rio de Janeiro', + }) + + await setAppConfig( + adminRequestContext, + '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( + adminRequestContext, + 'libresign', + 'groups_request_sign', + JSON.stringify(['admin', GROUP_ADMIN_GROUP]), + ) + + await setSystemPolicyEntry(adminRequestContext, POLICY_KEY, systemFlow, false) + await clearUserPolicyPreference(groupAdminRequestContext, POLICY_KEY, [200, 401, 500]) + + 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 requestData = response.request() + const body = requestData.postData() ?? '' + return response.url().includes('/apps/libresign/api/v1/request-signature') + && ['POST', 'PATCH'].includes(requestData.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]) + } + }) +} diff --git a/playwright/e2e/signature-footer-qrcode-preview.spec.ts b/playwright/e2e/signature-footer-qrcode-preview.spec.ts new file mode 100644 index 0000000000..4d97927847 --- /dev/null +++ b/playwright/e2e/signature-footer-qrcode-preview.spec.ts @@ -0,0 +1,182 @@ +/** + * 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, Request } from '@playwright/test' +import { login } from '../support/nc-login' + +test.describe.configure({ mode: 'serial', retries: 0, timeout: 90000 }) + +const PREVIEW_URL_PATTERN = /admin\/footer-template\/preview-pdf/ + +async function captureNextPreviewRequest(page: Page): Promise { + return page.waitForRequest( + (req) => req.method() === 'POST' && PREVIEW_URL_PATTERN.test(req.url()), + { timeout: 15000 }, + ) +} + +/** + * Click the visual toggle area of an NcCheckboxRadioSwitch. + * + * NcCheckboxRadioSwitch renders the interactive content in a child + * `.checkbox-radio-switch__content` span that has `onClick: onToggle` + * bound to it. Clicking the outer container span is unreliable because + * events may not reach the handler; clicking the content span directly + * is the correct approach. + */ +async function clickSwitch(switchContainer: Locator): Promise { + await switchContainer.locator('.checkbox-radio-switch__content').click() +} + +async function openFooterPolicyEditor(page: Page) { + await page.goto('./settings/admin/libresign') + + // Wait for the Policy Workbench section to load + const footerCard = page.getByRole('button', { name: /Signature footer/i }).first() + await expect(footerCard).toBeVisible({ timeout: 20000 }) + await footerCard.click() + + // Expect the footer settings dialog to appear + const dialog = page.getByRole('dialog').filter({ hasText: /Signature footer/i }).first() + await expect(dialog).toBeVisible({ timeout: 10000 }) + + return dialog +} + +async function clickChangeOrCreateRule(dialog: ReturnType) { + const changeBtn = dialog.getByRole('button', { name: /^Change$/i }).first() + if (await changeBtn.isVisible({ timeout: 3000 }).catch(() => false)) { + await changeBtn.click() + } else { + const createBtn = dialog.getByRole('button', { name: /Create rule/i }).first() + await expect(createBtn).toBeVisible({ timeout: 5000 }) + await createBtn.click() + // If scope selection dialog appears, pick "Everyone" + const everyoneOption = dialog.page().locator('[role="option"]').filter({ hasText: /Everyone/i }).first() + if (await everyoneOption.isVisible({ timeout: 3000 }).catch(() => false)) { + await everyoneOption.click() + } + } + + // Wait for the rule editor to appear + const ruleDialog = dialog.page().getByRole('dialog', { name: /Edit rule|Create rule/i }).last() + await expect(ruleDialog).toBeVisible({ timeout: 8000 }) + return ruleDialog +} + +test('toggleing writeQrcodeOnFooter sends correct flag to preview API and QR code appears/disappears in preview', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable the footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable QR code + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + await expect(qrcodeSwitch).toBeVisible({ timeout: 5000 }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (!(await qrcodeInput.isChecked().catch(() => false))) { + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + } + + // Enable template customization to show the preview + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + await expect(templateSwitch).toBeVisible({ timeout: 5000 }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const previewReqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await previewReqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // --- STEP 1: QR OFF → preview sends false --- + const qrOffReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + const qrOffReq = await qrOffReqPromise + const qrOffBody = qrOffReq.postDataJSON() as Record + expect(qrOffBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be false when switch is OFF').toBe(false) + + // --- STEP 2: QR ON → preview sends true --- + const qrOnReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const qrOnReq = await qrOnReqPromise + const qrOnBody = qrOnReq.postDataJSON() as Record + expect(qrOnBody.writeQrcodeOnFooter, 'writeQrcodeOnFooter should be true when switch is ON').toBe(true) + + // --- STEP 3: Assert the response is a valid PDF when writeQrcodeOnFooter is true --- + const previewResponse = await page.waitForResponse( + (res) => res.request().method() === 'POST' && PREVIEW_URL_PATTERN.test(res.url()), + { timeout: 15000 }, + ) + expect(previewResponse.status(), 'Preview endpoint should return 200').toBe(200) + expect(previewResponse.headers()['content-type']).toContain('pdf') + const body = await previewResponse.body() + expect(body.length, 'PDF response should not be empty').toBeGreaterThan(100) + expect(body.subarray(0, 4).toString(), 'Response should start with %PDF').toBe('%PDF') +}) + +test('preview request always includes writeQrcodeOnFooter when template is customized', async ({ page }) => { + await login( + page.request, + process.env.NEXTCLOUD_ADMIN_USER ?? 'admin', + process.env.NEXTCLOUD_ADMIN_PASSWORD ?? 'admin', + ) + + const dialog = await openFooterPolicyEditor(page) + const ruleDialog = await clickChangeOrCreateRule(dialog) + + // Enable footer + const enableSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Add visible footer/i }) + if (!(await enableSwitch.locator('input').isChecked().catch(() => false))) { + await clickSwitch(enableSwitch) + await expect(enableSwitch.locator('input')).toBeChecked({ timeout: 5000 }) + } + + // Enable template + const templateSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Customize footer template/i }) + const templateInput = templateSwitch.locator('input') + if (!(await templateInput.isChecked().catch(() => false))) { + const reqPromise = captureNextPreviewRequest(page) + await clickSwitch(templateSwitch) + await reqPromise + await expect(templateInput).toBeChecked({ timeout: 5000 }) + } + + // Ensure QR is OFF, then set to ON and verify + const qrcodeSwitch = ruleDialog.locator('.checkbox-radio-switch').filter({ hasText: /Write QR code on footer/i }) + const qrcodeInput = qrcodeSwitch.locator('input') + if (await qrcodeInput.isChecked().catch(() => false)) { + const offReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).not.toBeChecked({ timeout: 5000 }) + await offReqPromise + } + + // Turn QR ON and verify the request body + const onReqPromise = captureNextPreviewRequest(page) + await clickSwitch(qrcodeSwitch) + await expect(qrcodeInput).toBeChecked({ timeout: 5000 }) + const onReq = await onReqPromise + const body = onReq.postDataJSON() as Record + + expect(Object.prototype.hasOwnProperty.call(body, 'writeQrcodeOnFooter'), + 'writeQrcodeOnFooter field must be present in the preview request').toBe(true) + expect(body.writeQrcodeOnFooter, 'writeQrcodeOnFooter must be true when switch is ON').toBe(true) +}) 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-navigation.ts b/playwright/support/nc-navigation.ts new file mode 100644 index 0000000000..eb58ee0c74 --- /dev/null +++ b/playwright/support/nc-navigation.ts @@ -0,0 +1,29 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Browser-level navigation helpers for the Nextcloud / LibreSign UI. + */ + +import type { Page } from '@playwright/test' + +/** + * Ensures the "Settings" section of the Nextcloud left sidebar is expanded + * so that links like "Account" and "Policies" are visible. + * + * Works both when the sidebar is already expanded and when it still shows + * only the collapsed "Settings" toggle button. + */ +export async function expandSettingsMenu(page: 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() + } +} diff --git a/playwright/support/nc-provisioning.ts b/playwright/support/nc-provisioning.ts index 39e5665c37..35ccd08db0 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', @@ -45,6 +68,8 @@ async function ocsRequest( } if (jsonBody !== undefined) { headers['Content-Type'] = 'application/json' + } else if (body !== undefined) { + headers['Content-Type'] = 'application/x-www-form-urlencoded' } const response = await request[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](url, { headers, @@ -124,6 +149,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 +265,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 +331,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/playwright/support/policy-api.ts b/playwright/support/policy-api.ts new file mode 100644 index 0000000000..da5f24bc2a --- /dev/null +++ b/playwright/support/policy-api.ts @@ -0,0 +1,193 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Generic helpers for the LibreSign Policy OCS API, shared across all + * policy-related spec files. + */ + +import { expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type OcsPolicyResponse = { + ocs?: { + meta?: { statuscode?: number; message?: string } + data?: Record + } +} + +export type PolicyApiResult = { + httpStatus: number + statusCode: number + message: string + data: Record +} + +export type EffectivePolicyEntry = { + effectiveValue?: unknown + sourceScope?: string + canSaveAsUserDefault?: boolean + editableByCurrentActor?: boolean + allowedValues?: unknown[] +} + +// --------------------------------------------------------------------------- +// HTTP context +// --------------------------------------------------------------------------- + +/** + * Creates a Playwright `APIRequestContext` pre-configured with OCS headers + * and Basic authentication for the given user. + */ +export 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', + }, + }) +} + +// --------------------------------------------------------------------------- +// Low-level OCS request wrapper +// --------------------------------------------------------------------------- + +/** + * Issues an OCS request to the LibreSign policy API and returns a normalised + * result object. Never throws on non-2xx — callers decide what is acceptable. + */ +export async function policyRequest( + requestContext: APIRequestContext, + method: 'GET' | 'POST' | 'PUT' | 'DELETE', + path: string, + body?: Record, +): Promise { + 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 ?? {}, + } +} + +// --------------------------------------------------------------------------- +// Policy read helpers +// --------------------------------------------------------------------------- + +/** + * Returns the effective policy entry for `policyKey` from the + * `/policies/effective` endpoint, or `null` when the key is absent. + */ +export async function getEffectivePolicy( + requestContext: APIRequestContext, + policyKey: string, +): Promise { + const result = await policyRequest(requestContext, 'GET', '/apps/libresign/api/v1/policies/effective') + const policies = (result.data.policies ?? {}) as Record + return policies[policyKey] ?? null +} + +/** + * Polls until `canSaveAsUserDefault` reaches the expected value. + * Throws after `maxAttempts` unsuccessful reads. + */ +export async function waitForPolicyCanSaveAsUserDefault( + requestContext: APIRequestContext, + policyKey: string, + expected: boolean, + maxAttempts = 10, +): Promise { + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const entry = await getEffectivePolicy(requestContext, policyKey) + if (entry?.canSaveAsUserDefault === expected) { + return + } + } + + throw new Error(`Policy ${policyKey} did not reach canSaveAsUserDefault=${expected} after ${maxAttempts} attempts`) +} + +// --------------------------------------------------------------------------- +// Policy write helpers +// --------------------------------------------------------------------------- + +/** + * Sets a system-level policy entry and asserts HTTP 200. + * Pass `value: null` to clear an explicit system value. + */ +export async function setSystemPolicyEntry( + ctx: APIRequestContext, + policyKey: string, + value: string | null, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'POST', + `/apps/libresign/api/v1/policies/system/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setSystemPolicyEntry(${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Sets a group-level policy entry and asserts HTTP 200. + */ +export async function setGroupPolicyEntry( + ctx: APIRequestContext, + groupId: string, + policyKey: string, + value: string, + allowChildOverride: boolean, +): Promise { + const response = await policyRequest( + ctx, + 'PUT', + `/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`, + { value, allowChildOverride }, + ) + expect(response.httpStatus, `setGroupPolicyEntry(${groupId}/${policyKey}): expected 200 but got ${response.httpStatus}`).toBe(200) +} + +/** + * Deletes the authenticated user's own preference for `policyKey`. + * Accepted statuses default to `[200, 500]`; pass `[200, 401, 500]` when the + * user may not yet exist at cleanup time. + */ +export async function clearUserPolicyPreference( + ctx: APIRequestContext, + policyKey: string, + acceptedStatuses: number[] = [200, 500], +): Promise { + const response = await policyRequest(ctx, 'DELETE', `/apps/libresign/api/v1/policies/user/${policyKey}`) + expect( + acceptedStatuses, + `clearUserPolicyPreference(${policyKey}): expected ${acceptedStatuses.join(' or ')} but got ${response.httpStatus}`, + ).toContain(response.httpStatus) +} diff --git a/playwright/support/system-policies.ts b/playwright/support/system-policies.ts new file mode 100644 index 0000000000..0aaae6b607 --- /dev/null +++ b/playwright/support/system-policies.ts @@ -0,0 +1,119 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Helpers for managing LibreSign system policies from Playwright tests. + * + * The `useFooterPolicyGuard()` function registers `test.beforeEach` / + * `test.afterEach` hooks that disable the footer policy before each test and + * restore the original value afterwards. Call it once at the top level of any + * spec file that triggers document signing, because the footer merge step + * requires PDFtk/Java which may not be available in every environment. + */ + +import { test, expect, request, type APIRequestContext } from '@playwright/test' + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +export const FOOTER_POLICY_KEY = 'add_footer' + +export const FOOTER_DISABLED_VALUE = JSON.stringify({ + enabled: false, + writeQrcodeOnFooter: false, + validationSite: '', + customizeFooterTemplate: false, +}) + +// --------------------------------------------------------------------------- +// Low-level helpers +// --------------------------------------------------------------------------- + +/** + * Creates a standalone admin `APIRequestContext` suitable for use in + * `beforeEach`/`afterEach` hooks where no `page` fixture is available. + */ +export 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', + }, + }) +} + +/** + * Reads the current value of a system policy. Returns `null` when the policy + * has not been set (HTTP 404). + */ +export async function getSystemPolicy(ctx: APIRequestContext, key: string): Promise { + const response = await ctx.get(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + failOnStatusCode: false, + }) + if (response.status() === 404) { + return null + } + + const payload = await response.json() as { ocs?: { data?: { value?: string | null } } } + return payload.ocs?.data?.value ?? null +} + +/** + * Writes a system policy value. When `value` is `null` (meaning the policy + * was not set before) this is a no-op so the absent state is preserved on + * restore. + */ +export async function setSystemPolicy(ctx: APIRequestContext, key: string, value: string | null): Promise { + if (value === null) { + return + } + + const response = await ctx.post(`./ocs/v2.php/apps/libresign/api/v1/policies/system/${key}`, { + data: { + value, + allowChildOverride: true, + }, + failOnStatusCode: false, + }) + + expect(response.status(), `setSystemPolicy(${key}): expected 200 but got ${response.status()}`).toBe(200) +} + +// --------------------------------------------------------------------------- +// Spec-level hook +// --------------------------------------------------------------------------- + +/** + * Registers `test.beforeEach` / `test.afterEach` hooks that disable the + * footer policy for the duration of each test and restore it afterwards. + * + * Call once at the top level of any spec file that exercises document signing: + * + * ```ts + * import { useFooterPolicyGuard } from '../support/system-policies' + * useFooterPolicyGuard() + * ``` + */ +export function useFooterPolicyGuard(): void { + let adminContext: APIRequestContext + let originalFooterPolicy: string | null + + test.beforeEach(async () => { + adminContext = await makeAdminContext() + originalFooterPolicy = await getSystemPolicy(adminContext, FOOTER_POLICY_KEY) + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, FOOTER_DISABLED_VALUE) + }) + + test.afterEach(async () => { + await setSystemPolicy(adminContext, FOOTER_POLICY_KEY, originalFooterPolicy) + await adminContext.dispose() + }) +} diff --git a/src/components/CodeEditor.vue b/src/components/CodeEditor.vue index 200ef5e897..14dbd36a5f 100644 --- a/src/components/CodeEditor.vue +++ b/src/components/CodeEditor.vue @@ -4,9 +4,14 @@ --> - + + + + + + import { t } from '@nextcloud/l10n' import { getCurrentUser } from '@nextcloud/auth' +import { loadState } from '@nextcloud/initial-state' import { generateUrl } from '@nextcloud/router' +import { computed } from 'vue' +import { usePoliciesStore } from '../../store/policies' import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem' import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper' import { mdiAccount, + mdiCogOutline, mdiStar, - mdiTune, + mdiShieldCheckOutline, + mdiTuneVariant, } from '@mdi/js' defineOptions({ @@ -46,6 +65,38 @@ defineOptions({ }) const isAdmin = getCurrentUser()?.isAdmin ?? false +const config = loadState<{ can_manage_group_policies?: boolean }>('libresign', 'config', {}) +const policiesStore = usePoliciesStore() + +const canManagePreferences = computed(() => { + return Object.values(policiesStore.policies).some((policy) => { + if (!policy || typeof policy !== 'object') { + return false + } + + const policyState = policy as { + canSaveAsUserDefault?: boolean + } + + return policyState.canSaveAsUserDefault === true + }) +}) + +const hasEditablePolicies = computed(() => Object.values(policiesStore.policies).some((policy) => { + if (!policy || typeof policy !== 'object') { + return false + } + + const policyState = policy as { + groupCount?: number + userCount?: number + editableByCurrentActor?: boolean + } + + return policyState.editableByCurrentActor === true +})) + +const canManagePolicies = computed(() => isAdmin || (Boolean(config.can_manage_group_policies) && hasEditablePolicies.value)) function getAdminRoute() { return generateUrl('settings/admin/libresign') @@ -53,5 +104,7 @@ function getAdminRoute() { defineExpose({ getAdminRoute, + canManagePreferences, + canManagePolicies, }) diff --git a/src/components/Signers/Signer.vue b/src/components/Signers/Signer.vue index a7b1ee9093..84bd917a90 100644 --- a/src/components/Signers/Signer.vue +++ b/src/components/Signers/Signer.vue @@ -105,10 +105,7 @@ const signatureFlow = computed(() => { const file = filesStore.getFile() const rawFlow = file?.signatureFlow let flow: SignatureFlowMode = 'parallel' - if (typeof rawFlow === 'number') { - const flowMap: Record = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' } - flow = flowMap[rawFlow] || 'parallel' - } else if (rawFlow === 'none' || rawFlow === 'parallel' || rawFlow === 'ordered_numeric') { + if (rawFlow === 'none' || rawFlow === 'parallel' || rawFlow === 'ordered_numeric') { flow = rawFlow } return flow diff --git a/src/components/Signers/Signers.vue b/src/components/Signers/Signers.vue index 61fc7170d2..223540bcf5 100644 --- a/src/components/Signers/Signers.vue +++ b/src/components/Signers/Signers.vue @@ -41,7 +41,6 @@ import draggable from 'vuedraggable' import Signer from './Signer.vue' import { useFilesStore } from '../../store/files.js' -import type { SignatureFlowValue } from '../../types/index' defineOptions({ name: 'Signers', @@ -51,14 +50,6 @@ type FilesStoreContract = ReturnType type SelectedFile = ReturnType type SignerListItem = NonNullable[number]> -function normalizeSignatureFlow(flow: SelectedFile['signatureFlow']): SignatureFlowValue | string | null | undefined { - if (typeof flow === 'number') { - const flowMap: Record = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' } - return flowMap[flow] - } - return flow -} - const props = withDefaults(defineProps<{ event?: string }>(), { @@ -89,8 +80,7 @@ const sortableSigners = computed({ }) const isOrderedNumeric = computed(() => { - const flow = normalizeSignatureFlow(filesStore.getFile()?.signatureFlow) - return flow === 'ordered_numeric' + return filesStore.getFile()?.signatureFlow === 'ordered_numeric' }) const canReorder = computed(() => filesStore.canSave() && (signers.value?.length || 0) > 1) diff --git a/src/helpers/pdfWorker.ts b/src/helpers/pdfWorker.ts index 1ec61beac9..ce57be4ee8 100644 --- a/src/helpers/pdfWorker.ts +++ b/src/helpers/pdfWorker.ts @@ -2,14 +2,38 @@ * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { ensureWorkerReady } from '@libresign/pdf-elements' +import { ensureWorkerReady, setWorkerPath } from '@libresign/pdf-elements' +import pdfWorkerPath from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url' let configured = false +const ensureUrlParseLocationSupport = (): void => { + if (typeof URL.parse !== 'function') { + return + } + + const currentParse = URL.parse as unknown as (input: unknown, base?: string | URL) => URL | null + if ((currentParse as { __libresignPatched?: boolean }).__libresignPatched) { + return + } + + const patchedParse = ((input: unknown, base?: string | URL): URL | null => { + if (typeof Location !== 'undefined' && input instanceof Location) { + return new URL(input.href) + } + return currentParse(input, base) + }) as unknown as typeof URL.parse + + ;(patchedParse as unknown as { __libresignPatched?: boolean }).__libresignPatched = true + URL.parse = patchedParse +} + export const ensurePdfWorker = (): void => { if (configured) { return } + ensureUrlParseLocationSupport() + setWorkerPath(pdfWorkerPath) configured = true void ensureWorkerReady() } diff --git a/src/router/router.ts b/src/router/router.ts index 26344bfa7a..25f455e86a 100644 --- a/src/router/router.ts +++ b/src/router/router.ts @@ -180,6 +180,16 @@ const routes: RouteRecordRaw[] = [ name: 'Account', component: () => import('../views/Account/Account.vue'), }, + { + path: '/f/preferences', + name: 'Preferences', + component: () => import('../views/Preferences/Preferences.vue'), + }, + { + path: '/f/policies', + name: 'Policies', + component: () => import('../views/Policies/Policies.vue'), + }, { path: '/f/docs/id-docs/validation', name: 'DocsIdDocsValidation', diff --git a/src/store/files.js b/src/store/files.js index aee1a10214..57de14b33f 100644 --- a/src/store/files.js +++ b/src/store/files.js @@ -17,6 +17,7 @@ import { generateOcsUrl } from '@nextcloud/router' import { useFilesSortingStore } from './filesSorting.js' import { useFiltersStore } from './filters.js' import { useIdentificationDocumentStore } from './identificationDocument.js' +import { usePoliciesStore } from './policies' import { useSidebarStore } from './sidebar.js' import { FILE_STATUS } from '../constants.js' import { getSigningRouteUuid } from '../utils/signRequestUuid.ts' @@ -164,7 +165,10 @@ import { getSigningRouteUuid } from '../utils/signRequestUuid.ts' * signers?: EditableSignerDraft[] | null * uuid?: string | null * status?: number | null - * signatureFlow?: SignatureFlowValue | null + * policy?: { + * overrides?: Record> + * activeContext?: { type: 'group', id: string } + * } * }} SaveSignatureRequestOptions */ @@ -728,7 +732,7 @@ const _filesStore = defineStore('files', () => { } const flow = selectedFile?.signatureFlow - const isOrderedNumeric = flow === 'ordered_numeric' || flow === 2 + const isOrderedNumeric = flow === 'ordered_numeric' if (!isOrderedNumeric) { return true } @@ -1197,17 +1201,34 @@ const _filesStore = defineStore('files', () => { * @param {SaveSignatureRequestOptions} [payload] * @returns {Promise} */ - async function saveOrUpdateSignatureRequest({ visibleElements = [], signers = null, uuid = null, status = 0, signatureFlow = null } = {}) { + async function saveOrUpdateSignatureRequest({ visibleElements = [], signers = null, uuid = null, status = 0, policy = null } = {}) { const store = getStore() + const policiesStore = usePoliciesStore() const currentFileKey = selectedFileId.value const selectedFile = getFile() const requestSigners = serializeRequestSigners(signers || selectedFile?.signers || []) const requestVisibleElements = serializeVisibleElements(visibleElements) + const canUseSignatureFlowOverride = policiesStore.canUseRequestOverride('signature_flow') + const canUseFooterOverride = policiesStore.canUseRequestOverride('add_footer') + + const rawPolicyOverrides = policy?.overrides && typeof policy.overrides === 'object' && !Array.isArray(policy.overrides) + ? policy.overrides + : {} + const policyOverrides = Object.fromEntries( + Object.entries(rawPolicyOverrides).filter(([key]) => key !== 'signature_flow' && key !== 'add_footer') + ) + const requestedSignatureFlow = rawPolicyOverrides.signature_flow ?? selectedFile?.signatureFlow ?? null + if (canUseSignatureFlowOverride && (requestedSignatureFlow === 'none' || requestedSignatureFlow === 'parallel' || requestedSignatureFlow === 'ordered_numeric')) { + policyOverrides.signature_flow = requestedSignatureFlow + } + const requestedFooterPolicy = rawPolicyOverrides.add_footer + if (canUseFooterOverride && typeof requestedFooterPolicy === 'string' && requestedFooterPolicy.trim() !== '') { + policyOverrides.add_footer = requestedFooterPolicy + } - let flowValue = signatureFlow || selectedFile.signatureFlow - if (typeof flowValue === 'number') { - const flowMap = { 0: 'none', 1: 'parallel', 2: 'ordered_numeric' } - flowValue = flowMap[flowValue] || 'parallel' + const policyPayload = { + ...(Object.keys(policyOverrides).length > 0 ? { overrides: policyOverrides } : {}), + ...(policy?.activeContext ? { activeContext: policy.activeContext } : {}), } const config = { @@ -1218,7 +1239,7 @@ const _filesStore = defineStore('files', () => { signers: requestSigners, visibleElements: requestVisibleElements, status, - signatureFlow: flowValue, + ...(Object.keys(policyPayload).length > 0 ? { policy: policyPayload } : {}), }, } diff --git a/src/store/policies.ts b/src/store/policies.ts new file mode 100644 index 0000000000..c41e046621 --- /dev/null +++ b/src/store/policies.ts @@ -0,0 +1,334 @@ +/** + * SPDX-FileCopyrightText: 2026 LibreCode coop and contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +import axios from '@nextcloud/axios' +import { loadState } from '@nextcloud/initial-state' +import { generateOcsUrl } from '@nextcloud/router' + +import type { + EffectivePolicyState, + EffectivePolicyValue, + EffectivePoliciesResponse, + EffectivePoliciesState, + GroupPolicyResponse, + GroupPolicyState, + GroupPolicyWritePayload, + GroupPolicyWriteResponse, + SystemPolicyWritePayload, + SystemPolicyResponse, + SystemPolicyState, + SystemPolicyWriteResponse, + UserPolicyResponse, + UserPolicyState, +} from '../types/index' + +function isEffectivePolicyState(value: unknown): value is EffectivePolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && Array.isArray(candidate.allowedValues) + && typeof candidate.sourceScope === 'string' + && typeof candidate.visible === 'boolean' + && typeof candidate.editableByCurrentActor === 'boolean' + && typeof candidate.canSaveAsUserDefault === 'boolean' + && typeof candidate.canUseAsRequestOverride === 'boolean' + && typeof candidate.preferenceWasCleared === 'boolean' + && (candidate.blockedBy === null || typeof candidate.blockedBy === 'string') +} + +function isGroupPolicyState(value: unknown): value is GroupPolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && candidate.scope === 'group' + && typeof candidate.targetId === 'string' + && typeof candidate.allowChildOverride === 'boolean' + && typeof candidate.visibleToChild === 'boolean' + && Array.isArray(candidate.allowedValues) +} + +function isSystemPolicyState(value: unknown): value is SystemPolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && (candidate.scope === 'system' || candidate.scope === 'global') + && typeof candidate.allowChildOverride === 'boolean' + && typeof candidate.visibleToChild === 'boolean' + && Array.isArray(candidate.allowedValues) +} + +function isUserPolicyState(value: unknown): value is UserPolicyState { + if (typeof value !== 'object' || value === null) { + return false + } + + const candidate = value as Partial + return typeof candidate.policyKey === 'string' + && candidate.scope === 'user_policy' + && typeof candidate.targetId === 'string' + && typeof candidate.allowChildOverride === 'boolean' +} + +function sanitizePolicies(rawPolicies: Record): EffectivePoliciesState { + const nextPolicies: EffectivePoliciesState = {} + + for (const [policyKey, candidate] of Object.entries(rawPolicies)) { + if (isEffectivePolicyState(candidate)) { + nextPolicies[policyKey] = candidate + } + } + + return nextPolicies +} + +const _policiesStore = defineStore('policies', () => { + const initialPolicies = loadState('libresign', 'effective_policies', { policies: {} }) + const policies = ref(sanitizePolicies(initialPolicies.policies ?? {})) + + const setPolicies = (nextPolicies: Record): void => { + policies.value = sanitizePolicies(nextPolicies) + } + + const fetchEffectivePolicies = async (): Promise => { + try { + const response = await axios.get<{ ocs?: { data?: EffectivePoliciesResponse } }>(generateOcsUrl('/apps/libresign/api/v1/policies/effective')) + setPolicies(response.data?.ocs?.data?.policies ?? {}) + } catch (error: unknown) { + console.error('Failed to load effective policies', error) + } + } + + const saveSystemPolicy = async ( + policyKey: string, + value: EffectivePolicyValue, + allowChildOverride?: boolean, + ): Promise => { + const payload: SystemPolicyWritePayload & { allowChildOverride?: boolean } = { value } + if (typeof allowChildOverride === 'boolean') { + payload.allowChildOverride = allowChildOverride + } + const response = await axios.post<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + + const fetchGroupPolicy = async (groupId: string, policyKey: string): Promise => { + const response = await axios.get<{ ocs?: { data?: GroupPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + + const fetchSystemPolicy = async (policyKey: string): Promise => { + const response = await axios.get<{ ocs?: { data?: SystemPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/system/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isSystemPolicyState(policy)) { + return null + } + + return policy + } + + const fetchUserPolicyForUser = async (userId: string, policyKey: string): Promise => { + const response = await axios.get<{ ocs?: { data?: UserPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isUserPolicyState(policy)) { + return null + } + + return policy + } + + const saveGroupPolicy = async ( + groupId: string, + policyKey: string, + value: EffectivePolicyValue, + allowChildOverride: boolean, + ): Promise => { + const payload: GroupPolicyWritePayload = { value, allowChildOverride } + const response = await axios.put<{ ocs?: { data?: GroupPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + payload, + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + + const clearGroupPolicy = async (groupId: string, policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: GroupPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/group/${groupId}/${policyKey}`), + ) + + const policy = response.data?.ocs?.data?.policy + if (!isGroupPolicyState(policy)) { + return null + } + + return policy + } + + const saveUserPreference = async (policyKey: string, value: EffectivePolicyValue): Promise => { + const payload: SystemPolicyWritePayload = { value } + const response = await axios.put<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + + const clearUserPreference = async (policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: SystemPolicyWriteResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${policyKey}`), + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isEffectivePolicyState(savedPolicy)) { + return null + } + + policies.value = { + ...policies.value, + [policyKey]: savedPolicy, + } + + return savedPolicy + } + + const saveUserPolicyForUser = async ( + userId: string, + policyKey: string, + value: EffectivePolicyValue, + allowChildOverride: boolean, + ): Promise => { + const payload: SystemPolicyWritePayload & { allowChildOverride: boolean } = { + value, + allowChildOverride, + } + const response = await axios.put<{ ocs?: { data?: UserPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`), + payload, + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isUserPolicyState(savedPolicy)) { + return null + } + + return savedPolicy + } + + const clearUserPolicyForUser = async (userId: string, policyKey: string): Promise => { + const response = await axios.delete<{ ocs?: { data?: UserPolicyResponse } }>( + generateOcsUrl(`/apps/libresign/api/v1/policies/user/${userId}/${policyKey}`), + ) + + const savedPolicy = response.data?.ocs?.data?.policy + if (!isUserPolicyState(savedPolicy)) { + return null + } + + return savedPolicy + } + + const getPolicy = (policyKey: string): EffectivePolicyState | null => { + const policy = policies.value[policyKey] + if (!policy) { + return null + } + + return policy + } + + const getEffectiveValue = (policyKey: string): EffectivePolicyState['effectiveValue'] | null => { + return getPolicy(policyKey)?.effectiveValue ?? null + } + + const canUseRequestOverride = (policyKey: string): boolean => { + return getPolicy(policyKey)?.canUseAsRequestOverride ?? true + } + + return { + policies: computed(() => policies.value), + setPolicies, + fetchEffectivePolicies, + fetchGroupPolicy, + fetchSystemPolicy, + fetchUserPolicyForUser, + saveSystemPolicy, + saveGroupPolicy, + clearGroupPolicy, + saveUserPreference, + clearUserPreference, + saveUserPolicyForUser, + clearUserPolicyForUser, + getPolicy, + getEffectiveValue, + canUseRequestOverride, + } +}) + +export const usePoliciesStore = function(...args: Parameters) { + return _policiesStore(...args) +} + +export { + isEffectivePolicyState, + isGroupPolicyState, + isSystemPolicyState, + isUserPolicyState, +} diff --git a/src/store/userconfig.js b/src/store/userconfig.js index 5ebd054119..c577188c33 100644 --- a/src/store/userconfig.js +++ b/src/store/userconfig.js @@ -14,6 +14,7 @@ import { generateOcsUrl } from '@nextcloud/router' * @typedef {Record & { * locale?: string * files_list_grid_view?: boolean + * policy_workbench_catalog_compact_view?: boolean * files_list_signer_identify_tab?: string * crl_filters?: { serialNumber?: string, status?: string | null, owner?: string } * crl_sort?: { sortBy?: string | null, sortOrder?: 'ASC' | 'DESC' | null } diff --git a/src/tests/App.spec.ts b/src/tests/App.spec.ts index 218e3425c3..04165f4bcf 100644 --- a/src/tests/App.spec.ts +++ b/src/tests/App.spec.ts @@ -71,6 +71,28 @@ describe('App', () => { expect(wrapper.find('.left-sidebar').exists()).toBe(true) }) + it('shows left sidebar on request route', () => { + routeState.path = '/f/request' + routeState.name = 'requestFiles' + routeState.matched = [] + + const wrapper = mount(App, { + global: { + stubs: { + NcContent: { template: '
' }, + NcAppContent: { template: '
' }, + NcEmptyContent: { template: '
' }, + LeftSidebar: { name: 'LeftSidebar', template: '