diff --git a/lib/Attribute/RequirePermission.php b/lib/Attribute/RequirePermission.php index add756a..92ab18e 100644 --- a/lib/Attribute/RequirePermission.php +++ b/lib/Attribute/RequirePermission.php @@ -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; + } } diff --git a/lib/Controller/AdminController.php b/lib/Controller/AdminController.php index 6905521..c21c110 100644 --- a/lib/Controller/AdminController.php +++ b/lib/Controller/AdminController.php @@ -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 { diff --git a/lib/Controller/CategoryController.php b/lib/Controller/CategoryController.php index b8af04d..ea2676a 100644 --- a/lib/Controller/CategoryController.php +++ b/lib/Controller/CategoryController.php @@ -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 { diff --git a/lib/Controller/RoleController.php b/lib/Controller/RoleController.php index f318219..1523fef 100644 --- a/lib/Controller/RoleController.php +++ b/lib/Controller/RoleController.php @@ -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 { diff --git a/lib/Middleware/PermissionMiddleware.php b/lib/Middleware/PermissionMiddleware.php index 96b1836..0e1100a 100644 --- a/lib/Middleware/PermissionMiddleware.php +++ b/lib/Middleware/PermissionMiddleware.php @@ -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; + } + } } /** diff --git a/openapi-administration.json b/openapi-administration.json index a758ff0..4a2ed01 100644 --- a/openapi-administration.json +++ b/openapi-administration.json @@ -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": { diff --git a/openapi-full.json b/openapi-full.json index 5a597f7..786fca7 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -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": { diff --git a/openapi.json b/openapi.json index 5266bd0..f1ed292 100644 --- a/openapi.json +++ b/openapi.json @@ -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": { diff --git a/src/components/AppNavigation/AppNavigation.vue b/src/components/AppNavigation/AppNavigation.vue index ed30059..fe81cbc 100644 --- a/src/components/AppNavigation/AppNavigation.vue +++ b/src/components/AppNavigation/AppNavigation.vue @@ -118,6 +118,7 @@