fix(admin): correctly handle nav sections access

This commit is contained in:
2026-03-25 15:09:02 +02:00
parent e26a07a883
commit 7fc7778f45
11 changed files with 476 additions and 358 deletions

View File

@@ -38,6 +38,9 @@ class RequirePermission {
private ?string $resourceIdFromThreadId = null,
// Derive category ID from post ID parameter
private ?string $resourceIdFromPostId = null,
// OR group name: attributes with the same group are OR'd together (any must pass),
// while attributes with different groups or no group are AND'd
private ?string $orGroup = null,
) {
}
@@ -64,4 +67,8 @@ class RequirePermission {
public function getResourceIdFromPostId(): ?string {
return $this->resourceIdFromPostId;
}
public function getOrGroup(): ?string {
return $this->orGroup;
}
}

View File

@@ -129,7 +129,8 @@ class AdminController extends OCSController {
* 200: Users list returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/admin/users')]
public function users(): DataResponse {
try {
@@ -305,8 +306,6 @@ class AdminController extends OCSController {
*
* 200: Stats rebuilt successfully
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[ApiRoute(verb: 'POST', url: '/api/admin/rebuild-stats')]
public function rebuildStats(): DataResponse {
try {
@@ -353,6 +352,9 @@ class AdminController extends OCSController {
*
* 200: Roles list returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/admin/roles')]
public function getRoles(): DataResponse {
try {
@@ -378,6 +380,8 @@ class AdminController extends OCSController {
*
* 200: Role assigned successfully
*/
#[NoAdminRequired]
#[RequirePermission('canEditRoles')]
#[ApiRoute(verb: 'POST', url: '/api/admin/users/{userId}/roles')]
public function assignRole(string $userId, int $roleId): DataResponse {
try {
@@ -435,7 +439,7 @@ class AdminController extends OCSController {
* 200: Role removed successfully
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canEditRoles')]
#[ApiRoute(verb: 'DELETE', url: '/api/admin/users/{userId}/roles/{roleId}')]
public function removeRole(string $userId, int $roleId): DataResponse {
try {

View File

@@ -394,7 +394,8 @@ class CategoryController extends OCSController {
* 200: Permissions returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditCategories', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/categories/{id}/permissions')]
public function getPermissions(int $id, int $limit = 100, int $offset = 0): DataResponse {
try {

View File

@@ -42,7 +42,8 @@ class RoleController extends OCSController {
* 200: Roles returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/roles')]
public function index(int $limit = 100, int $offset = 0): DataResponse {
try {
@@ -63,7 +64,8 @@ class RoleController extends OCSController {
* 200: Role returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/roles/{id}')]
public function show(int $id): DataResponse {
try {
@@ -245,7 +247,8 @@ class RoleController extends OCSController {
* 200: Permissions returned
*/
#[NoAdminRequired]
#[RequirePermission('canAccessAdminTools')]
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[ApiRoute(verb: 'GET', url: '/api/roles/{id}/permissions')]
public function getPermissions(int $id, int $limit = 100, int $offset = 0): DataResponse {
try {

View File

@@ -65,11 +65,7 @@ class PermissionMiddleware extends Middleware {
if (!empty($permissionAttrs)) {
// Check permissions using guest role (null userId)
$this->logger->debug('Checking permissions for unauthenticated user (public page or guest access)');
foreach ($permissionAttrs as $attr) {
/** @var RequirePermission $permission */
$permission = $attr->newInstance();
$this->checkPermission(null, $permission);
}
$this->checkPermissions(null, $permissionAttrs);
}
return;
}
@@ -87,12 +83,57 @@ class PermissionMiddleware extends Middleware {
return;
}
// Check each permission requirement
$this->checkPermissions($userId, $permissionAttrs);
}
/**
* Check all permission attributes, respecting OR groups.
*
* Attributes with the same orGroup are OR'd (any one must pass).
* Attributes with no orGroup, or with different orGroups, are AND'd together.
*
* @param string|null $userId
* @param \ReflectionAttribute[] $permissionAttrs
* @throws OCSForbiddenException
*/
private function checkPermissions(?string $userId, array $permissionAttrs): void {
// Separate into OR groups and ungrouped
$orGroups = [];
$ungrouped = [];
foreach ($permissionAttrs as $attr) {
/** @var RequirePermission $permission */
$permission = $attr->newInstance();
$group = $permission->getOrGroup();
if ($group !== null) {
$orGroups[$group][] = $permission;
} else {
$ungrouped[] = $permission;
}
}
// Ungrouped attributes: all must pass (AND)
foreach ($ungrouped as $permission) {
$this->checkPermission($userId, $permission);
}
// OR groups: at least one in each group must pass
foreach ($orGroups as $group => $permissions) {
$lastException = null;
$passed = false;
foreach ($permissions as $permission) {
try {
$this->checkPermission($userId, $permission);
$passed = true;
break;
} catch (OCSForbiddenException $e) {
$lastException = $e;
}
}
if (!$passed && $lastException !== null) {
throw $lastException;
}
}
}
/**

View File

@@ -177,142 +177,10 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/roles": {
"get": {
"operationId": "admin-get-roles",
"summary": "Get all available roles",
"description": "This endpoint requires admin access",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Roles list returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"roles"
],
"properties": {
"roles": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-assign-role",
"summary": "Assign a role to a user",
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -325,36 +193,7 @@
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"roleId"
],
"properties": {
"roleId": {
"type": "integer",
"format": "int64",
"description": "The role ID to assign"
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
@@ -368,7 +207,7 @@
],
"responses": {
"200": {
"description": "Role assigned successfully",
"description": "Stats rebuilt successfully",
"content": {
"application/json": {
"schema": {

View File

@@ -466,10 +466,10 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"/ocs/v2.php/apps/forum/api/admin/roles": {
"get": {
"operationId": "admin-get-roles",
"summary": "Get all available roles",
"tags": [
"admin"
],
@@ -495,7 +495,139 @@
],
"responses": {
"200": {
"description": "Stats rebuilt successfully",
"description": "Roles list returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"roles"
],
"properties": {
"roles": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
"post": {
"operationId": "admin-assign-role",
"summary": "Assign a role to a user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"roleId"
],
"properties": {
"roleId": {
"type": "integer",
"format": "int64",
"description": "The role ID to assign"
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Role assigned successfully",
"content": {
"application/json": {
"schema": {
@@ -11965,142 +12097,10 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/roles": {
"get": {
"operationId": "admin-get-roles",
"summary": "Get all available roles",
"description": "This endpoint requires admin access",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"parameters": [
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Roles list returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"roles"
],
"properties": {
"roles": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
},
"403": {
"description": "Logged in account must be an admin",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-assign-role",
"summary": "Assign a role to a user",
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"description": "This endpoint requires admin access",
"tags": [
"admin"
@@ -12113,36 +12113,7 @@
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"roleId"
],
"properties": {
"roleId": {
"type": "integer",
"format": "int64",
"description": "The role ID to assign"
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
@@ -12156,7 +12127,7 @@
],
"responses": {
"200": {
"description": "Role assigned successfully",
"description": "Stats rebuilt successfully",
"content": {
"application/json": {
"schema": {

View File

@@ -466,10 +466,10 @@
}
}
},
"/ocs/v2.php/apps/forum/api/admin/rebuild-stats": {
"post": {
"operationId": "admin-rebuild-stats",
"summary": "Rebuild all forum statistics (users, categories, threads)",
"/ocs/v2.php/apps/forum/api/admin/roles": {
"get": {
"operationId": "admin-get-roles",
"summary": "Get all available roles",
"tags": [
"admin"
],
@@ -495,7 +495,139 @@
],
"responses": {
"200": {
"description": "Stats rebuilt successfully",
"description": "Roles list returned",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {
"type": "object",
"required": [
"roles"
],
"properties": {
"roles": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": {
"type": "object"
}
}
}
}
}
}
}
}
}
}
}
},
"401": {
"description": "Current user is not logged in",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"ocs"
],
"properties": {
"ocs": {
"type": "object",
"required": [
"meta",
"data"
],
"properties": {
"meta": {
"$ref": "#/components/schemas/OCSMeta"
},
"data": {}
}
}
}
}
}
}
}
}
}
},
"/ocs/v2.php/apps/forum/api/admin/users/{userId}/roles": {
"post": {
"operationId": "admin-assign-role",
"summary": "Assign a role to a user",
"tags": [
"admin"
],
"security": [
{
"bearer_auth": []
},
{
"basic_auth": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"roleId"
],
"properties": {
"roleId": {
"type": "integer",
"format": "int64",
"description": "The role ID to assign"
}
}
}
}
}
},
"parameters": [
{
"name": "userId",
"in": "path",
"description": "The user ID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "OCS-APIRequest",
"in": "header",
"description": "Required to be true for the API request to pass",
"required": true,
"schema": {
"type": "boolean",
"default": true
}
}
],
"responses": {
"200": {
"description": "Role assigned successfully",
"content": {
"application/json": {
"schema": {

View File

@@ -118,6 +118,7 @@
<!-- Admin sub-items -->
<template v-if="isAdminOpen">
<NcAppNavigationItem
v-if="canAccessAdminTools"
:name="strings.navAdminDashboard"
:to="{ path: '/admin' }"
:active="isPathActive('/admin')"
@@ -128,6 +129,7 @@
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAccessAdminTools"
:name="strings.navAdminSettings"
:to="{ path: '/admin/settings' }"
:active="isPathActive('/admin/settings')"
@@ -171,6 +173,7 @@
</NcAppNavigationItem>
<NcAppNavigationItem
v-if="canAccessAdminTools"
:name="strings.navAdminBBCodes"
:to="{ path: '/admin/bbcodes' }"
:active="isPathActive('/admin/bbcodes', true)"
@@ -263,7 +266,8 @@ export default defineComponent({
setup() {
const { categoryHeaders, fetchCategories } = useCategories()
const { userId, displayName, fetchCurrentUser } = useCurrentUser()
const { canAccessAdmin, canEditRoles, canEditCategories, fetchUserRoles } = useUserRole()
const { canAccessAdmin, canAccessAdminTools, canEditRoles, canEditCategories, fetchUserRoles } =
useUserRole()
const { categoryId: currentThreadCategoryId, fetchThread, clearThread } = useCurrentThread()
const { isGuest, guestDisplayName, fetchGuestIdentity } = useGuestSession()
@@ -275,6 +279,7 @@ export default defineComponent({
userId,
displayName,
canAccessAdmin,
canAccessAdminTools,
canEditRoles,
canEditCategories,
currentThreadCategoryId,
@@ -457,8 +462,14 @@ export default defineComponent({
this.saveNavigationState()
}
// Navigate to admin dashboard (first admin item)
this.$router.push({ path: '/admin' })
// Navigate to the first available management item
if (this.canAccessAdminTools) {
this.$router.push({ path: '/admin' })
} else if (this.canEditRoles) {
this.$router.push({ path: '/admin/users' })
} else if (this.canEditCategories) {
this.$router.push({ path: '/admin/categories' })
}
},
isCategoryActive(category: Category): boolean {

View File

@@ -40,10 +40,14 @@ export function useUserRole() {
return userRoles.value.some(isModeratorRole)
})
const canAccessAdmin = computed<boolean>(() => {
const canAccessAdminTools = computed<boolean>(() => {
return userRoles.value.some((role) => role.canAccessAdminTools)
})
const canAccessAdmin = computed<boolean>(() => {
return canAccessAdminTools.value || canEditRoles.value || canEditCategories.value
})
const canEditRoles = computed<boolean>(() => {
return userRoles.value.some((role) => role.canEditRoles)
})
@@ -74,6 +78,7 @@ export function useUserRole() {
isAdmin,
isModerator,
canAccessAdmin,
canAccessAdminTools,
canEditRoles,
canEditCategories,
fetchUserRoles,

View File

@@ -47,6 +47,17 @@ class TestPermissionController extends Controller {
#[RequirePermission('canView', resourceType: 'invalid', resourceIdParam: 'id')]
public function methodWithInvalidResource(): void {
}
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
public function methodWithOrGroup(): void {
}
#[RequirePermission('canAccessAdminTools', orGroup: 'access')]
#[RequirePermission('canEditRoles', orGroup: 'access')]
#[RequirePermission('canEditCategories')]
public function methodWithOrGroupAndUngrouped(): void {
}
}
class PermissionMiddlewareTest extends TestCase {
@@ -428,6 +439,99 @@ class PermissionMiddlewareTest extends TestCase {
$this->assertTrue(true);
}
public function testOrGroupAllowsAccessWhenFirstPermissionPasses(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);
$this->config->method('getAppValueBool')
->with('allow_guest_access', false, true)
->willReturn(false);
$this->permissionService->method('hasGlobalPermission')
->willReturnMap([
['user1', 'canAccessAdminTools', true],
]);
$this->middleware->beforeController($this->controller, 'methodWithOrGroup');
$this->assertTrue(true);
}
public function testOrGroupAllowsAccessWhenSecondPermissionPasses(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);
$this->config->method('getAppValueBool')
->with('allow_guest_access', false, true)
->willReturn(false);
$this->permissionService->method('hasGlobalPermission')
->willReturnMap([
['user1', 'canAccessAdminTools', false],
['user1', 'canEditRoles', true],
]);
$this->middleware->beforeController($this->controller, 'methodWithOrGroup');
$this->assertTrue(true);
}
public function testOrGroupDeniesAccessWhenNoPermissionPasses(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);
$this->config->method('getAppValueBool')
->with('allow_guest_access', false, true)
->willReturn(false);
$this->permissionService->method('hasGlobalPermission')
->willReturnMap([
['user1', 'canAccessAdminTools', false],
['user1', 'canEditRoles', false],
]);
$this->expectException(OCSForbiddenException::class);
$this->middleware->beforeController($this->controller, 'methodWithOrGroup');
}
public function testOrGroupWithUngroupedRequiresBoth(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);
$this->config->method('getAppValueBool')
->with('allow_guest_access', false, true)
->willReturn(false);
// OR group passes (canEditRoles), but ungrouped (canEditCategories) fails
$this->permissionService->method('hasGlobalPermission')
->willReturnMap([
['user1', 'canAccessAdminTools', false],
['user1', 'canEditRoles', true],
['user1', 'canEditCategories', false],
]);
$this->expectException(OCSForbiddenException::class);
$this->middleware->beforeController($this->controller, 'methodWithOrGroupAndUngrouped');
}
public function testOrGroupWithUngroupedAllowsWhenBothPass(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');
$this->userSession->method('getUser')->willReturn($user);
$this->config->method('getAppValueBool')
->with('allow_guest_access', false, true)
->willReturn(false);
// OR group passes (canEditRoles) AND ungrouped (canEditCategories) passes
$this->permissionService->method('hasGlobalPermission')
->willReturnMap([
['user1', 'canAccessAdminTools', false],
['user1', 'canEditRoles', true],
['user1', 'canEditCategories', true],
]);
$this->middleware->beforeController($this->controller, 'methodWithOrGroupAndUngrouped');
$this->assertTrue(true);
}
public function testAuthenticatedUserBypassesGuestRestrictions(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')->willReturn('user1');