Skip to content
Merged
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
103 changes: 103 additions & 0 deletions lib/AuditLog/Operation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

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

namespace OCA\Deck\AuditLog;

/**
* Operations Deck 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 {
// Boards
case BoardCreated = 'deck.board.created';
case BoardDeleted = 'deck.board.deleted';
case BoardRestored = 'deck.board.restored';
case BoardPurged = 'deck.board.purged';
case BoardArchived = 'deck.board.archived';
case BoardUnarchived = 'deck.board.unarchived';
case BoardCloned = 'deck.board.cloned';
case BoardImported = 'deck.board.imported';
case BoardOwnershipTransferred = 'deck.board.ownership_transferred';

// Sharing
case BoardShareCreated = 'deck.board_share.created';
case BoardShareUpdated = 'deck.board_share.updated';
case BoardShareDeleted = 'deck.board_share.deleted';
case BoardShareLeft = 'deck.board_share.left';
case BoardShareReceived = 'deck.board_share.received';

// Stacks, cards and labels
case StackDeleted = 'deck.stack.deleted';
case StackRestored = 'deck.stack.restored';
case CardCreated = 'deck.card.created';
case CardDeleted = 'deck.card.deleted';
case CardRestored = 'deck.card.restored';
case CardMoved = 'deck.card.moved';
case CardArchived = 'deck.card.archived';
case CardUnarchived = 'deck.card.unarchived';
case CardAssignmentCreated = 'deck.card_assignment.created';
case CardAssignmentDeleted = 'deck.card_assignment.deleted';
case LabelDeleted = 'deck.label.deleted';

// Attachments
case AttachmentCreated = 'deck.attachment.created';
case AttachmentDeleted = 'deck.attachment.deleted';
case AttachmentRestored = 'deck.attachment.restored';

// Comments
case CommentCreated = 'deck.comment.created';
case CommentUpdated = 'deck.comment.updated';
case CommentDeleted = 'deck.comment.deleted';

// Administration
case ConfigGroupLimitUpdated = 'deck.config.group_limit_updated';
case ConfigFederationUpdated = 'deck.config.federation_updated';

/**
* Short description of when the operation is logged, used for the documentation
*/
public function description(): string {
return match ($this) {
self::BoardCreated => 'A board was created',
self::BoardDeleted => 'A board was moved to the trash',
self::BoardRestored => 'A board was restored from the trash',
self::BoardPurged => 'A board was permanently deleted, after the trash retention or together with its team',
self::BoardArchived => 'A board was archived',
self::BoardUnarchived => 'A board was unarchived',
self::BoardCloned => 'A board was cloned into a new board',
self::BoardImported => 'A board was imported from another system',
self::BoardOwnershipTransferred => 'The ownership of a board was transferred to another user',
self::BoardShareCreated => 'A board was shared with a user, group, team or remote user',
self::BoardShareUpdated => 'The permissions of a board share were changed',
self::BoardShareDeleted => 'A board share was removed',
self::BoardShareLeft => 'A user left a board shared with them',
self::BoardShareReceived => 'A board was shared with a local user from another server',
self::StackDeleted => 'A stack was moved to the trash with all its cards',
self::StackRestored => 'A stack was restored from the trash',
self::CardCreated => 'A card was created',
self::CardDeleted => 'A card was moved to the trash',
self::CardRestored => 'A card was restored from the trash',
self::CardMoved => 'A card was moved to another board',
self::CardArchived => 'A card was archived',
self::CardUnarchived => 'A card was unarchived',
self::CardAssignmentCreated => 'A user, group or team was assigned to a card',
self::CardAssignmentDeleted => 'A user, group or team was unassigned from a card',
self::LabelDeleted => 'A label was deleted and removed from all cards of its board',
self::AttachmentCreated => 'A file stored by Deck was attached to a card',
self::AttachmentDeleted => 'A file stored by Deck was deleted from a card',
self::AttachmentRestored => 'A deleted file stored by Deck was restored on a card',
self::CommentCreated => 'A comment was added to a card',
self::CommentUpdated => 'A comment on a card was edited by its author',
self::CommentDeleted => 'A comment was deleted from a card by its author',
self::ConfigGroupLimitUpdated => 'The groups allowed to use Deck were changed',
self::ConfigFederationUpdated => 'Sharing boards with other servers was turned on or off',
};
}
}
13 changes: 13 additions & 0 deletions lib/Cron/DeleteCron.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

