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
6 changes: 5 additions & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -260,12 +260,16 @@
['name' => 'bulk#deleteSchemaObjects', 'url' => '/api/bulk/{register}/{schema}/delete-objects', 'verb' => 'POST'],
['name' => 'bulk#deleteRegister', 'url' => '/api/bulk/{register}/delete-register', 'verb' => 'POST'],
['name' => 'bulk#validateSchema', 'url' => '/api/bulk/schema/{schema}/validate', 'verb' => 'POST'],
// Audit Trails.
// Audit Trails — specific routes MUST come before parameterized {id} routes.
['name' => 'auditTrail#objects', 'url' => '/api/objects/{register}/{schema}/{id}/audit-trails', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']],
['name' => 'auditTrail#index', 'url' => '/api/audit-trails', 'verb' => 'GET'],
['name' => 'auditTrail#export', 'url' => '/api/audit-trails/export', 'verb' => 'GET'],
['name' => 'auditTrail#verify', 'url' => '/api/audit-trails/verify', 'verb' => 'GET'],
['name' => 'auditTrail#verwerkingsregister', 'url' => '/api/audit-trails/verwerkingsregister', 'verb' => 'GET'],
['name' => 'auditTrail#inzageverzoek', 'url' => '/api/audit-trails/inzageverzoek', 'verb' => 'GET'],
['name' => 'auditTrail#clearAll', 'url' => '/api/audit-trails/clear-all', 'verb' => 'DELETE'],
['name' => 'auditTrail#show', 'url' => '/api/audit-trails/{id}', 'verb' => 'GET', 'requirements' => ['id' => '[^/]+']],
['name' => 'auditTrail#update', 'url' => '/api/audit-trails/{id}', 'verb' => 'PUT', 'requirements' => ['id' => '[^/]+']],
['name' => 'auditTrail#destroy', 'url' => '/api/audit-trails/{id}', 'verb' => 'DELETE', 'requirements' => ['id' => '[^/]+']],
['name' => 'auditTrail#destroyMultiple', 'url' => '/api/audit-trails', 'verb' => 'DELETE'],
// Search Trails - specific routes first, then general ones.
Expand Down
174 changes: 132 additions & 42 deletions lib/Controller/AuditTrailController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*
* Controller for managing audit trail operations in the OpenRegister app.
* Provides functionality to retrieve audit trails related to objects within registers and schemas.
* Includes hash chain verification, verwerkingsregister, and immutability enforcement.
*
* @category Controller
* @package OCA\OpenRegister\AppInfo
Expand All @@ -21,10 +22,11 @@
namespace OCA\OpenRegister\Controller;

use OCA\OpenRegister\Db\AuditTrailMapper;
use OCA\OpenRegister\Service\AuditHashService;
use OCA\OpenRegister\Service\LogService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IRequest;

/**
Expand All @@ -33,6 +35,9 @@
* Handles all audit trail related operations.
*
* @psalm-suppress UnusedClass
*
* @SuppressWarnings(PHPMD.ExcessiveClassLength) Controller covers audit trail, verification, verwerkingsregister
* @SuppressWarnings(PHPMD.CouplingBetweenObjects) Necessary service dependencies
*/
class AuditTrailController extends Controller
{
Expand All @@ -43,12 +48,14 @@ class AuditTrailController extends Controller
* @param IRequest $request The request object
* @param LogService $logService The log service
* @param AuditTrailMapper $auditTrailMapper The audit trail mapper
* @param AuditHashService $auditHashService The audit hash chain service
*/
public function __construct(
string $appName,
IRequest $request,
private readonly LogService $logService,
private readonly AuditTrailMapper $auditTrailMapper
private readonly AuditTrailMapper $auditTrailMapper,
private readonly AuditHashService $auditHashService
) {
parent::__construct(appName: $appName, request: $request);
}//end __construct()
Expand All @@ -58,8 +65,8 @@ public function __construct(
*
* @return array The extracted request parameters
*
* @suppressWarnings(PHPMD.NPathComplexity) Request parameter extraction requires many conditional checks
* @suppressWarnings(PHPMD.CyclomaticComplexity)
* @SuppressWarnings(PHPMD.NPathComplexity) Request parameter extraction requires many conditional checks
* @SuppressWarnings(PHPMD.CyclomaticComplexity)
*/
private function extractRequestParameters(): array
{
Expand Down Expand Up @@ -131,6 +138,10 @@ function ($key) {
'id',
'register',
'schema',
'format',
'from',
'to',
'identifier',
]
);
},
Expand Down Expand Up @@ -216,6 +227,26 @@ public function show(int $id): JSONResponse
}
}//end show()

