diff --git a/appinfo/info.xml b/appinfo/info.xml index c32a2db6..18c7618d 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -81,6 +81,16 @@ Vrij en open source onder de EUPL-1.2-licentie. OCA\MyDash\BackgroundJob\OrphanedDataCleanupJob + + + + OCA\MyDash\Repair\InitializeActions + + + OCA\MyDash\Repair\InitializeActions + + + OCA\MyDash\Settings\MyDashAdmin OCA\MyDash\Settings\MyDashAdminSection diff --git a/appinfo/routes.php b/appinfo/routes.php index 6cf89ab6..6dfc57a3 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -328,6 +328,11 @@ ['name' => 'admin#getSettings', 'url' => '/api/admin/settings', 'verb' => 'GET'], ['name' => 'admin#updateSettings', 'url' => '/api/admin/settings', 'verb' => 'PUT'], + // ADR-023 action-authorization matrix. Admin-only via + // #[AuthorizedAdminSetting] on the controller methods. + ['name' => 'action_matrix#getMatrix', 'url' => '/api/admin/action-matrix', 'verb' => 'GET'], + ['name' => 'action_matrix#setMatrix', 'url' => '/api/admin/action-matrix', 'verb' => 'PUT'], + // Global footer settings (REQ-FTR-001, REQ-FTR-010). Both // admin-only via runtime `IGroupManager::isAdmin` check inside // the controller. The PUT verb is a partial-patch contract — diff --git a/lib/Controller/ActionMatrixController.php b/lib/Controller/ActionMatrixController.php new file mode 100644 index 00000000..98502dfe --- /dev/null +++ b/lib/Controller/ActionMatrixController.php @@ -0,0 +1,169 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + +declare(strict_types=1); + +namespace OCA\MyDash\Controller; + +use OCA\MyDash\AppInfo\Application; +use OCA\MyDash\Service\ActionAuthService; +use OCP\AppFramework\Controller; +use OCP\AppFramework\Http\Attribute\AuthorizedAdminSetting; +use OCP\AppFramework\Http\JSONResponse; +use OCP\IGroupManager; +use OCP\IRequest; + +/** + * Admin-only controller exposing the action-authorization matrix (ADR-023). + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ +class ActionMatrixController extends Controller +{ + private const SEED_PATH = __DIR__.'/../actions.seed.json'; + + /** + * Constructor. + * + * @param IRequest $request The request. + * @param ActionAuthService $actionAuth The action authorization service. + * @param IGroupManager $groupManager The group manager. + */ + public function __construct( + IRequest $request, + private readonly ActionAuthService $actionAuth, + private readonly IGroupManager $groupManager, + ) { + parent::__construct( + appName: Application::APP_ID, + request: $request + ); + }//end __construct() + + /** + * Get the full action matrix, the complete action key list, and all groups. + * + * The action key list is the union of the keys currently in the matrix and + * the keys declared in the seed file, so the admin sees every declared + * action even before any customization. + * + * @return JSONResponse The matrix, action keys, and group IDs. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + #[AuthorizedAdminSetting(Application::APP_ID)] + public function getMatrix(): JSONResponse + { + $matrix = $this->actionAuth->getMatrix(); + + $actionKeys = array_keys($matrix); + foreach ($this->seedActionKeys() as $key) { + if (in_array($key, $actionKeys, true) === false) { + $actionKeys[] = $key; + } + } + + sort($actionKeys); + + $groups = []; + foreach ($this->groupManager->search('') as $group) { + $groups[] = $group->getGID(); + } + + return new JSONResponse( + [ + 'matrix' => $matrix, + 'actions' => $actionKeys, + 'groups' => $groups, + ] + ); + + }//end getMatrix() + + /** + * Persist the action matrix. + * + * Reads the `matrix` parameter from the request body and writes it through + * the action authorization service (which normalizes the shape). + * + * @return JSONResponse The normalized matrix after the write. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + #[AuthorizedAdminSetting(Application::APP_ID)] + public function setMatrix(): JSONResponse + { + $matrix = $this->request->getParam('matrix'); + if (is_array($matrix) === false) { + $matrix = []; + } + + try { + $this->actionAuth->setMatrix($matrix); + } catch (\JsonException $e) { + return new JSONResponse( + ['error' => 'Could not encode the action matrix: '.$e->getMessage()], + \OCP\AppFramework\Http::STATUS_BAD_REQUEST + ); + } + + return new JSONResponse(['matrix' => $this->actionAuth->getMatrix()]); + + }//end setMatrix() + + /** + * Read the action keys declared in the seed file. + * + * @return array + */ + private function seedActionKeys(): array + { + if (file_exists(self::SEED_PATH) === false) { + return []; + } + + $raw = file_get_contents(self::SEED_PATH); + if ($raw === false) { + return []; + } + + try { + $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return []; + } + + $actions = ($parsed['actions'] ?? null); + if (is_array($actions) === false) { + return []; + } + + $keys = []; + foreach (array_keys($actions) as $key) { + if (is_string($key) === true) { + $keys[] = $key; + } + } + + return $keys; + + }//end seedActionKeys() +}//end class diff --git a/lib/Controller/DashboardApiController.php b/lib/Controller/DashboardApiController.php index 06606eab..8d2adeac 100644 --- a/lib/Controller/DashboardApiController.php +++ b/lib/Controller/DashboardApiController.php @@ -24,6 +24,7 @@ use OCA\MyDash\AppInfo\Application; use OCA\MyDash\Exception\DashboardHasChildrenException; use OCA\MyDash\Exception\PersonalDashboardsDisabledException; +use OCA\MyDash\Service\ActionAuthService; use OCA\MyDash\Service\AnalyticsService; use OCA\MyDash\Service\DashboardService; use OCA\MyDash\Service\DashboardTreeService; @@ -35,6 +36,7 @@ use OCP\AppFramework\Http\Attribute\NoAdminRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -90,6 +92,12 @@ class DashboardApiController extends Controller * fork to report * unexpected errors * — REQ-DASH-021). + * @param IUserSession $userSession The user session, used + * to resolve the + * authenticated IUser for + * ADR-023 action checks. + * @param ActionAuthService $actionAuth The ADR-023 action + * authorization service. * @param string|null $userId The user ID. */ public function __construct( @@ -100,6 +108,8 @@ public function __construct( private readonly DashboardVersionService $versionService, private readonly AnalyticsService $analyticsService, private readonly LoggerInterface $logger, + private readonly IUserSession $userSession, + private readonly ActionAuthService $actionAuth, private readonly ?string $userId, ) { parent::__construct( @@ -122,6 +132,10 @@ public function __construct( #[NoAdminRequired] public function list(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -148,6 +162,10 @@ public function list(): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function visible(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -176,6 +194,10 @@ public function visible(): JSONResponse #[NoAdminRequired] public function getActive(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -226,6 +248,10 @@ public function getActive(): JSONResponse #[NoAdminRequired] public function show(int $id): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -400,6 +426,13 @@ public function update( ?string $slug=null, ?int $sortOrder=null ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.update'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -486,6 +519,13 @@ public function update( #[NoAdminRequired] public function delete(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.delete'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -534,6 +574,10 @@ public function delete(int $id): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function tree(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -560,6 +604,10 @@ public function tree(): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function byPath(string $path=''): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -615,6 +663,10 @@ public function byPath(string $path=''): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function computePath(string $uuid=''): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -646,6 +698,13 @@ public function computePath(string $uuid=''): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function activate(int $id): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.activate'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -677,6 +736,10 @@ public function activate(int $id): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function listGroup(string $groupId): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -760,6 +823,10 @@ public function getGroup( string $groupId, string $uuid ): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -979,6 +1046,10 @@ public function setGroupDefault( /** @spec openspec/specs/dashboards/spec.md */ public function setActiveDashboard(?string $uuid=null): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1012,6 +1083,10 @@ public function setActiveDashboard(?string $uuid=null): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function setDefaultDashboard(?string $uuid=null): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1034,6 +1109,10 @@ public function setDefaultDashboard(?string $uuid=null): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function getDefaultDashboard(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1155,6 +1234,13 @@ public function fork( /** @spec openspec/specs/dashboards/spec.md */ public function publish(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.publish'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1192,6 +1278,13 @@ public function publish(string $uuid): JSONResponse /** @spec openspec/specs/dashboards/spec.md */ public function unpublish(string $uuid): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.unpublish'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1238,6 +1331,13 @@ public function schedule( string $uuid, ?string $publishAt=null ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'dashboard.schedule'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -1304,6 +1404,10 @@ public function schedule( /** @spec openspec/specs/dashboards/spec.md */ public function viewEvent(string $uuid): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } diff --git a/lib/Controller/WidgetApiController.php b/lib/Controller/WidgetApiController.php index ab7ff3fc..081dc09c 100644 --- a/lib/Controller/WidgetApiController.php +++ b/lib/Controller/WidgetApiController.php @@ -21,6 +21,7 @@ use DateTimeImmutable; use InvalidArgumentException; use OCA\MyDash\AppInfo\Application; +use OCA\MyDash\Service\ActionAuthService; use OCA\MyDash\Service\CalendarWidgetService; use OCA\MyDash\Service\NewsWidgetService; use OCA\MyDash\Service\PermissionService; @@ -33,6 +34,7 @@ use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Throwable; /** @@ -56,6 +58,9 @@ class WidgetApiController extends Controller * @param CalendarWidgetService $calendarWidgetService The calendar widget service (REQ-CAL-003). * @param WidgetPlacementService $widgetPlacementService Placement-payload validators (REQ-CONT-006). * @param RoleFeaturePermissionService $roleFeaturePerm Role-feature filter (REQ-RFP-001..010). + * @param IUserSession $userSession User session, used to resolve the + * authenticated IUser for ADR-023 action checks. + * @param ActionAuthService $actionAuth The ADR-023 action authorization service. * @param string|null $userId The user ID. */ public function __construct( @@ -66,6 +71,8 @@ public function __construct( private readonly CalendarWidgetService $calendarWidgetService, private readonly WidgetPlacementService $widgetPlacementService, private readonly RoleFeaturePermissionService $roleFeaturePerm, + private readonly IUserSession $userSession, + private readonly ActionAuthService $actionAuth, private readonly ?string $userId, ) { parent::__construct( @@ -85,6 +92,10 @@ public function __construct( #[NoAdminRequired] public function listAvailable(): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + $widgets = $this->widgetService->getAvailableWidgets(); if ($this->userId === null) { @@ -133,6 +144,10 @@ public function getItems( array $widgets=[], int $limit=7 ): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -169,6 +184,13 @@ public function addWidget( int $gridWidth=4, int $gridHeight=4 ): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'widget.add-widget'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -276,6 +298,13 @@ private function containerDepthExceededResponse(): JSONResponse /** @spec openspec/specs/widgets/spec.md */ public function addTile(int $dashboardId): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'widget.add-tile'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -317,6 +346,13 @@ public function addTile(int $dashboardId): JSONResponse #[NoAdminRequired] public function updatePlacement(int $placementId): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'widget.update-placement'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -375,6 +411,13 @@ public function updatePlacement(int $placementId): JSONResponse #[NoAdminRequired] public function removePlacement(int $placementId): JSONResponse { + $user = $this->userSession->getUser(); + if ($user === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + + $this->actionAuth->requireAction($user, 'widget.remove-placement'); + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -419,6 +462,10 @@ public function removePlacement(int $placementId): JSONResponse /** @spec openspec/specs/widgets/spec.md */ public function newsItems(int $placementId, ?int $limit=10): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } @@ -475,6 +522,10 @@ public function calendarEvents( string $from='', string $to='' ): JSONResponse { + if ($this->userSession->getUser() === null) { + return new JSONResponse(['error' => 'Not authenticated'], \OCP\AppFramework\Http::STATUS_UNAUTHORIZED); + } + if ($this->userId === null) { return ResponseHelper::unauthorized(); } diff --git a/lib/Repair/InitializeActions.php b/lib/Repair/InitializeActions.php new file mode 100644 index 00000000..31e7db5a --- /dev/null +++ b/lib/Repair/InitializeActions.php @@ -0,0 +1,140 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + +declare(strict_types=1); + +namespace OCA\MyDash\Repair; + +use OCA\MyDash\Service\ActionAuthService; +use OCP\Migration\IOutput; +use OCP\Migration\IRepairStep; +use Psr\Log\LoggerInterface; + +/** + * Seed the action-authorization matrix from lib/actions.seed.json on install. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ +class InitializeActions implements IRepairStep +{ + private const SEED_PATH = __DIR__.'/../actions.seed.json'; + + /** + * Constructor. + * + * @param ActionAuthService $actionAuth The action authorization service. + * @param LoggerInterface $logger Logger. + */ + public function __construct( + private ActionAuthService $actionAuth, + private LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Repair-step name. + * + * @return string + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function getName(): string + { + return 'Initialize action-authorization matrix (ADR-023)'; + + }//end getName() + + /** + * Seed the matrix if empty; preserve any existing admin-customized matrix. + * + * @param IOutput $output Repair output channel. + * + * @return void + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function run(IOutput $output): void + { + $existing = $this->actionAuth->getMatrix(); + if (count($existing) > 0) { + $entrySuffix = 'ies'; + if (count($existing) === 1) { + $entrySuffix = 'y'; + } + + $output->info( + sprintf( + 'Action matrix already has %d entr%s — preserving.', + count($existing), + $entrySuffix + ) + ); + return; + } + + if (file_exists(self::SEED_PATH) === false) { + $output->warning('actions.seed.json not found — matrix left empty (default-deny).'); + $this->logger->warning('[mydash] ADR-023 seed file missing at '.self::SEED_PATH); + return; + } + + $raw = file_get_contents(self::SEED_PATH); + if ($raw === false) { + $output->warning('Could not read actions.seed.json — matrix left empty (default-deny).'); + return; + } + + try { + $parsed = json_decode($raw, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + $output->warning('actions.seed.json invalid JSON: '.$e->getMessage()); + $this->logger->error('[mydash] ADR-023 seed malformed: '.$e->getMessage()); + return; + } + + $actions = ($parsed['actions'] ?? null); + if (is_array($actions) === false) { + $output->warning('actions.seed.json missing `actions` object — matrix left empty.'); + return; + } + + try { + $this->actionAuth->setMatrix(matrix: $actions); + } catch (\JsonException $e) { + $output->warning('Failed to write matrix: '.$e->getMessage()); + return; + } + + $actionSuffix = 's'; + if (count($actions) === 1) { + $actionSuffix = ''; + } + + $output->info( + sprintf( + 'Seeded action matrix with %d action%s (default: admin-only).', + count($actions), + $actionSuffix + ) + ); + + }//end run() +}//end class diff --git a/lib/Service/ActionAuthService.php b/lib/Service/ActionAuthService.php new file mode 100644 index 00000000..2a334e9a --- /dev/null +++ b/lib/Service/ActionAuthService.php @@ -0,0 +1,255 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @link https://conduction.nl + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + +declare(strict_types=1); + +namespace OCA\MyDash\Service; + +use OCA\MyDash\AppInfo\Application; +use OCP\AppFramework\OCS\OCSForbiddenException; +use OCP\IAppConfig; +use OCP\IGroupManager; +use OCP\IUser; + +/** + * Action-level authorization service. + * + * Enforces ADR-023 action RBAC: controllers call requireAction with a + * dot-separated action name; this service checks the admin-configured + * action-to-group mapping stored in IAppConfig. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ +class ActionAuthService +{ + private const CONFIG_KEY = 'actions'; + + /** + * Constructor. + * + * @param IAppConfig $appConfig IAppConfig for reading/writing the matrix + * @param IGroupManager $groupManager Group manager for resolving user groups + */ + public function __construct( + private IAppConfig $appConfig, + private IGroupManager $groupManager, + ) { + }//end __construct() + + /** + * Require that the user may perform the named action. + * + * Admin users always pass (break-glass). Non-admins pass only when any + * of their groups intersects the matrix entry for the action. + * + * @param IUser $user The authenticated user. + * @param string $action Dot-separated action name (e.g. "item.publish"). + * + * @return void + * + * @throws OCSForbiddenException When the user's groups don't match the action's allowed groups. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function requireAction(IUser $user, string $action): void + { + // Admin always passes — break-glass for ops / debugging. + if ($this->groupManager->isAdmin($user->getUID()) === true) { + return; + } + + $allowedGroups = $this->getAllowedGroups(action: $action); + + // An "admin"-only entry means non-admins never pass (admin already + // returned above). Empty entry means nobody is allowed. + if (count($allowedGroups) === 0 || $allowedGroups === ['admin']) { + throw new OCSForbiddenException( + "Action '{$action}' requires admin rights" + ); + } + + $userGroups = $this->groupManager->getUserGroupIds($user); + + // Exclude "admin" from matrix entry before intersection — admin was + // already checked above; its presence in the entry is a display hint, + // not a group membership check. + $nonAdminAllowed = array_values(array_diff($allowedGroups, ['admin'])); + + if (count(array_intersect($userGroups, $nonAdminAllowed)) === 0) { + throw new OCSForbiddenException( + "Action '{$action}' not allowed for your groups" + ); + } + + }//end requireAction() + + /** + * Check whether the user may perform the named action (non-throwing). + * + * @param IUser $user The authenticated user. + * @param string $action Dot-separated action name. + * + * @return bool True if the user may perform the action. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function can(IUser $user, string $action): bool + { + try { + $this->requireAction(user: $user, action: $action); + return true; + } catch (OCSForbiddenException $e) { + return false; + } + + }//end can() + + /** + * Get the list of groups allowed to perform the action. + * + * Returns the matrix entry for the action, or ["admin"] as the safe + * default when the action is not in the matrix. + * + * @param string $action Dot-separated action name. + * + * @return array + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function getAllowedGroups(string $action): array + { + $matrix = $this->getMatrix(); + return $matrix[$action] ?? ['admin']; + + }//end getAllowedGroups() + + /** + * Get the full action-to-groups matrix. + * + * Reads the JSON-encoded matrix from IAppConfig. Missing or malformed + * config returns an empty array (default-deny — admin-only for every + * action since getAllowedGroups falls back to ["admin"]). + * + * @return array> + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function getMatrix(): array + { + $json = $this->appConfig->getValueString(Application::APP_ID, self::CONFIG_KEY, '{}'); + + try { + $decoded = json_decode($json, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + return []; + } + + if (is_array($decoded) === false) { + return []; + } + + // Normalize: discard any non-array values + any non-string group entries. + $matrix = []; + foreach ($decoded as $action => $groups) { + if (is_string($action) === false || is_array($groups) === false) { + continue; + } + + $clean = []; + foreach ($groups as $g) { + if (is_string($g) === true && $g !== '') { + $clean[] = $g; + } + } + + $matrix[$action] = array_values(array_unique($clean)); + } + + return $matrix; + + }//end getMatrix() + + /** + * Set the full action-to-groups matrix. + * + * Caller MUST enforce admin-only before invoking (this method does not + * gate writes — it's called from an admin-only settings endpoint). + * + * @param array> $matrix The new matrix. + * + * @return void + * + * @throws \JsonException When the matrix cannot be encoded. + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function setMatrix(array $matrix): void + { + // Normalize on write — same shape as getMatrix returns. + $normalized = []; + foreach ($matrix as $action => $groups) { + if (is_string($action) === false || is_array($groups) === false) { + continue; + } + + $clean = []; + foreach ($groups as $g) { + if (is_string($g) === true && $g !== '') { + $clean[] = $g; + } + } + + $normalized[$action] = array_values(array_unique($clean)); + } + + $json = json_encode($normalized, flags: JSON_THROW_ON_ERROR); + $this->appConfig->setValueString(Application::APP_ID, self::CONFIG_KEY, $json); + + }//end setMatrix() + + /** + * List all action keys currently in the matrix. + * + * @return array + * + * @spec openspec/architecture/adr-023-action-authorization.md + */ + public function getActions(): array + { + return array_keys($this->getMatrix()); + + }//end getActions() +}//end class diff --git a/lib/actions.seed.json b/lib/actions.seed.json new file mode 100644 index 00000000..7da36a29 --- /dev/null +++ b/lib/actions.seed.json @@ -0,0 +1,79 @@ +{ + "$comment": "ADR-023 action matrix seed for mydash. Default admin-only; broaden via Admin Settings > MyDash > Action authorization.", + "actions": { + "admin.get-my-role": ["admin"], + "admin-org-navigation.get-org-navigation": ["admin"], + "admin-org-navigation.get-position": ["admin"], + "analytics.top-dashboards": ["admin"], + "analytics.dashboard-detail": ["admin"], + "analytics.instance-summary": ["admin"], + "analytics.export-csv": ["admin"], + "dashboard.list": ["admin"], + "dashboard.visible": ["admin"], + "dashboard.get-active": ["admin"], + "dashboard.show": ["admin"], + "dashboard.update": ["admin"], + "dashboard.delete": ["admin"], + "dashboard.tree": ["admin"], + "dashboard.by-path": ["admin"], + "dashboard.compute-path": ["admin"], + "dashboard.activate": ["admin"], + "dashboard.list-group": ["admin"], + "dashboard.get-group": ["admin"], + "dashboard.set-active-dashboard": ["admin"], + "dashboard.set-default-dashboard": ["admin"], + "dashboard.get-default-dashboard": ["admin"], + "dashboard.publish": ["admin"], + "dashboard.unpublish": ["admin"], + "dashboard.schedule": ["admin"], + "dashboard.view-event": ["admin"], + "dashboard-comments.index": ["admin"], + "dashboard-comments.destroy": ["admin"], + "dashboard-lock.acquire": ["admin"], + "dashboard-lock.get": ["admin"], + "dashboard-metadata.get-metadata": ["admin"], + "dashboard-metadata.set-metadata": ["admin"], + "dashboard-reaction.get-reactions": ["admin"], + "dashboard-reaction.add-reaction": ["admin"], + "dashboard-reaction.remove-reaction": ["admin"], + "dashboard-reaction.get-reactors-by-emoji": ["admin"], + "dashboard-translation.list": ["admin"], + "dashboard-translation.create": ["admin"], + "dashboard-translation.update": ["admin"], + "dashboard-translation.destroy": ["admin"], + "dashboard-translation.set-primary": ["admin"], + "dashboard-translation.resolved": ["admin"], + "dashboard-version.list-versions": ["admin"], + "dashboard-version.fetch-version": ["admin"], + "dashboard-version.create-version": ["admin"], + "dashboard-version.restore-version": ["admin"], + "manifest.index": ["admin"], + "metadata-admin.list-fields": ["admin"], + "metadata-admin.create-field": ["admin"], + "metadata-admin.get-field": ["admin"], + "metadata-admin.update-field": ["admin"], + "metadata-admin.delete-field": ["admin"], + "people-widget.get-users": ["admin"], + "resource.get-resource": ["admin"], + "resource.list-resources": ["admin"], + "resource-serve.get-resource": ["admin"], + "resource-serve.list-resources": ["admin"], + "rule.get-rules": ["admin"], + "rule.add-rule": ["admin"], + "rule.update-rule": ["admin"], + "rule.delete-rule": ["admin"], + "template.gallery": ["admin"], + "tile.index": ["admin"], + "tile.create": ["admin"], + "tile.update": ["admin"], + "tile.destroy": ["admin"], + "widget.list-available": ["admin"], + "widget.get-items": ["admin"], + "widget.add-widget": ["admin"], + "widget.add-tile": ["admin"], + "widget.update-placement": ["admin"], + "widget.remove-placement": ["admin"], + "widget.news-items": ["admin"], + "widget.calendar-events": ["admin"] + } +} diff --git a/src/components/admin/ActionAuthMatrix.vue b/src/components/admin/ActionAuthMatrix.vue new file mode 100644 index 00000000..5edbdff5 --- /dev/null +++ b/src/components/admin/ActionAuthMatrix.vue @@ -0,0 +1,250 @@ + + + + + + + diff --git a/src/components/admin/AdminSettings.vue b/src/components/admin/AdminSettings.vue index e973cdba..400b0adc 100644 --- a/src/components/admin/AdminSettings.vue +++ b/src/components/admin/AdminSettings.vue @@ -231,6 +231,11 @@ + +
+ +
+

