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
16 changes: 16 additions & 0 deletions docs/administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,22 @@ permissions for groups in the same way as for users described in the topic above
Please note that the permissions for a group are higher rated than the permissions on a user. To enable the group
permissions, please set the permission level from _basic_ to _medium_ in the main configuration.

### 5.1.3 Category restrictions (Medium permission mode)

Groups can be restricted per right to a set of categories (Admin → Groups → Category restrictions). The rules are:

- **No restriction selected = the right applies to all categories.**
- A group restricted to categories X and Y cannot add, edit, translate, approve, or delete FAQs in other categories, and
the admin UI only shows the allowed categories.
- **Direct user rights always remain global** — category restrictions only apply to rights granted through groups. Grant
rights via groups if you want category-level control.
- Restrictions match exact categories; they are not inherited by subcategories.
- **Basic permission mode has no groups**, so category restrictions do not apply there; every right is global.
- CSV import and the AI translation endpoint are gated by the global `add_faq` / `translate_faq` rights; category checks
apply when the translated or imported content is saved.
- A blocked action returns HTTP 403 with a message naming the missing right and the category.
- FAQs without any category assignment can only be modified by users whose rights are not category-restricted.

## 5.2 Content

### 5.2.1 Category Administration
Expand Down
74 changes: 74 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

/**
* Filters linear category trees to the categories a user may act on.
*
* This Source Code Form is subject to the terms of the Mozilla Public License,
* v. 2.0. If a copy of the MPL was not distributed with this file, You can
* obtain one at https://mozilla.org/MPL/2.0/.
*
* @package phpMyFAQ
* @author Thorsten Rinne <thorsten@phpmyfaq.de>
* @copyright 2026 phpMyFAQ Team
* @license https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
* @link https://www.phpmyfaq.de
* @since 2026-08-10
*/

declare(strict_types=1);

namespace phpMyFAQ\Category;

/**
* Filters linear category trees down to the categories a user may act on,
* based on group-level category restrictions. Null means unrestricted.
*/
final class CategoryTreeRestrictionFilter
{
/**
* @param array<int, array<string, mixed>> $categoryTree Linear tree from Category::getCategoryTree()
* @param array<int>|null $allowedCategoryIds Null = unrestricted
* @return array<int, array<string, mixed>>
*/
public static function filter(array $categoryTree, ?array $allowedCategoryIds): array
{
if ($allowedCategoryIds === null) {
return $categoryTree;
}

return array_values(array_filter($categoryTree, static fn(array $entry): bool => in_array(
needle: (int) $entry['id'],
haystack: $allowedCategoryIds,
strict: true,
)));
}

/**
* Filters a nested tree (map of categoryId => children map) as produced by
* Category\Order::getCategoryTree() and Category::buildAdminCategoryTree().
* Removing a node removes its whole subtree.
*
* @param array<array-key, mixed> $categoryTree
* @param array<int>|null $allowedCategoryIds Null = unrestricted
* @return array<array-key, mixed>
*/
public static function filterNested(array $categoryTree, ?array $allowedCategoryIds): array
{
if ($allowedCategoryIds === null) {
return $categoryTree;
}

$filtered = [];
foreach ($categoryTree as $categoryId => $children) {

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.6 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.5 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.4 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.6 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.4 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.

Check warning on line 62 in phpmyfaq/src/phpMyFAQ/Category/CategoryTreeRestrictionFilter.php

View workflow job for this annotation

GitHub Actions / phpMyFAQ 8.5 Test on ubuntu-latest

mixed-assignment

Assigning `mixed` type to a variable may lead to unexpected behavior. >Assigning `mixed` type here. Using `mixed` can lead to runtime errors if the variable is used in a way that assumes a specific type. Help: Consider using a more specific type to avoid potential issues.
if (!in_array(needle: (int) $categoryId, haystack: $allowedCategoryIds, strict: true)) {
continue;
}

$filtered[$categoryId] = is_array($children)
? self::filterNested($children, $allowedCategoryIds)
: $children;
}

return $filtered;
}
}
1 change: 1 addition & 0 deletions phpmyfaq/src/phpMyFAQ/Category/Relation.php
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,7 @@ private function aggregateRecursively(
*
* @param int $faqId FAQ id
* @param string $faqLang FAQ language
* @return array<int, array{category_id: int, category_lang: string}>
*/
public function getCategories(int $faqId, string $faqLang): array
{
Expand Down
55 changes: 55 additions & 0 deletions phpmyfaq/src/phpMyFAQ/Controller/AbstractController.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
contact: new OA\Contact(name: 'phpMyFAQ Team', email: 'support@phpmyfaq.de'),
)]
#[OA\Server(url: 'https://localhost', description: 'Local dockerized server')]
/* @mago-expect lint:too-many-methods - permission guard methods grow with each enforced right; a dedicated guard trait is planned */
#[OA\License(name: 'Mozilla Public Licence 2.0', url: 'https://www.mozilla.org/MPL/2.0/')]
abstract class AbstractController
{
Expand Down Expand Up @@ -356,6 +357,60 @@ protected function userHasPermission(PermissionType $permissionType): void
}
}

