Skip to content
This repository was archived by the owner on May 29, 2026. It is now read-only.
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
10 changes: 10 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,16 @@
</navigation>
</navigations>

<!-- @spec openspec/architecture/adr-023-action-authorization.md -->
<repair-steps>
<install>
<step>OCA\AppTemplate\Repair\InitializeActions</step>
</install>
<post-migration>
<step>OCA\AppTemplate\Repair\InitializeActions</step>
</post-migration>
</repair-steps>

<settings>
<admin>OCA\AppTemplate\Settings\AdminSettings</admin>
<admin-section>OCA\AppTemplate\Sections\SettingsSection</admin-section>
Expand Down
127 changes: 127 additions & 0 deletions lib/Repair/InitializeActions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
<?php

/**
* Initialize Actions Repair Step
*
* Seeds the ADR-023 action-authorization matrix on fresh install if empty.
* Preserves any admin-customized matrix on upgrade — existing non-empty
* matrix values are left untouched.
*
* @category Repair
* @package OCA\AppTemplate\Repair
*
* @author Conduction Development Team <info@conduction.nl>
* @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\AppTemplate\Repair;

use OCA\AppTemplate\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
*/
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
*/
public function run(IOutput $output): void
{
$existing = $this->actionAuth->getMatrix();
if (count($existing) > 0) {
$output->info(
sprintf(
'Action matrix already has %d entr%s — preserving.',
count($existing),
(count($existing) === 1 ? 'y' : 'ies')
)
);
return;
}

if (file_exists(self::SEED_PATH) === false) {
$output->warning('actions.seed.json not found — matrix left empty (default-deny).');
$this->logger->warning('[app-template] 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('[app-template] 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($actions);
} catch (\JsonException $e) {
$output->warning('Failed to write matrix: ' . $e->getMessage());
return;
}

$output->info(
sprintf(
'Seeded action matrix with %d action%s (default: admin-only).',
count($actions),
(count($actions) === 1 ? '' : 's')
)
);

}//end run()

}//end class
242 changes: 242 additions & 0 deletions lib/Service/ActionAuthService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
<?php

/**
* Action Authorization Service
*
* Implements the ADR-023 action-level authorization pattern: each controller
* method declares an action name (e.g. "item.publish") and delegates the
* authorization decision to this service, which resolves the action against
* an admin-configured matrix stored in IAppConfig.
*
* This service is the canonical place to enforce action RBAC. Per ADR-023:
* - Data RBAC (who can read/write which objects) is OpenRegister's job.
* - Action RBAC (who can invoke which controller method) is this service.
* - Admin-only operations (editing the matrix itself, app config, backup/
* restore, integrations, credentials) bypass this service and use
* #[AuthorizedAdminSetting(Application::APP_ID)] at the route layer.
*
* Controllers call `requireAction` which throws OCSForbiddenException when
* the caller's groups don't intersect the matrix entry for the action.
*
* Admin users always pass. The matrix defaults to ["admin"] for every
* declared action — first-install safe posture. The admin broadens via
* the settings UI.
*
* @category Service
* @package OCA\AppTemplate\Service
*
* @author Conduction Development Team <info@conduction.nl>
* @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\AppTemplate\Service;

use OCA\AppTemplate\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.
*/
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.
*/
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);

// 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.
*/
public function can(IUser $user, string $action): bool
{
try {
$this->requireAction($user, $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<int, string>
*/
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<string, array<int, string>>
*/
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<string, array<int, string>> $matrix The new matrix.
*
* @return void
*
* @throws \JsonException When the matrix cannot be encoded.
*/
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<int, string>
*/
public function getActions(): array
{
return array_keys($this->getMatrix());

}//end getActions()

}//end class
4 changes: 4 additions & 0 deletions lib/actions.seed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"$comment": "ADR-023 action-authorization matrix seed. Each entry maps a dot-separated action name to an array of group IDs allowed to invoke it. The special 'admin' group means Nextcloud admins; admins always pass regardless. Empty array or ['admin'] means admin-only. Admins customize via Admin Settings > App Template > Actions. Add entries below as the app grows — one per ActionAuthService::requireAction() call site. Template ships with an example commented pattern; remove the $comment field and add real actions when implementing your app.",
"actions": {}
}
Loading
Loading