]> git.ipfire.org Git - thirdparty/bacula.git/commitdiff
baculum: New API config ACLs
authorMarcin Haba <marcin.haba@bacula.pl>
Tue, 13 Jun 2023 13:20:29 +0000 (15:20 +0200)
committerMarcin Haba <marcin.haba@bacula.pl>
Mon, 3 Jul 2023 08:46:57 +0000 (10:46 +0200)
Changes:
 - Add new POST and DELETE config endpoints
 - Validate Console roles for each config request
 - Introduce extended API mode (default disabled)

gui/baculum/protected/API/Modules/APIConfig.php
gui/baculum/protected/API/Modules/BaculaConfigACL.php [new file with mode: 0644]
gui/baculum/protected/API/Modules/BaculumAPIServer.php
gui/baculum/protected/API/Pages/API/Config.php
gui/baculum/protected/API/Pages/API/config.xml
gui/baculum/protected/API/openapi_baculum.json
gui/baculum/protected/Common/Modules/Errors/BaculaConfigError.php

index 24090973c30f492fbff078728699d7ac02e36561..ef47eccc819ecc349c4a72962f2977e5014b9104 100644 (file)
@@ -384,4 +384,19 @@ class APIConfig extends ConfigFileModule {
                }
                return $actions;
        }
+
+       /**
+        * Check if extended mode is enabled.
+        *
+        * @return bool true if extended mode is enabled, false otherwise
+        */
+       public function isExtendedMode() {
+               $is_emode = false;
+               $api_config = $this->getConfig('api');
+               if (key_exists('extended_mode', $api_config) && $api_config['extended_mode'] === '1') {
+                       // without extended mode all resources are allowed
+                       $is_emode = true;
+               }
+               return $is_emode;
+       }
 }