{{ t('mydash', 'Setting as default app') }}

@@ -329,6 +334,7 @@ import AdminDemoData from './AdminDemoData.vue' import SetupWizardModal from './SetupWizardModal.vue' import { api } from '../../services/api.js' import RolePermissionsSection from './RolePermissionsSection.vue' +import ActionAuthMatrix from './ActionAuthMatrix.vue' export default { name: 'AdminSettings', @@ -353,6 +359,7 @@ export default { SetupWizardModal, ViewDashboard, RolePermissionsSection, + ActionAuthMatrix, }, // REQ-INIT-004: read the initial-state snapshot the PHP admin form diff --git a/src/services/api.js b/src/services/api.js index 3ddf0d8b..e507dcef 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -423,6 +423,17 @@ export const api = { return axios.post(`${baseUrl}/api/admin/groups`, { groups }) }, + // ADR-023 action-authorization matrix. Both endpoints are admin-only + // on the server side via #[AuthorizedAdminSetting]; the UI gates the + // section behind the same admin check. + getActionMatrix() { + return axios.get(`${baseUrl}/api/admin/action-matrix`) + }, + + updateActionMatrix(matrix) { + return axios.put(`${baseUrl}/api/admin/action-matrix`, { matrix }) + }, + // Setup wizard endpoints (REQ-WIZ-008, REQ-WIZ-009, REQ-WIZ-003). // All three are admin-only on the server side; the UI gates the // banner + modal behind the same check via `getSetupWizardState`.