Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions lib/AuditLog/AuditLogger.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\AuditLog;

use OCA\Forms\Db\Share;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Log\Audit\CriticalActionPerformedEvent;
use OCP\Share\IShare;

/**
* Writes entries to the admin audit log
*/
class AuditLogger {
public function __construct(
private readonly IEventDispatcher $eventDispatcher,
) {
}

/**
* The operation is only passed on to servers whose CriticalActionPerformedEvent supports it
*
* @param string $message format string with a %s placeholder for each of the first parameters
* @param array<string, mixed> $parameters named values for the placeholders of $message, in order, optionally followed by more context
*/
public function log(Operation $operation, string $message, array $parameters = []): void {
$arguments = [$message, $parameters, false];
if (method_exists(CriticalActionPerformedEvent::class, 'getOperation')) {
$arguments[] = $operation->value;
}
$this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(...$arguments));
}

/**
* Logs a share with its share ID and form ID as the first parameters.
* The token of a link share is stored as its shareWith and is never logged.
*
* @param array<string, mixed> $extraParameters
*/
public function logShare(Operation $operation, string $message, Share $share, array $extraParameters = []): void {
$this->log($operation, $message, [
'shareId' => $share->getId(),
'formId' => $share->getFormId(),
'shareType' => $share->getShareType(),
'shareWith' => $share->getShareType() === IShare::TYPE_LINK ? null : $share->getShareWith(),
'permissions' => $share->getPermissions(),
] + $extraParameters);
}
}
58 changes: 58 additions & 0 deletions lib/AuditLog/Operation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Forms\AuditLog;