/**
* Reject audit trail modification (immutability enforcement).
*
* @param int $id The audit trail ID
*
* @return JSONResponse HTTP 405 Method Not Allowed
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function update(int $id): JSONResponse
{
return new JSONResponse(
data: ['error' => 'Audit trail entries cannot be modified'],
statusCode: Http::STATUS_METHOD_NOT_ALLOWED
);
}//end update()

/**
* Get logs for an object
*
Expand Down Expand Up @@ -337,52 +368,23 @@ public function export(): JSONResponse
}//end export()

/**
* Delete a single audit trail log
* Reject audit trail deletion (immutability enforcement).
*
* @param int $id The audit trail ID to delete
* @param int $id The audit trail ID
*
* @NoAdminRequired
* @return JSONResponse HTTP 405 Method Not Allowed
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse JSON response confirming deletion or error
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
public function destroy(int $id): JSONResponse
{
try {
$success = $this->logService->deleteLog($id);

if ($success === true) {
return new JSONResponse(
data: [
'success' => true,
'message' => 'Audit trail deleted successfully',
],
statusCode: 200
);
}

return new JSONResponse(
data: [
'error' => 'Failed to delete audit trail',
],
statusCode: 500
);
} catch (\OCP\AppFramework\Db\DoesNotExistException $e) {
return new JSONResponse(
data: [
'error' => 'Audit trail not found',
],
statusCode: 404
);
} catch (\Exception $e) {
return new JSONResponse(
data: [
'error' => 'Deletion failed: '.$e->getMessage(),
],
statusCode: 500
);
}//end try
return new JSONResponse(
data: ['error' => 'Audit trail entries cannot be deleted'],
statusCode: Http::STATUS_METHOD_NOT_ALLOWED
);
}//end destroy()

/**
Expand Down Expand Up @@ -487,4 +489,92 @@ public function clearAll(): JSONResponse
);
}//end try
}//end clearAll()

/**
* Verify the integrity of the audit trail hash chain.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse Verification result with valid/invalid status
*/
public function verify(): JSONResponse
{
$from = $this->request->getParam('from');
$to = $this->request->getParam('to');

$fromInt = ($from !== null) ? (int) $from : null;
$toInt = ($to !== null) ? (int) $to : null;

try {
$result = $this->auditHashService->verifyChain($fromInt, $toInt);
return new JSONResponse(data: $result);
} catch (\Exception $e) {
return new JSONResponse(
data: ['error' => 'Verification failed: '.$e->getMessage()],
statusCode: 500
);
}
}//end verify()

/**
* Get verwerkingsregister (processing register) overview.
*
* Returns distinct processing activities from the audit trail with counts
* and date ranges, for GDPR Art 30 compliance.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse List of processing activities
*
* @SuppressWarnings(PHPMD.ExcessiveMethodLength)
*/
public function verwerkingsregister(): JSONResponse
{
$organisationId = $this->request->getParam('organisationId');

try {
$results = $this->auditTrailMapper->getProcessingActivities($organisationId);
return new JSONResponse(data: $results);
} catch (\Exception $e) {
return new JSONResponse(
data: ['error' => 'Failed to retrieve verwerkingsregister: '.$e->getMessage()],
statusCode: 500
);
}
}//end verwerkingsregister()

/**
* Handle a data subject access request (inzageverzoek).
*
* Searches audit trail entries by identifier in the changed JSON field,
* grouped by schema.
*
* @NoAdminRequired
* @NoCSRFRequired
*
* @return JSONResponse Matching audit trail entries grouped by schema
*/
public function inzageverzoek(): JSONResponse
{
$identifier = $this->request->getParam('identifier');

if ($identifier === null || $identifier === '') {
return new JSONResponse(
data: ['error' => 'identifier parameter is required'],
statusCode: 400
);
}

try {
$results = $this->auditTrailMapper->findByIdentifier($identifier);
return new JSONResponse(data: $results);
} catch (\Exception $e) {
return new JSONResponse(
data: ['error' => 'Inzageverzoek failed: '.$e->getMessage()],
statusCode: 500
);
}
}//end inzageverzoek()
}//end class
26 changes: 25 additions & 1 deletion lib/Db/AuditTrail.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@
* @method void setOrganisation(?string $organisation)
* @method DateTime|null getExpires()
* @method void setExpires(?DateTime $expires)
* @method string|null getHash()
* @method void setHash(?string $hash)
* @method string|null getPreviousHash()
* @method void setPreviousHash(?string $previousHash)
*
* @psalm-suppress PossiblyUnusedMethod
* @psalm-suppress PropertyNotSetInConstructor $id is set by Nextcloud's Entity base class
Expand Down Expand Up @@ -257,6 +261,20 @@ class AuditTrail extends Entity implements JsonSerializable
*/
protected ?DateTime $expires = null;

/**
* SHA-256 hash of this entry chained to the previous entry
*
* @var string|null SHA-256 hash of this entry chained to the previous entry
*/
protected ?string $hash = null;

/**
* SHA-256 hash of the previous audit trail entry in the chain
*
* @var string|null SHA-256 hash of the previous audit trail entry
*/
protected ?string $previousHash = null;

/**
* Constructor for the AuditTrail class
*
Expand Down Expand Up @@ -289,6 +307,8 @@ public function __construct()
$this->addType(fieldName: 'retentionPeriod', type: 'string');
$this->addType(fieldName: 'size', type: 'integer');
$this->addType(fieldName: 'expires', type: 'datetime');
$this->addType(fieldName: 'hash', type: 'string');
$this->addType(fieldName: 'previousHash', type: 'string');
}//end __construct()

/**
Expand Down Expand Up @@ -385,7 +405,9 @@ public function hydrate(array $object): static
* confidentiality: null|string,
* retentionPeriod: null|string,
* size: int|null,
* expires: null|string
* expires: null|string,
* hash: null|string,
* previousHash: null|string
* }
*/
public function jsonSerialize(): array
Expand Down Expand Up @@ -427,6 +449,8 @@ public function jsonSerialize(): array
'retentionPeriod' => $this->retentionPeriod,
'size' => $this->size,
'expires' => $expires,
'hash' => $this->hash,
'previousHash' => $this->previousHash,
];
}//end jsonSerialize()

Expand Down
Loading
Loading