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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

### Added
- Support Nextcloud 35
- Log integration settings saved through the setup API and every new Nextcloud OAuth client for OpenProject to the audit log
- Log the project folder setup and terms of service signed for the OpenProject user to the audit log
- Log users connecting and disconnecting their OpenProject account to the audit log
- Log files linked to and unlinked from OpenProject work packages to the audit log

### Fixed
- Keep the admin settings when Nextcloud replays the migrations to check the database schema (`occ db:schema:check`, every `occ upgrade` on Nextcloud 35)

### Changed
- Make OAuth2 Client class usage compatible with older Nextcloud versions [#1148](https://github.com/nextcloud/integration_openproject/pull/1148)
- Add owner_file_name property in filesInfo endpoint response [#1189](https://github.com/nextcloud/integration_openproject/pull/1189)
- Write the audit log entries through the admin_audit app, with an operation and named parameters, whenever the app is enabled

### Removed

Expand Down
1 change: 1 addition & 0 deletions bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@

$classLoader = new ClassLoader();
$classLoader->addPsr4("OCA\\OpenProject\\", $rootDir . '/lib', true);
$classLoader->addPsr4("OCA\\OpenProject\\Tests\\", $rootDir . '/tests/lib', true);
$classLoader->register();
50 changes: 50 additions & 0 deletions lib/AuditLog/Operation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

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

namespace OCA\OpenProject\AuditLog;

/**
* Operations the OpenProject integration 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 IntegrationConfigured = 'integration_openproject.integration.configured';
case IntegrationReset = 'integration_openproject.integration.reset';
case OauthClientCreated = 'integration_openproject.oauth_client.created';
case AppPasswordCreated = 'integration_openproject.app_password.created';
case AppPasswordDeleted = 'integration_openproject.app_password.deleted';
case ProjectFolderCreated = 'integration_openproject.project_folder.created';
case ProjectFolderDeactivated = 'integration_openproject.project_folder.deactivated';
case TermsOfServiceSigned = 'integration_openproject.terms_of_service.signed';
case AccountConnected = 'integration_openproject.account.connected';
case AccountDisconnected = 'integration_openproject.account.disconnected';
case FileLinkCreated = 'integration_openproject.file_link.created';
case FileLinkDeleted = 'integration_openproject.file_link.deleted';

/**
* Short description of when the operation is logged, used for the documentation
*/
public function description(): string {
return match ($this) {
self::IntegrationConfigured => 'An admin changed the OpenProject integration settings, such as the OpenProject URL or the OpenProject OAuth client',
self::IntegrationReset => 'An admin reset the OpenProject integration, which disconnects all users from OpenProject',
self::OauthClientCreated => 'An admin created or replaced the Nextcloud OAuth client used by OpenProject',
self::AppPasswordCreated => 'An app password was created or replaced for the OpenProject system user',
self::AppPasswordDeleted => 'The app password of the OpenProject system user was deleted',
self::ProjectFolderCreated => 'The project folder setup created the OpenProject system user and groups, made the user their group admin and created the project groupfolder',
self::ProjectFolderDeactivated => 'An admin deactivated the project folder setup',
self::TermsOfServiceSigned => 'Terms of service were signed on behalf of the OpenProject system user, by an admin, during the project folder setup or automatically when the terms change',
self::AccountConnected => 'A user connected their OpenProject account through OAuth or a personal access token',
self::AccountDisconnected => 'A user disconnected their OpenProject account',
self::FileLinkCreated => 'A user linked a file to an OpenProject work package, which shares the file ID, name and type with OpenProject',
self::FileLinkDeleted => 'A user removed the link between a file and an OpenProject work package',
};
}
}
100 changes: 64 additions & 36 deletions lib/Controller/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use OCA\OAuth2\Exceptions\ClientNotFoundException;
use OCA\OAuth2\Service\ClientService;
use OCA\OpenProject\AppInfo\Application;
use OCA\OpenProject\AuditLog\Operation;
use OCA\OpenProject\Exception\OpenprojectGroupfolderSetupConflictException;
use OCA\OpenProject\Service\OauthService;
use OCA\OpenProject\Service\OpenProjectAPIService;
Expand Down Expand Up @@ -113,6 +114,20 @@ public function clearUserInfo(?string $userId = null) {
$this->config->deleteUserValue($userId, Application::APP_ID, 'token_expires_at');
}

/**
* Only the OpenProject user ID is logged, never the access or refresh token
*/
private function logAccountConnected(): void {
$this->openprojectAPIService->logToAuditLog(
Operation::AccountConnected,
'User %s has connected the OpenProject account %s in application ' . Application::APP_ID,
[
'userId' => $this->userId,
'openprojectUserId' => (string)$this->config->getUserValue($this->userId, Application::APP_ID, 'user_id'),
],
);
}

/**
* @return void
*/
Expand Down Expand Up @@ -148,8 +163,16 @@ public function setConfig(array $values): DataResponse {
if (isset($values['token'])) {
if ($values['token']) {
$result = $this->openprojectAPIService->initUserInfo($this->userId, $values['token']);
if (!isset($result['error'])) {
$this->logAccountConnected();
}
} else {
$this->clearUserInfo();
$this->openprojectAPIService->logToAuditLog(
Operation::AccountDisconnected,
'User %s has disconnected the OpenProject account in application ' . Application::APP_ID,
['userId' => $this->userId],
);
$result = [
'user_name' => '',
];
Expand Down Expand Up @@ -185,16 +208,7 @@ private function setIntegrationConfig(array $values): array {
if (!$this->userManager->userExists(Application::OPEN_PROJECT_ENTITIES_NAME)) {
throw new NoUserException('User "' . Application::OPEN_PROJECT_ENTITIES_NAME . '" does not exists to create application password');
}
$appPassword = $this->openprojectAPIService->generateAppPasswordTokenForUser();
if ($isAppPasswordBeingReplaced) {
$this->openprojectAPIService->logToAuditFile(
"Application password for user 'OpenProject has been replaced' with new password in application " . Application::APP_ID
);
} else {
$this->openprojectAPIService->logToAuditFile(
"New application password for user 'OpenProject' has been created in application " . Application::APP_ID
);
}
$appPassword = $this->openprojectAPIService->generateAppPasswordTokenForUser($isAppPasswordBeingReplaced);
}
$oldClientId = $oldClientSecret = '';
$oldOpenProjectOauthUrl = $this->config->getAppValue(
Expand Down Expand Up @@ -293,19 +307,22 @@ private function setIntegrationConfig(array $values): array {
if (key_exists('setup_app_password', $values) && $values['setup_app_password'] === false) {
$this->openprojectAPIService->deleteAppPassword();
if (!$runningFullReset) {
$this->openprojectAPIService->logToAuditFile(
"Project folder setup has been deactivated in application " . Application::APP_ID
$this->openprojectAPIService->logToAuditLog(
Operation::ProjectFolderDeactivated,
'Project folder setup has been deactivated in application ' . Application::APP_ID,
);
}
}

$resetOpenProjectClient = ((key_exists('openproject_client_id', $values) && $values['openproject_client_id'] !== $oldClientId) ||
(key_exists('openproject_client_secret', $values) && $values['openproject_client_secret'] !== $oldClientSecret));

$usersDisconnected = false;
if ($resetOpenProjectClient || $runningFullResetWithOAuth2Auth || $switchingOAuthToOIDC) {
$this->userManager->callForAllUsers(function (IUser $user) {
$this->clearUserInfo($user->getUID());
});
$usersDisconnected = true;

if ($switchingOAuthToOIDC) {
$this->resetOauth2Configs();
Expand All @@ -315,21 +332,39 @@ private function setIntegrationConfig(array $values): array {
$this->userManager->callForAllUsers(function (IUser $user) {
$this->clearUserInfo($user->getUID());
});
$usersDisconnected = true;
}

// whenever doing full reset we want the project folder switch state to be "on" in the UI
// so setting `fresh_project_folder_setup` as true
if ($runningFullReset) {
$this->config->setAppValue(Application::APP_ID, 'fresh_project_folder_setup', "1");
$this->openprojectAPIService->logToAuditFile(
"OpenProject Integration admin configuration has been reset in application " . Application::APP_ID
$this->openprojectAPIService->logToAuditLog(
Operation::IntegrationReset,
'OpenProject Integration admin configuration has been reset in application ' . Application::APP_ID,
['usersDisconnected' => $usersDisconnected],
);
} elseif (key_exists('setup_app_password', $values) && key_exists('setup_project_folder', $values)) {
// for other cases when api has key 'setup_app_password' and 'setup_project_folder' we set it to false
// assuming user has either fully set the integration or partially without project folder/app password
$this->config->setAppValue(Application::APP_ID, 'fresh_project_folder_setup', "0");
}

$savedSettings = array_values(array_diff(array_keys($values), ['setup_project_folder', 'setup_app_password']));
if (!$runningFullReset && $savedSettings !== []) {
// only the names of the settings are logged, never the OpenProject client secret
$this->openprojectAPIService->logToAuditLog(
Operation::IntegrationConfigured,
"OpenProject integration settings %s have been saved for the OpenProject instance '%s' in application " . Application::APP_ID,
[
'settings' => implode(', ', $savedSettings),
'openprojectUrl' => key_exists('openproject_instance_url', $values) ? (string)$values['openproject_instance_url'] : $oldOpenProjectOauthUrl,
'authorizationMethod' => key_exists('authorization_method', $values) ? (string)$values['authorization_method'] : $oldAuthMethod,
'usersDisconnected' => $usersDisconnected,
],
);
}

return [
"status" => OpenProjectAPIService::isAdminConfigOk($this->config),
"oPUserAppPassword" => $appPassword,
Expand All @@ -355,23 +390,6 @@ public function setAdminConfig(array $values): DataResponse {
}
}
$result = $this->setIntegrationConfig($values);
$isOPOAuthCrdentialSet = key_exists('openproject_client_id', $values) &&
key_exists('openproject_client_secret', $values) &&
$values['openproject_client_id'] &&
$values['openproject_client_secret'];

if (key_exists('openproject_instance_url', $values) &&
$values['openproject_instance_url'] && !$isOPOAuthCrdentialSet) {
// sending admin audit log if admin has changed or added the openproject host url
$this->openprojectAPIService->logToAuditFile(
"OpenProject host url has been set to '" . $values['openproject_instance_url'] . "' in application " . Application::APP_ID
);
}
if ($isOPOAuthCrdentialSet) {
$this->openprojectAPIService->logToAuditFile(
"OpenProject OAuth credential 'openproject_client_id' and 'openproject_client_secret' has been set in application " . Application::APP_ID
);
}
return new DataResponse($result);
} catch (OpenprojectGroupfolderSetupConflictException $e) {
return new DataResponse([
Expand Down Expand Up @@ -476,6 +494,7 @@ public function oauthRedirect(string $code = '', string $state = ''): RedirectRe
$this->config->setUserValue(
$this->userId, Application::APP_ID, 'oauth_connection_result', 'success'
);
$this->logAccountConnected();
}
return new RedirectResponse($newUrl);
}
Expand Down Expand Up @@ -510,13 +529,13 @@ public function oauthRedirect(string $code = '', string $state = ''): RedirectRe
*/
public function autoOauthCreation(): DataResponse {
$result = $this->recreateOauthClientInformation();
$this->openprojectAPIService->logToAuditFile(
"Nextcloud OAuth credential has been set in application " . Application::APP_ID
);
return new DataResponse($result);
}

private function deleteOauthClient(): void {
/**
* @return bool whether an OAuth client was configured
*/
private function deleteOauthClient(): bool {
$oauthClientInternalId = $this->config->getAppValue(
Application::APP_ID, 'nc_oauth_client_id', ''
);
Expand All @@ -527,7 +546,9 @@ private function deleteOauthClient(): void {
} catch (ClientNotFoundException $e) {
}
$this->config->deleteAppValue(Application::APP_ID, 'nc_oauth_client_id');
return true;
}
return false;
}

/**
Expand Down Expand Up @@ -701,10 +722,17 @@ public function resetIntegration(): DataResponse {
* @return array<mixed>
*/
private function recreateOauthClientInformation(): array {
$this->deleteOauthClient();
$replaced = $this->deleteOauthClient();
$opUrl = $this->config->getAppValue(Application::APP_ID, 'openproject_instance_url', '');
$clientInfo = $this->oauthService->createNcOauthClient('OpenProject client', rtrim($opUrl, '/') .'/oauth_clients/%s/callback');
$this->config->setAppValue(Application::APP_ID, 'nc_oauth_client_id', $clientInfo['id']);
$this->openprojectAPIService->logToAuditLog(
Operation::OauthClientCreated,
$replaced
? 'Nextcloud OAuth client %s for OpenProject has replaced the previous one in application ' . Application::APP_ID
: 'Nextcloud OAuth client %s for OpenProject has been created in application ' . Application::APP_ID,
['oauthClientId' => (int)$clientInfo['id'], 'replaced' => $replaced],
);
unset($clientInfo['id']);
return $clientInfo;
}
Expand Down
Loading
Loading