/**
* Ensures the user owns the permission in every given category.
* Direct user-rights remain global; in basic permission mode this is
* identical to userHasPermission().
*
* Empty-list policy: when $categoryIds resolves to an empty list after
* filtering (e.g. an orphaned FAQ with no category relations), only users
* whose right is unrestricted (getAllowedCategoriesForRight() returns null)
* may proceed. A category-restricted user is denied with a ForbiddenException,
* because there is no category membership to verify against and allowing
* unrestricted access would defeat the restriction.
*
* @param int[] $categoryIds
* @throws UnauthorizedHttpException|ForbiddenException
*/
protected function userHasPermissionForCategories(PermissionType $permissionType, array $categoryIds): void
{
if (!$this->currentUser->isLoggedIn()) {
throw new UnauthorizedHttpException(challenge: 'User is not authenticated.');
}

$currentUser = $this->currentUser;
$categoryIds = array_filter(array_unique($categoryIds), static fn(int $id): bool => $id > 0);

if ($categoryIds === []) {
$allowed = $currentUser->perm->getAllowedCategoriesForRight(
$currentUser->getUserId(),
$permissionType->value,
);
if ($allowed !== null) {
throw new ForbiddenException(message: sprintf(
'User has no "%s" permission for uncategorized content.',
$permissionType->name,
));
}

return;
}

foreach ($categoryIds as $categoryId) {
if (!$currentUser->perm->hasPermissionForCategory(
$currentUser->getUserId(),
$permissionType->value,
$categoryId,
)) {
throw new ForbiddenException(message: sprintf(
'User has no "%s" permission for category %d.',
$permissionType->name,
$categoryId,
));
}
}
}