namespace OCA\Deck\Cron;

use OCA\Deck\AuditLog\Operation;
use OCA\Deck\Db\AttachmentMapper;
use OCA\Deck\Db\BoardMapper;
use OCA\Deck\Db\CardMapper;
Expand All @@ -18,6 +19,8 @@
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJob;
use OCP\BackgroundJob\TimedJob;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Log\Audit\CriticalActionPerformedEvent;

class DeleteCron extends TimedJob {

Expand All @@ -43,6 +46,7 @@ public function __construct(
AttachmentMapper $attachmentMapper,
StackMapper $stackMapper,
DeckShareProvider $deckShareProvider,
private readonly IEventDispatcher $eventDispatcher,
) {
parent::__construct($time);
$this->boardMapper = $boardMapper;
Expand All @@ -66,6 +70,15 @@ protected function run($argument) {
$boards = $this->boardMapper->findToDelete($timeLimit);
foreach ($boards as $board) {
$this->boardMapper->delete($board);
$this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
'Board %s of "%s" was permanently deleted from the trash',
[
'boardId' => $board->getId(),
'owner' => $board->getOwner(),
],
false,
Operation::BoardPurged->value,
));
}

$cards = $this->cardMapper->findToDelete($timeLimit, 500);
Expand Down
16 changes: 16 additions & 0 deletions lib/Federation/DeckFederationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OCA\Deck\Federation;

use Exception;
use OCA\Deck\AuditLog\Operation;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AclMapper;
use OCA\Deck\Db\Board;
Expand All @@ -17,9 +18,11 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\Common\Exception\NotFoundException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Federation\ICloudFederationShare;
use OCP\Federation\ICloudIdManager;
use OCP\Federation\ISignedCloudFederationProvider;
use OCP\Log\Audit\CriticalActionPerformedEvent;
use OCP\Notification\IManager as INotificationManager;

class DeckFederationProvider implements ISignedCloudFederationProvider {
Expand All @@ -32,6 +35,7 @@ public function __construct(
private AclMapper $aclMapper,
private ChangeHelper $changeHelper,
private ConfigService $configService,
private IEventDispatcher $eventDispatcher,
) {
}

Expand Down Expand Up @@ -60,6 +64,18 @@ public function shareReceived(ICloudFederationShare $share): string {

$this->changeHelper->boardChanged($insertedBoard->getId());

$this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
'Board %s was shared by "%s" with "%s" from the remote board %s',
[
'boardId' => $insertedBoard->getId(),
'sharedBy' => $share->getSharedBy(),
'sharedWith' => $share->getShareWith(),
'remoteBoardId' => $share->getProviderId(),
],
false,
Operation::BoardShareReceived->value,
));

$notification = $this->notificationManager->createNotification();
$notification->setApp('deck');
$notification->setUser($share->getShareWith());
Expand Down
23 changes: 23 additions & 0 deletions lib/Service/AssignmentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
namespace OCA\Deck\Service;

use OCA\Deck\Activity\ActivityManager;
use OCA\Deck\AuditLog\Operation;
use OCA\Deck\BadRequestException;
use OCA\Deck\Db\Acl;
use OCA\Deck\Db\AclMapper;
Expand All @@ -23,6 +24,7 @@
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\Log\Audit\CriticalActionPerformedEvent;

class AssignmentService {
public function __construct(
Expand Down Expand Up @@ -87,6 +89,7 @@ public function assignUser(int $cardId, string $userId, int $type = Assignment::
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $card, ActivityManager::SUBJECT_CARD_USER_ASSIGN, ['assigneduser' => $userId]);

$this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card));
$this->logAssignment($cardId, $card->getStackId(), $userId, $type, 'assigned to', Operation::CardAssignmentCreated);

return $assignment;
}
Expand Down Expand Up @@ -114,10 +117,30 @@ public function unassignUser(int $cardId, string $userId, int $type = 0): Assign
$this->changeHelper->cardChanged($cardId);