/**
* Operations Forms writes to the admin audit log, in the form `app.entity.action`
*
* The values are matched by log consumers (filters, alert rules, SIEM imports),
* so changing one is a breaking change for them. Case names may be renamed.
*/
enum Operation: string {
case FormCreated = 'forms.form.created';
case FormDeleted = 'forms.form.deleted';
case FormOwnerTransferred = 'forms.form.owner_transferred';
case FormAccessUpdated = 'forms.form_access.updated';
case FormStateChanged = 'forms.form_state.changed';
case FormSettingsUpdated = 'forms.form_settings.updated';
case ResultsFileLinked = 'forms.results_file.linked';
case ShareCreated = 'forms.share.created';
case ShareUpdated = 'forms.share.updated';
case ShareDeleted = 'forms.share.deleted';
case ResultsDownloaded = 'forms.results.downloaded';
case ResultsExported = 'forms.results.exported';
case SubmissionUpdated = 'forms.submission.updated';
case SubmissionDeleted = 'forms.submission.deleted';
case SubmissionsDeletedAll = 'forms.submissions.deleted_all';
case ConfigUpdated = 'forms.config.updated';

/**
* Short description of when the operation is logged, used for the documentation
*/
public function description(): string {
return match ($this) {
self::FormCreated => 'A form was created, either empty or as a copy of another form',
self::FormDeleted => 'A form was deleted with its questions, shares, submissions and uploaded files',
self::FormOwnerTransferred => 'The ownership of a form was transferred to another user',
self::FormAccessUpdated => 'A form was opened to or closed for all users of the instance',
self::FormStateChanged => 'A form was closed, archived or reopened',
self::FormSettingsUpdated => 'The response settings of a form (anonymity, multiple or editable submissions, expiration, submission limit) were changed',
self::ResultsFileLinked => 'The results of a form were linked to a spreadsheet file, which is then kept in sync with the submissions',
self::ShareCreated => 'A form was shared with a user, group or team or by public link, with its permissions',
self::ShareUpdated => 'The permissions or the custom public link token of a form share were changed',
self::ShareDeleted => 'A form share was deleted, by someone managing the form or through the share review',
self::ResultsDownloaded => 'All submissions of a form were downloaded as a CSV, ODS or XLSX file',
self::ResultsExported => 'All submissions of a form were exported to a file in the cloud',
self::SubmissionUpdated => 'The answers of a submission were replaced',
self::SubmissionDeleted => 'A submission was deleted',
self::SubmissionsDeletedAll => 'All submissions of a form were deleted',
self::ConfigUpdated => 'An admin setting of Forms (sharing, form creation, confirmation emails, comments) was changed',
};
}
}
9 changes: 9 additions & 0 deletions lib/Constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ class Constants {
public const FORM_STATE_CLOSED = 1;
public const FORM_STATE_ARCHIVED = 2;

/**
* Names of the form states, used in the audit log
*/
public const FORM_STATE_NAMES = [
self::FORM_STATE_ACTIVE => 'active',
self::FORM_STATE_CLOSED => 'closed',
self::FORM_STATE_ARCHIVED => 'archived',
];

/**
* Access flags of a form
*/
Expand Down
115 changes: 115 additions & 0 deletions lib/Controller/ApiController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

namespace OCA\Forms\Controller;

use OCA\Forms\AuditLog\AuditLogger;
use OCA\Forms\AuditLog\Operation;
use OCA\Forms\BackgroundJob\DeleteQuestionFoldersJob;
use OCA\Forms\BackgroundJob\SyncSubmissionsWithLinkedFileJob;
use OCA\Forms\Constants;
Expand Down Expand Up @@ -92,6 +94,7 @@ public function __construct(
private readonly IMimeTypeDetector $mimeTypeDetector,
private readonly IJobList $jobList,
private readonly ISecureRandom $secureRandom,
private readonly AuditLogger $auditLogger,
) {
parent::__construct($appName, $request);
$this->currentUser = $userSession->getUser();
Expand Down Expand Up @@ -178,6 +181,11 @@ public function newForm(?int $fromId = null): DataResponse {
$form->setIsAnonymous(false);

$this->formMapper->insert($form);
$this->auditLogger->log(Operation::FormCreated, 'Form %s was created by "%s"', [
'formId' => $form->getId(),
'ownerId' => $form->getOwnerId(),
'sourceFormId' => null,
]);
} else {
$oldForm = $this->formsService->getFormIfAllowed($fromId, Constants::PERMISSION_EDIT);

Expand Down Expand Up @@ -238,6 +246,11 @@ public function newForm(?int $fromId = null): DataResponse {
}

$this->formMapper->update($form);
$this->auditLogger->log(Operation::FormCreated, 'Form %s was created by "%s" as a copy of form %s', [
'formId' => $form->getId(),
'ownerId' => $form->getOwnerId(),
'sourceFormId' => $oldForm->getId(),
]);
}

return new DataResponse($this->formsService->getForm($form), Http::STATUS_CREATED);
Expand Down Expand Up @@ -323,11 +336,17 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse {
// Do not allow changing showToAllUsers or permitAllUsers if disabled
$this->checkAccessUpdate($keyValuePairs);

$previousAccess = $form->getAccess();
$previousState = $form->getState();
$previousSettings = $this->getResponseSettings($form);

// Process file linking
$fileLinked = false;
if (isset($keyValuePairs['path']) && isset($keyValuePairs['fileFormat'])) {
$file = $this->submissionService->writeFileToCloud($form, $keyValuePairs['path'], $keyValuePairs['fileFormat']);
$form->setFileId($file->getId());
$form->setFileFormat($keyValuePairs['fileFormat']);
$fileLinked = true;
}

// Process file unlinking
Expand Down Expand Up @@ -365,6 +384,8 @@ public function updateForm(int $formId, array $keyValuePairs): DataResponse {
// Update changed Columns in Db.
$this->formMapper->update($form);

$this->logFormUpdate($form, $previousAccess, $previousState, $previousSettings, $fileLinked);

return new DataResponse($form->getId());
}

Expand All @@ -389,6 +410,10 @@ public function deleteForm(int $formId): DataResponse {

$form = $this->formsService->getFormIfAllowed($formId);
$this->formMapper->deleteForm($form);
$this->auditLogger->log(Operation::FormDeleted, 'Form %s of "%s" was deleted', [
'formId' => $form->getId(),
'ownerId' => $form->getOwnerId(),
]);

return new DataResponse($formId);
}
Expand Down Expand Up @@ -1239,6 +1264,10 @@ public function getSubmissions(int $formId, ?string $query = null, ?int $limit =

$submissionsData = $this->submissionService->getSubmissionsData($form, $fileFormat);
$fileName = $this->formsService->getFileName($form, $fileFormat);
$this->auditLogger->log(Operation::ResultsDownloaded, 'Results of form %s were downloaded as %s', [
'formId' => $form->getId(),
'fileFormat' => $fileFormat,
]);

return new DataDownloadResponse($submissionsData, $fileName, Constants::SUPPORTED_EXPORT_FORMATS[$fileFormat]);
}
Expand Down Expand Up @@ -1377,6 +1406,9 @@ public function deleteAllSubmissions(int $formId): DataResponse {
// Delete all submissions (incl. Answers)
$this->submissionMapper->deleteByForm($formId);
$this->formMapper->update($form);
$this->auditLogger->log(Operation::SubmissionsDeletedAll, 'All submissions of form %s were deleted', [
'formId' => $form->getId(),
]);

$this->queueLinkedFileSync($form);

Expand Down Expand Up @@ -1545,6 +1577,12 @@ public function updateSubmission(int $formId, int $submissionId, array $answers)
$this->storeAnswersForQuestion($form, $submission->getId(), $question, $answerArray);
}

$this->auditLogger->log(Operation::SubmissionUpdated, 'Submission %s of form %s by "%s" was updated', [
'submissionId' => $submissionId,
'formId' => $form->getId(),
'submissionUserId' => $submission->getUserId(),
]);

//Create Activity
$this->formsService->notifyNewSubmission($form, $submission);

Expand Down Expand Up @@ -1597,6 +1635,11 @@ public function deleteSubmission(int $formId, int $submissionId): DataResponse {
// Delete submission (incl. Answers)
$this->submissionMapper->deleteById($form, $submissionId);
$this->formMapper->update($form);
$this->auditLogger->log(Operation::SubmissionDeleted, 'Submission %s of form %s by "%s" was deleted', [
'submissionId' => $submissionId,
'formId' => $form->getId(),
'submissionUserId' => $submission->getUserId(),
]);

$this->queueLinkedFileSync($form);

Expand Down Expand Up @@ -1630,6 +1673,12 @@ public function exportSubmissionsToCloud(int $formId, string $path, string $file
}

$file = $this->submissionService->writeFileToCloud($form, $path, $fileFormat);
$this->auditLogger->log(Operation::ResultsExported, 'Results of form %s were exported to file %s', [
'formId' => $form->getId(),
'fileId' => $file->getId(),
'fileName' => $file->getName(),
'fileFormat' => $fileFormat,
]);

return new DataResponse($file->getName());
}
Expand Down Expand Up @@ -2019,13 +2068,79 @@ private function handleOwnerTransfer(Form $form, int $formId, string $currentUse
$this->logger->debug('Could not find new form owner');
throw new OCSBadRequestException('Could not find new form owner');
}
$previousOwnerId = $form->getOwnerId();
$form->setOwnerId($keyValuePairs['ownerId']);
$form->setLockedBy(null);
$form->setLockedUntil(null);
$this->formMapper->update($form);
$this->auditLogger->log(Operation::FormOwnerTransferred, 'Ownership of form %s was transferred from "%s" to "%s"', [
'formId' => $form->getId(),
'oldOwnerId' => $previousOwnerId,
'newOwnerId' => $form->getOwnerId(),
]);
return new DataResponse($form->getOwnerId());
}

/**
* Settings that decide how responses are collected and handled
*
* @return array<string, mixed>
*/
private function getResponseSettings(Form $form): array {
return [
'isAnonymous' => $form->getIsAnonymous(),
'submitMultiple' => $form->getSubmitMultiple(),
'allowEditSubmissions' => $form->getAllowEditSubmissions(),
'expires' => $form->getExpires(),
'maxSubmissions' => $form->getMaxSubmissions(),
];
}

/**
* Only the names of changed settings are logged, never texts like the title,
* description or confirmation email
*
* @param array<string, mixed> $previousSettings
*/
private function logFormUpdate(Form $form, array $previousAccess, ?int $previousState, array $previousSettings, bool $fileLinked): void {
$access = $form->getAccess();
if ($access !== $previousAccess) {
$this->auditLogger->log(Operation::FormAccessUpdated, 'Access to form %s was changed', [
'formId' => $form->getId(),
'permitAllUsers' => $access['permitAllUsers'],
'showToAllUsers' => $access['showToAllUsers'],
]);
}

if ($form->getState() !== $previousState) {
$this->auditLogger->log(Operation::FormStateChanged, 'State of form %s was changed to %s', [
'formId' => $form->getId(),
'state' => Constants::FORM_STATE_NAMES[$form->getState()] ?? $form->getState(),
'previousState' => Constants::FORM_STATE_NAMES[$previousState] ?? $previousState,
]);
}

$changedKeys = array_keys(array_filter(
$this->getResponseSettings($form),
static fn (mixed $value, string $key): bool => $value !== $previousSettings[$key],
ARRAY_FILTER_USE_BOTH,
));
if ($changedKeys !== []) {
$this->auditLogger->log(Operation::FormSettingsUpdated, 'Response settings of form %s were changed', [
'formId' => $form->getId(),
'changedKeys' => $changedKeys,
]);
}

if ($fileLinked) {
$this->auditLogger->log(Operation::ResultsFileLinked, 'Results of form %s were linked to file %s', [
'formId' => $form->getId(),
'fileId' => $form->getFileId(),
'fileFormat' => $form->getFileFormat(),
]);
}
}

/**
* Shift existing question orders to make room for an insertion at $position.
* Normalizes the given $position to the valid range and updates all questions
Expand Down
9 changes: 9 additions & 0 deletions lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

namespace OCA\Forms\Controller;

use OCA\Forms\AuditLog\AuditLogger;
use OCA\Forms\AuditLog\Operation;
use OCA\Forms\Constants;
use OCA\Forms\Service\ConfigService;
use OCP\AppFramework\ApiController;
Expand All @@ -27,6 +29,7 @@ public function __construct(
private readonly IAppConfig $appConfig,
private readonly LoggerInterface $logger,
IRequest $request,
private readonly AuditLogger $auditLogger,
) {
parent::__construct($appName, $request);
}
Expand Down Expand Up @@ -83,6 +86,12 @@ public function updateAppConfig(string $configKey, mixed $configValue): DataResp
return new DataResponse('Invalid value for ' . $configKey, Http::STATUS_BAD_REQUEST);
}

// All settings are booleans, integers or group IDs
$this->auditLogger->log(Operation::ConfigUpdated, 'Forms setting %s was changed', [
'configKey' => $configKey,
'configValue' => $configValue,
]);

return new DataResponse();
}
}
Loading
Loading