diff --git a/gui/baculum/protected/API/Modules/BaculaConfigACL.php b/gui/baculum/protected/API/Modules/BaculaConfigACL.php
new file mode 100644 (file)
index 0000000..f5144b3
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+/*
+ * Bacula(R) - The Network Backup Solution
+ * Baculum   - Bacula web interface
+ *
+ * Copyright (C) 2013-2023 Kern Sibbald
+ *
+ * The main author of Baculum is Marcin Haba.
+ * The original author of Bacula is Kern Sibbald, with contributions
+ * from many others, a complete list can be found in the file AUTHORS.
+ *
+ * You may use this file and others of this release according to the
+ * license defined in the LICENSE file, which includes the Affero General
+ * Public License, v3.0 ("AGPLv3") and some additional permissions and
+ * terms pursuant to its AGPLv3 Section 7.
+ *
+ * This notice must be preserved when any source code is
+ * conveyed and/or propagated.
+ *
+ * Bacula(R) is a registered trademark of Kern Sibbald.
+ */
+
+namespace Baculum\API\Modules;
+
+/**
+ * ACLs for Bacula configuration part.
+ *
+ * @author Marcin Haba <marcin.haba@bacula.pl>
+ * @category Module
+ * @package Baculum API
+ */
+class BaculaConfigACL extends APIModule
+{
+       /**
+        * Special config ACL action names.
+        */
+       const CONFIG_ACL_ACTIONS = [
+               'READ',
+               'CREATE',
+               'UPDATE',
+               'DELETE'
+       ];
+       /**
+        * Validate if request command is allowed.
+        *
+        * @param string $console_name Director Console name
+        * @param string $action current action (@see BaculaConfigACL::CONFIG_ACL_ACTIONS)
+        * @param string $component_type component type (currently not used)
+        * @param string $resource_type resource type
+        * @return bool true if request command is allowed, false otherwise
+        */
+       public function validateCommand($user_id, $action, $component_type, $resource_type)
+       {
+               $valid = false;
+               $resource = strtoupper($resource_type);
+               if ($this->validateAction($action)) {
+                       $command_acls = $this->getCommandACLs($user_id);
+                       for ($i = 0; $i < count($command_acls); $i++) {
+                               if ($command_acls[$i]['action'] === $action && $command_acls[$i]['keyword'] === $resource) {
+                                       $valid = true;
+                                       break;
+                               }
+                       }
+               }
+               return $valid;
+       }
+
+       /**
+        * Validate action.
+        *
+        * @param string $action action name (ex. 'READ' or 'DELETE')
+        * @return bool true if action is valid, false otherwise
+        */
+       private function validateAction($action)
+       {
+               return in_array($action, self::CONFIG_ACL_ACTIONS);
+       }
+
+       /**
+        * Get special commands for restrictions from Director Console resource.
+        *
+        * @param $console_name console name
+        * @return array commands for restrictions or empty array if no command found
+        */
+       private function getCommandACLs($console_name)
+       {
+               $command_acls = [];
+               $bacula_setting = $this->getModule('bacula_setting');
+               $config = $bacula_setting->getConfig(
+                       'dir',
+                       'Console',
+                       $console_name,
+                       [
+                               'apply_jobdefs' => true
+                       ]
+               );
+               if ($config['exitcode'] === 0) {
+                       if (key_exists('CommandAcl', $config['output'])) {
+                               $command_acls = $this->findCommands($config['output']['CommandAcl']);
+                       }
+               }
+               return $command_acls;
+       }
+
+       /**
+        * Find special command ACLs that defines config restrictions.
+        * It takes CommandACLs directive value and reads it to find commands.
+        *
+        * @param array $commands CommandACLs command list
+        * @return array restriction commands or empty array if no command found
+        */
+       private function findCommands(array $commands)
+       {
+               $command_acls = [];
+               for ($i = 0; $i < count($commands); $i++) {
+                       // @TODO: Propose using commands in form <RESOURCE>_<ACTION> or <PREFIX>_<RESOURCE>_<ACTION>
+                       if (preg_match('/^(?P<action>(READ|CREATE|UPDATE|DELETE))_(?P<keyword>[A-Z]+)$/', $commands[$i], $match) === 1) {
+                               $command_acls[] = [
+                                       'keyword' => $match['keyword'],
+                                       'action' => $match['action']
+                               ];
+                       }
+               }
+               return $command_acls;
+       }
+
+}
+?>
index 5e2381e4eaf400f4b718ce9859fc5888ddd07235..eb914e65697a6c149e336347eeaabf12941c48b0 100644 (file)
@@ -62,11 +62,9 @@ abstract class BaculumAPIServer extends TPage {
        protected $director;
 
        /**
-        * Web interface User name that sent request to API.
-        * Null value means administrator, any other value means normal user
-        * (non-admin user).
+        * Basic auth username
         */
-       protected $user;
+       protected $username;
 
        /**
         * Endpoints available for every authenticated client.
@@ -101,6 +99,7 @@ abstract class BaculumAPIServer extends TPage {
                        $is_auth = true;
                        $username = isset($_SERVER['PHP_AUTH_USER']) ? $_SERVER['PHP_AUTH_USER'] : null;
                        if ($username) {
+                               $this->username = $username;
                                $props = $this->getModule('basic_config')->getConfig($username);
                                $this->initAuthParams($props);
                        }
index 0a99a08c6283cba8a1df090bea232ed744270c5c..15e3d2c98117962745c376b73e0cf6a938091b4e 100644 (file)
@@ -21,6 +21,7 @@
  */
 
 use Baculum\API\Modules\BaculumAPIServer;
+use Baculum\Common\Modules\Errors\AuthorizationError;
 use Baculum\Common\Modules\Errors\BaculaConfigError;
 
 /**
@@ -31,6 +32,7 @@ use Baculum\Common\Modules\Errors\BaculaConfigError;
  * @package Baculum API
  */
 class Config extends BaculumAPIServer {
+
        public function get() {
                $misc = $this->getModule('misc');
                $component_type = $this->Request->contains('component_type') ? $this->Request['component_type'] : null;
@@ -41,10 +43,31 @@ class Config extends BaculumAPIServer {
                if ($apply_jobdefs) {
                        $opts['apply_jobdefs'] = $apply_jobdefs;
                }
+               if (!$this->isResourceAllowed(
+                       'READ',
+                       $component_type,
+                       $resource_type,
+                       $resource_name
+               )) {
+                       // Access denied. End.
+                       return;
+               }
 
-               $config = $this->getModule('bacula_setting')->getConfig($component_type, $resource_type, $resource_name, $opts);
-               $this->output = $config['output'];
-               $this->error = $config['exitcode'];
+               // Role valid. Access granted
+               $config = $this->getModule('bacula_setting')->getConfig(
+                       $component_type,
+                       $resource_type,
+                       $resource_name,
+                       $opts
+               );
+               if ($config['exitcode'] === 0 && count($config['output']) == 0) {
+                       // Config does not exists. Nothing to get.
+                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_DOES_NOT_EXIST;
+                       $this->error = BaculaConfigError::ERROR_CONFIG_DOES_NOT_EXIST;
+               } else {
+                       $this->output = $config['output'];
+                       $this->error = $config['exitcode'];
+               }
        }
 
        public function set($id, $params) {
@@ -57,9 +80,10 @@ class Config extends BaculumAPIServer {
                                $config = json_decode($config['config'], true);
                        }
                } else {
-                       $config = array();
+                       $config = [];
                }
                if (is_null($config)) {
+                       // Invalid config. End.
                        $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR;
                        $this->error = BaculaConfigError::ERROR_CONFIG_VALIDATION_ERROR;
                        return;
@@ -68,16 +92,230 @@ class Config extends BaculumAPIServer {
                $resource_type = $this->Request->contains('resource_type') ? $this->Request['resource_type'] : null;
                $resource_name = $this->Request->contains('resource_name') ? $this->Request['resource_name'] : null;
 
-               $result = $this->getModule('bacula_setting')->setConfig($config, $component_type, $resource_type, $resource_name);
-               if ($result['save_result'] === true) {
-                       $this->output = BaculaConfigError::MSG_ERROR_NO_ERRORS;
-                       $this->error = BaculaConfigError::ERROR_NO_ERRORS;
-               } else if ($result['is_valid'] === false) {
-                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR . print_r($result['result'], true);
+               if (!$this->isResourceAllowed(
+                       'UPDATE',
+                       $component_type,
+                       $resource_type,
+                       $resource_name
+               )) {
+                       // Access denied. End.
+                       return;
+               }
+
+               $resource_config = [];
+               if (is_string($component_type) && is_string($resource_type) && is_string($resource_name)) {
+                       // Get existing resource config.
+                       $res = $this->getModule('bacula_setting')->getConfig(
+                               $component_type,
+                               $resource_type,
+                               $resource_name
+                       );
+                       if ($res['exitcode'] === 0) {
+                               $resource_config = $res['output'];
+                       }
+               }
+
+               if (is_null($resource_name) || count($resource_config) > 0 || $this->getModule('api_config')->isExtendedMode() === false) {
+                       // Config exists. Update it.
+                       $result = $this->getModule('bacula_setting')->setConfig(
+                               $config,
+                               $component_type,
+                               $resource_type,
+                               $resource_name
+                       );
+                       if ($result['save_result'] === true) {
+                               $this->output = BaculaConfigError::MSG_ERROR_NO_ERRORS;
+                               $this->error = BaculaConfigError::ERROR_NO_ERRORS;
+                       } else if ($result['is_valid'] === false) {
+                               $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR . print_r($result['result'], true);
+                               $this->error = BaculaConfigError::ERROR_CONFIG_VALIDATION_ERROR;
+                       } else {
+                               $this->output = BaculaConfigError::MSG_ERROR_WRITE_TO_CONFIG_ERROR . print_r($result['result'], true);
+                               $this->error = BaculaConfigError::ERROR_WRITE_TO_CONFIG_ERROR;
+                       }
+               } else {
+                       // Config does not exists. Nothing to update.
+                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_DOES_NOT_EXIST;
+                       $this->error = BaculaConfigError::ERROR_CONFIG_DOES_NOT_EXIST;
+               }
+       }
+
+       public function create($params) {
+               $config = (array)$params;
+               $config = json_decode($config['config'], true);
+               if (is_null($config)) {
+                       // Invalid config. End.
+                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR;
                        $this->error = BaculaConfigError::ERROR_CONFIG_VALIDATION_ERROR;
+                       return;
+               }
+
+               $component_type = $this->Request->contains('component_type') ? $this->Request['component_type'] : null;
+               $resource_type = $this->Request->contains('resource_type') ? $this->Request['resource_type'] : null;
+               $resource_name = $this->Request->contains('resource_name') ? $this->Request['resource_name'] : null;
+
+               if (!$this->isResourceAllowed(
+                       'CREATE',
+                       $component_type,
+                       $resource_type,
+                       $resource_name
+               )) {
+                       // Access denied. End.
+                       return;
+               }
+
+               $resource_config = [];
+               if (is_string($component_type) && is_string($resource_type) && is_string($resource_name)) {
+                       // Get existing resource config.
+                       $res = $this->getModule('bacula_setting')->getConfig(
+                               $component_type,
+                               $resource_type,
+                               $resource_name
+                       );
+                       if ($res['exitcode'] === 0) {
+                               $resource_config = $res['output'];
+                       }
+               }
+
+               if (is_null($resource_name) || count($resource_config) == 0) {
+                       // Resource does not exists, so add it to config.
+                       $result = $this->getModule('bacula_setting')->setConfig(
+                               $config,
+                               $component_type,
+                               $resource_type,
+                               $resource_name
+                       );
+                       if ($result['save_result'] === true) {
+                               $this->output = BaculaConfigError::MSG_ERROR_NO_ERRORS;
+                               $this->error = BaculaConfigError::ERROR_NO_ERRORS;
+                       } else if ($result['is_valid'] === false) {
+                               $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR . print_r($result['result'], true);
+                               $this->error = BaculaConfigError::ERROR_CONFIG_VALIDATION_ERROR;
+                       } else {
+                               $this->output = BaculaConfigError::MSG_ERROR_WRITE_TO_CONFIG_ERROR . print_r($result['result'], true);
+                               $this->error = BaculaConfigError::ERROR_WRITE_TO_CONFIG_ERROR;
+                       }
                } else {
-                       $this->output = BaculaConfigError::MSG_ERROR_WRITE_TO_CONFIG_ERROR . print_r($result['result'], true);
-                       $this->error = BaculaConfigError::ERROR_WRITE_TO_CONFIG_ERROR;
+                       // Resource already exists. End.
+                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_ALREADY_EXISTS;
+                       $this->error = BaculaConfigError::ERROR_CONFIG_ALREADY_EXISTS;
+               }
+       }
+
+       public function remove($id) {
+               $component_type = $this->Request->contains('component_type') ? $this->Request['component_type'] : null;
+               $resource_type = $this->Request->contains('resource_type') ? $this->Request['resource_type'] : null;
+               $resource_name = $this->Request->contains('resource_name') ? $this->Request['resource_name'] : null;
+
+               if (!$this->isResourceAllowed(
+                       'DELETE',
+                       $component_type,
+                       $resource_type,
+                       $resource_name
+               )) {
+                       // Access denied. End.
+                       return;
+               }
+
+               $config = [];
+               if (is_string($component_type) && is_string($resource_type) && is_string($resource_name)) {
+                       $res = $this->getModule('bacula_setting')->getConfig(
+                               $component_type
+                       );
+                       if ($res['exitcode'] === 0) {
+                               $config = $res['output'];
+                       }
+               }
+               $config_len = count($config);
+               if ($config_len > 0) {
+                       $index_del = -1;
+                       for ($i = 0; $i < $config_len; $i++) {
+                               if (!key_exists($resource_type, $config[$i])) {
+                                       // skip other resource types
+                                       continue;
+                               }
+                               if ($config[$i][$resource_type]['Name'] === $resource_name) {
+                                       $index_del = $i;
+                                       break;
+                               }
+                       }
+                       if ($index_del > -1) {
+                               array_splice($config, $index_del, 1);
+                               $result = $this->getModule('bacula_setting')->setConfig(
+                                       $config,
+                                       $component_type
+                               );
+                               if ($result['save_result'] === true) {
+                                       $this->output = BaculaConfigError::MSG_ERROR_NO_ERRORS;
+                                       $this->error = BaculaConfigError::ERROR_NO_ERRORS;
+                               } else if ($result['is_valid'] === false) {
+                                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_VALIDATION_ERROR . print_r($result['result'], true);
+                                       $this->error = BaculaConfigError::ERROR_CONFIG_VALIDATION_ERROR;
+                               } else {
+                                       $this->output = BaculaConfigError::MSG_ERROR_WRITE_TO_CONFIG_ERROR . print_r($result['result'], true);
+                                       $this->error = BaculaConfigError::ERROR_WRITE_TO_CONFIG_ERROR;
+                               }
+                       } else {
+                               $this->output = BaculaConfigError::MSG_ERROR_CONFIG_DOES_NOT_EXIST;
+                               $this->error = BaculaConfigError::ERROR_CONFIG_DOES_NOT_EXIST;
+                       }
+               } else {
+                       $this->output = BaculaConfigError::MSG_ERROR_CONFIG_DOES_NOT_EXIST;
+                       $this->error = BaculaConfigError::ERROR_CONFIG_DOES_NOT_EXIST;
+               }
+       }
+
+       /**
+        * Access denied error.
+        * User is not allowed to use given action.
+        *
+        * @param string $component_type component type
+        * @param string $resource_type resource type
+        * @param string $resource_name resource name
+        * @return none
+        */
+       private function accessDenied($component_type, $resource_type, $resource_name)
+       {
+               $emsg =  sprintf(
+                       ' ComponentType: %s, ResourceType: %s, ResourceName: %s',
+                       $component_type,
+                       $resource_type,
+                       $resource_name
+               );
+               $this->output = AuthorizationError::MSG_ERROR_ACCESS_ATTEMPT_TO_NOT_ALLOWED_RESOURCE . $emsg;
+               $this->error = AuthorizationError::ERROR_ACCESS_ATTEMPT_TO_NOT_ALLOWED_RESOURCE;
+       }
+
+       /**
+        * Check if resource is allowed for current user.
+        *
+        * @param string $component_type component type
+        * @param string $resource_type resource type
+        * @param string $resource_name resource name
+        * @return bool true if user is allowed, false otherwise
+        */
+       private function isResourceAllowed($action, $component_type, $resource_type, $resource_name)
+       {
+               if ($this->getModule('api_config')->isExtendedMode() === false) {
+                       // without extended mode all resources are allowed
+                       return true;
+               }
+               $bacula_config_acl = $this->getModule('bacula_config_acl');
+               $valid = $bacula_config_acl->validateCommand(
+                       $this->username,
+                       $action,
+                       $component_type,
+                       $resource_type
+               );
+
+               if (!$valid) {
+                       // Access denied. End.
+                       $this->accessDenied(
+                               $component_type,
+                               $resource_type,
+                               $resource_name
+                       );
                }
+               return $valid;
        }
 }
index a7865a57ca6c992961ece38aceeb7ef8b755a207..41c931ccc053b34f83abcd0268b725960bbe2f16 100644 (file)
@@ -39,6 +39,7 @@
                <!-- config modules -->
                <module id="api_config" class="Baculum\API\Modules\APIConfig" />
                <module id="bacula_config" class="Baculum\API\Modules\BaculaConfig" />
+               <module id="bacula_config_acl" class="Baculum\API\Modules\BaculaConfigACL" />
                <module id="bacula_setting" class="Baculum\API\Modules\BaculaSetting" />
                <module id="device_config" class="Baculum\API\Modules\DeviceConfig" />
                <!-- logging modules -->
index 01ca997387e481642ec2f7253389be3d84ddbc13..16413e007d241d1ef2d56e5a0b95a6fc8e6793b2 100644 (file)
                                        }
                                ]
                        },
+                       "post": {
+                               "tags": ["config"],
+                               "summary": "Create component resource config",
+                               "description": "Create specific component resource config",
+                               "consumes": [ "application/json" ],
+                               "responses": {
+                                       "200": {
+                                               "description": "Create single resource config",
+                                               "content": {
+                                                       "application/json": {
+                                                               "schema": {
+                                                                       "type": "object",
+                                                                       "properties": {
+                                                                               "output": {
+                                                                                       "type": "object",
+                                                                                       "description": "Create resource config error message"
+                                                                               },
+                                                                               "error": {
+                                                                                       "type": "integer",
+                                                                                       "description": "Error code",
+                                                                                       "enum": [0, 1, 2, 3, 4, 5, 6, 7, 11, 80, 81, 82, 83, 84, 90, 91, 92, 93, 94, 95, 1000]
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               },
+                               "parameters": [
+                                       {
+                                               "$ref": "#/components/parameters/ComponentType"
+                                       },
+                                       {
+                                               "$ref": "#/components/parameters/ResourceType"
+                                       },
+                                       {
+                                               "$ref": "#/components/parameters/ResourceName"
+                                       },
+                                       {
+                                               "name": "config",
+                                               "in": "body",
+                                               "description": "Config in JSON form to create",
+                                               "required": true,
+                                               "schema": {
+                                                       "type": "string"
+                                               }
+                                       }
+                               ]
+                       },
                        "put": {
                                "tags": ["config"],
                                "summary": "Set component resource config",
                                                }
                                        }
                                ]
+                       },
+                       "delete": {
+                               "tags": ["config"],
+                               "summary": "Delete component resource config",
+                               "description": "Delete specific component resource config",
+                               "consumes": [ "application/json" ],
+                               "responses": {
+                                       "200": {
+                                               "description": "Delete single resource config",
+                                               "content": {
+                                                       "application/json": {
+                                                               "schema": {
+                                                                       "type": "object",
+                                                                       "properties": {
+                                                                               "output": {
+                                                                                       "type": "object",
+                                                                                       "description": "Delete resource config error message"
+                                                                               },
+                                                                               "error": {
+                                                                                       "type": "integer",
+                                                                                       "description": "Error code",
+                                                                                       "enum": [0, 1, 2, 3, 4, 5, 6, 7, 11, 80, 81, 82, 83, 84, 90, 91, 92, 93, 94, 96, 1000]
+                                                                               }
+                                                                       }
+                                                               }
+                                                       }
+                                               }
+                                       }
+                               },
+                               "parameters": [
+                                       {
+                                               "$ref": "#/components/parameters/ComponentType"
+                                       },
+                                       {
+                                               "$ref": "#/components/parameters/ResourceType"
+                                       },
+                                       {
+                                               "$ref": "#/components/parameters/ResourceName"
+                                       }
+                               ]
                        }
                },
                "/api/v2/devices/{device_name}/load": {
index 3e22416b992296c6b8414ea5f4ed01300c5ba861..a34b4f744b6b741d5c3339ccd5b538505fabae9d 100644 (file)
@@ -36,10 +36,14 @@ class BaculaConfigError extends GenericError {
        const ERROR_CONFIG_NO_JSONTOOL_READY = 92;
        const ERROR_WRITE_TO_CONFIG_ERROR = 93;
        const ERROR_CONFIG_VALIDATION_ERROR = 94;
+       const ERROR_CONFIG_ALREADY_EXISTS = 95;
+       const ERROR_CONFIG_DOES_NOT_EXIST = 96;
 
        const MSG_ERROR_CONFIG_DIR_NOT_WRITABLE = 'Config directory is not writable.';
        const MSG_ERROR_UNEXPECTED_BACULA_CONFIG_VALUE = 'Unexpected Bacula config value.';
        const MSG_ERROR_CONFIG_NO_JSONTOOL_READY = 'No JSON tool ready.';
        const MSG_ERROR_WRITE_TO_CONFIG_ERROR = 'Write to config file error.';
        const MSG_ERROR_CONFIG_VALIDATION_ERROR = 'Config validation error.';
+       const MSG_ERROR_CONFIG_ALREADY_EXISTS = 'Config already exists.';
+       const MSG_ERROR_CONFIG_DOES_NOT_EXIST = 'Config does not exist.';
 }