/**
* Grants access when the user owns at least one of the given permissions.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use phpMyFAQ\Category\Order;
use phpMyFAQ\Category\Permission;
use phpMyFAQ\Category\Relation;
use phpMyFAQ\Controller\Exception\ForbiddenException;
use phpMyFAQ\Core\Exception;
use phpMyFAQ\Enums\AdminLogType;
use phpMyFAQ\Enums\PermissionType;
Expand Down Expand Up @@ -64,6 +65,8 @@ public function delete(Request $request): JsonResponse
$categoryId = (int) ($data->categoryId ?? 0);
$categoryLang = (string) ($data->language ?? '');

$this->userHasPermissionForCategories(PermissionType::CATEGORY_DELETE, [$categoryId]);

[$currentAdminUser, $currentAdminGroups] = CurrentUser::getCurrentUserGroupId($this->currentUser);

$category = new Category($this->configuration, [], false);
Expand Down Expand Up @@ -157,6 +160,20 @@ public function updateOrder(Request $request): JsonResponse
}

$categoryId = (int) ($data->categoryId ?? 0);

// Reordering persists the entire submitted tree (all categories), so a
// per-category check is not enough: the right must be unrestricted.
$allowedCategories = $this->currentUser->perm->getAllowedCategoriesForRight(
$this->currentUser->getUserId(),
PermissionType::CATEGORY_EDIT->value,
);
if ($allowedCategories !== null) {
throw new ForbiddenException(message: sprintf(
'User has no "%s" permission to reorder the category tree.',
PermissionType::CATEGORY_EDIT->name,
));
Comment thread
thorsten marked this conversation as resolved.
}

$categoryTreeRaw = $data->categoryTree ?? [];
$categoryTree = array_values(array_filter(
is_array($categoryTreeRaw) ? $categoryTreeRaw : [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ public function create(Request $request): JsonResponse
? array_map(static fn(mixed $categoryId): int => (int) $categoryId, $rawCategories)
: [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];

$this->userHasPermissionForCategories(PermissionType::FAQ_ADD, $categories);

$language = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
$tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
$active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
Expand Down Expand Up @@ -355,6 +357,11 @@ public function update(Request $request): JsonResponse
: [(int) Filter::filterVar($rawCategories, FILTER_VALIDATE_INT)];

$faqLang = Filter::filterVar($data->lang ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');

$categoryRelation = new Relation($this->configuration, $category);
$currentCategoryIds = array_keys($categoryRelation->getCategories($faqId, $faqLang));
$this->userHasPermissionForCategories(PermissionType::FAQ_EDIT, [...$categories, ...$currentCategoryIds]);

Comment thread
coderabbitai[bot] marked this conversation as resolved.
$tags = Filter::filterVar($data->tags ?? '', FILTER_SANITIZE_SPECIAL_CHARS, '');
$active = Filter::filterVar($data->active ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
$sticky = Filter::filterVar($data->sticky ?? 'no', FILTER_SANITIZE_SPECIAL_CHARS, 'no');
Expand Down Expand Up @@ -442,7 +449,6 @@ public function update(Request $request): JsonResponse

$faqId = $faqData->getId() ?? $faqId;

$categoryRelation = new Relation($this->configuration, $category);
$categoryRelation->deleteByFaq($faqId, $faqLang);
$categoryRelation->add($categories, $faqId, $faqLang);

Expand Down Expand Up @@ -550,6 +556,8 @@ public function listByCategory(Request $request): JsonResponse
$categoryId = (int) Filter::filterVar($request->attributes->get(key: 'categoryId'), FILTER_VALIDATE_INT);
$language = Filter::filterVar($request->attributes->get(key: 'language'), FILTER_SANITIZE_SPECIAL_CHARS, '');

$this->userHasPermissionForCategories(PermissionType::FAQ_EDIT, [$categoryId]);

$onlyInactive = Filter::filterVar(
$request->query->get(key: 'only-inactive'),
FILTER_VALIDATE_BOOLEAN,
Expand All @@ -562,9 +570,10 @@ public function listByCategory(Request $request): JsonResponse

return $this->json([
'faqs' => $faq->getAllFaqsByCategory($categoryId, $onlyInactive, $onlyNew),
'isAllowedToTranslate' => $this->currentUser?->perm->hasPermission(
'isAllowedToTranslate' => $this->currentUser?->perm->hasPermissionForCategory(
$this->currentUser->getUserId(),
PermissionType::FAQ_TRANSLATE->value,
$categoryId,
),
], Response::HTTP_OK);
}
Expand Down Expand Up @@ -592,6 +601,15 @@ public function activate(Request $request): JsonResponse
}

if ($faqIds !== []) {
$activateCategory = new Category($this->configuration, [], withPermission: false);
$activateCategoryRelation = new Relation($this->configuration, $activateCategory);
foreach ($faqIds as $faqId) {
$this->userHasPermissionForCategories(
PermissionType::FAQ_APPROVE,
array_keys($activateCategoryRelation->getCategories($faqId, $faqLanguage)),
);
}

$faq = new FaqAdministration($this->configuration);
$success = false;

Expand Down Expand Up @@ -637,6 +655,15 @@ public function sticky(Request $request): JsonResponse
}

if ($faqIds !== []) {
$stickyCategory = new Category($this->configuration, [], withPermission: false);
$stickyCategoryRelation = new Relation($this->configuration, $stickyCategory);
foreach ($faqIds as $faqId) {
$this->userHasPermissionForCategories(
PermissionType::FAQ_EDIT,
array_keys($stickyCategoryRelation->getCategories($faqId, $faqLanguage)),
);
}

$faq = new FaqAdministration($this->configuration);
$success = false;

Expand Down Expand Up @@ -682,6 +709,13 @@ public function delete(Request $request): JsonResponse
], Response::HTTP_UNAUTHORIZED);
}

$deleteCategory = new Category($this->configuration, [], withPermission: false);
$deleteCategoryRelation = new Relation($this->configuration, $deleteCategory);
$this->userHasPermissionForCategories(
PermissionType::FAQ_DELETE,
array_keys($deleteCategoryRelation->getCategories($faqId, $faqLanguage)),
);

$this->adminLog->log($this->currentUser, AdminLogType::FAQ_DELETE->value . ':' . $faqId);

try {
Expand Down
Loading
Loading