$this->eventDispatcher->dispatchTyped(new CardUpdatedEvent($card));
$this->logAssignment($cardId, $card->getStackId(), $userId, $type, 'unassigned from', Operation::CardAssignmentDeleted);

return $assignment;
}
}
throw new NotFoundException('No assignment for ' . $userId . 'found.');
}

private function logAssignment(int $cardId, ?int $stackId, string $assignee, int $type, string $action, Operation $operation): void {
$this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
'%s "%s" was ' . $action . ' card %s of stack %s',
[
'assigneeType' => match ($type) {
Assignment::TYPE_GROUP => 'group',
Assignment::TYPE_CIRCLE => 'team',
Assignment::TYPE_REMOTE => 'remote user',
default => 'user',
},
'assignee' => $assignee,
'cardId' => $cardId,
'stackId' => $stackId,
],
false,
$operation->value,
));
}
}
28 changes: 28 additions & 0 deletions lib/Service/AttachmentService.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

use OCA\Deck\Activity\ActivityManager;
use OCA\Deck\AppInfo\Application;
use OCA\Deck\AuditLog\Operation;
use OCA\Deck\BadRequestException;
use OCA\Deck\Cache\AttachmentCacheHelper;
use OCA\Deck\Db\Acl;
Expand All @@ -23,8 +24,10 @@
use OCA\Deck\Validators\AttachmentServiceValidator;
use OCP\AppFramework\Db\IMapperException;
use OCP\AppFramework\Http\Response;
use OCP\EventDispatcher\IEventDispatcher;
use OCP\IL10N;
use OCP\IUserManager;
use OCP\Log\Audit\CriticalActionPerformedEvent;
use Psr\Container\ContainerExceptionInterface;

class AttachmentService {
Expand All @@ -44,6 +47,7 @@ public function __construct(
private readonly IL10N $l10n,
private readonly ActivityManager $activityManager,
private readonly AttachmentServiceValidator $attachmentServiceValidator,
private readonly IEventDispatcher $eventDispatcher,
) {
// Register shipped attachment services
// TODO: move this to a plugin based approach once we have different types of attachments
Expand Down Expand Up @@ -202,6 +206,7 @@ public function create(int $cardId, string $type, string $data = '') {

$this->changeHelper->cardChanged($attachment->getCardId());
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_CREATE);
$this->logAttachment($attachment, 'added to', Operation::AttachmentCreated);
return $attachment;
}

Expand Down Expand Up @@ -355,6 +360,7 @@ public function delete(int $cardId, int $attachmentId, string $type = 'deck_file
$this->attachmentCacheHelper->clearAttachmentCount($cardId);
$this->changeHelper->cardChanged($attachment->getCardId());
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_DELETE);
$this->logAttachment($attachment, 'deleted from', Operation::AttachmentDeleted);
return $attachment;
}

Expand All @@ -375,6 +381,7 @@ public function restore(int $cardId, int $attachmentId, string $type = 'deck_fil
$attachment = $this->attachmentMapper->update($attachment);
$this->changeHelper->cardChanged($attachment->getCardId());
$this->activityManager->triggerEvent(ActivityManager::DECK_OBJECT_CARD, $attachment, ActivityManager::SUBJECT_ATTACHMENT_RESTORE);
$this->logAttachment($attachment, 'restored on', Operation::AttachmentRestored);
return $attachment;
}
} catch (InvalidAttachmentType $e) {
Expand Down Expand Up @@ -403,4 +410,25 @@ private function addCreator(Attachment $attachment): Attachment {

return $attachment;
}

/**
* Attachments of the type "file" are Nextcloud files shared with the card,
* which the audit log already records as shares
*/
private function logAttachment(Attachment $attachment, string $action, Operation $operation): void {
if ($attachment->getType() === 'file') {
return;
}

$this->eventDispatcher->dispatchTyped(new CriticalActionPerformedEvent(
'Attachment %s (%s) was ' . $action . ' card %s',
[
'attachmentId' => $attachment->getId(),
'attachmentType' => $attachment->getType(),
'cardId' => $attachment->getCardId(),
],
false,
$operation->value,
));
}
}
Loading
Loading