1<?php
2/**
3 * PHP configuration based AclInterface implementation
4 *
5 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
6 * Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
7 *
8 * Licensed under The MIT License
9 * For full copyright and license information, please see the LICENSE.txt
10 * Redistributions of files must retain the above copyright notice.
11 *
12 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
13 * @link          https://cakephp.org CakePHP(tm) Project
14 * @package       Cake.Controller.Component.Acl
15 * @since         CakePHP(tm) v 2.1
16 * @license       https://opensource.org/licenses/mit-license.php MIT License
17 */
18
19/**
20 * PhpAcl implements an access control system using a plain PHP configuration file.
21 * An example file can be found in app/Config/acl.php
22 *
23 * @package Cake.Controller.Component.Acl
24 */
25class PhpAcl extends CakeObject implements AclInterface {
26
27/**
28 * Constant for deny
29 *
30 * @var bool
31 */
32	const DENY = false;
33
34/**
35 * Constant for allow
36 *
37 * @var bool
38 */
39	const ALLOW = true;
40
41/**
42 * Options:
43 *  - policy: determines behavior of the check method. Deny policy needs explicit allow rules, allow policy needs explicit deny rules
44 *  - config: absolute path to config file that contains the acl rules (@see app/Config/acl.php)
45 *
46 * @var array
47 */
48	public $options = array();
49
50/**
51 * Aro Object
52 *
53 * @var PhpAro
54 */
55	public $Aro = null;
56
57/**
58 * Aco Object
59 *
60 * @var PhpAco
61 */
62	public $Aco = null;
63
64/**
65 * Constructor
66 *
67 * Sets a few default settings up.
68 */
69	public function __construct() {
70		$this->options = array(
71			'policy' => static::DENY,
72			'config' => CONFIG . 'acl.php',
73		);
74	}
75
76/**
77 * Initialize method
78 *
79 * @param AclComponent $Component Component instance
80 * @return void
81 */
82	public function initialize(Component $Component) {
83		if (!empty($Component->settings['adapter'])) {
84			$this->options = $Component->settings['adapter'] + $this->options;
85		}
86
87		App::uses('PhpReader', 'Configure');
88		$Reader = new PhpReader(dirname($this->options['config']) . DS);
89		$config = $Reader->read(basename($this->options['config']));
90		$this->build($config);
91		$Component->Aco = $this->Aco;
92		$Component->Aro = $this->Aro;
93	}
94
95/**
96 * build and setup internal ACL representation
97 *
98 * @param array $config configuration array, see docs
99 * @return void
100 * @throws AclException When required keys are missing.
101 */
102	public function build(array $config) {
103		if (empty($config['roles'])) {
104			throw new AclException(__d('cake_dev', '"roles" section not found in configuration.'));
105		}
106
107		if (empty($config['rules']['allow']) && empty($config['rules']['deny'])) {
108			throw new AclException(__d('cake_dev', 'Neither "allow" nor "deny" rules were provided in configuration.'));
109		}
110
111		$rules['allow'] = !empty($config['rules']['allow']) ? $config['rules']['allow'] : array();
112		$rules['deny'] = !empty($config['rules']['deny']) ? $config['rules']['deny'] : array();
113		$roles = !empty($config['roles']) ? $config['roles'] : array();
114		$map = !empty($config['map']) ? $config['map'] : array();
115		$alias = !empty($config['alias']) ? $config['alias'] : array();
116
117		$this->Aro = new PhpAro($roles, $map, $alias);
118		$this->Aco = new PhpAco($rules);
119	}
120
121/**
122 * No op method, allow cannot be done with PhpAcl
123 *
124 * @param string $aro ARO The requesting object identifier.
125 * @param string $aco ACO The controlled object identifier.
126 * @param string $action Action (defaults to *)
127 * @return bool Success
128 */
129	public function allow($aro, $aco, $action = "*") {
130		return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'allow');
131	}
132
133/**
134 * deny ARO access to ACO
135 *
136 * @param string $aro ARO The requesting object identifier.
137 * @param string $aco ACO The controlled object identifier.
138 * @param string $action Action (defaults to *)
139 * @return bool Success
140 */
141	public function deny($aro, $aco, $action = "*") {
142		return $this->Aco->access($this->Aro->resolve($aro), $aco, $action, 'deny');
143	}
144
145/**
146 * No op method
147 *
148 * @param string $aro ARO The requesting object identifier.
149 * @param string $aco ACO The controlled object identifier.
150 * @param string $action Action (defaults to *)
151 * @return bool Success
152 */
153	public function inherit($aro, $aco, $action = "*") {
154		return false;
155	}
156
157/**
158 * Main ACL check function. Checks to see if the ARO (access request object) has access to the
159 * ACO (access control object).
160 *
161 * @param string $aro ARO
162 * @param string $aco ACO
163 * @param string $action Action
164 * @return bool true if access is granted, false otherwise
165 */
166	public function check($aro, $aco, $action = "*") {
167		$allow = $this->options['policy'];
168		$prioritizedAros = $this->Aro->roles($aro);
169
170		if ($action && $action !== "*") {
171			$aco .= '/' . $action;
172		}
173
174		$path = $this->Aco->path($aco);
175
176		if (empty($path)) {
177			return $allow;
178		}
179
180		foreach ($path as $node) {
181			foreach ($prioritizedAros as $aros) {
182				if (!empty($node['allow'])) {
183					$allow = $allow || count(array_intersect($node['allow'], $aros));
184				}
185
186				if (!empty($node['deny'])) {
187					$allow = $allow && !count(array_intersect($node['deny'], $aros));
188				}
189			}
190		}
191
192		return $allow;
193	}
194
195}
196
197/**
198 * Access Control Object
199 */
200class PhpAco {
201
202/**
203 * holds internal ACO representation
204 *
205 * @var array
206 */
207	protected $_tree = array();
208
209/**
210 * map modifiers for ACO paths to their respective PCRE pattern
211 *
212 * @var array
213 */
214	public static $modifiers = array(
215		'*' => '.*',
216	);
217
218/**
219 * Constructor
220 *
221 * @param array $rules Rules array
222 */
223	public function __construct(array $rules = array()) {
224		foreach (array('allow', 'deny') as $type) {
225			if (empty($rules[$type])) {
226				$rules[$type] = array();
227			}
228		}
229
230		$this->build($rules['allow'], $rules['deny']);
231	}
232
233/**
234 * return path to the requested ACO with allow and deny rules attached on each level
235 *
236 * @param string $aco ACO string
237 * @return array
238 */
239	public function path($aco) {
240		$aco = $this->resolve($aco);
241		$path = array();
242		$level = 0;
243		$root = $this->_tree;
244		$stack = array(array($root, 0));
245
246		while (!empty($stack)) {
247			list($root, $level) = array_pop($stack);
248
249			if (empty($path[$level])) {
250				$path[$level] = array();
251			}
252
253			foreach ($root as $node => $elements) {
254				$pattern = '/^' . str_replace(array_keys(static::$modifiers), array_values(static::$modifiers), $node) . '$/';
255
256				if ($node == $aco[$level] || preg_match($pattern, $aco[$level])) {
257					// merge allow/denies with $path of current level
258					foreach (array('allow', 'deny') as $policy) {
259						if (!empty($elements[$policy])) {
260							if (empty($path[$level][$policy])) {
261								$path[$level][$policy] = array();
262							}
263							$path[$level][$policy] = array_merge($path[$level][$policy], $elements[$policy]);
264						}
265					}
266
267					// traverse
268					if (!empty($elements['children']) && isset($aco[$level + 1])) {
269						array_push($stack, array($elements['children'], $level + 1));
270					}
271				}
272			}
273		}
274
275		return $path;
276	}
277
278/**
279 * allow/deny ARO access to ARO
280 *
281 * @param string $aro ARO string
282 * @param string $aco ACO string
283 * @param string $action Action string
284 * @param string $type access type
285 * @return void
286 */
287	public function access($aro, $aco, $action, $type = 'deny') {
288		$aco = $this->resolve($aco);
289		$depth = count($aco);
290		$root = $this->_tree;
291		$tree = &$root;
292
293		foreach ($aco as $i => $node) {
294			if (!isset($tree[$node])) {
295				$tree[$node] = array(
296					'children' => array(),
297				);
298			}
299
300			if ($i < $depth - 1) {
301				$tree = &$tree[$node]['children'];
302			} else {
303				if (empty($tree[$node][$type])) {
304					$tree[$node][$type] = array();
305				}
306
307				$tree[$node][$type] = array_merge(is_array($aro) ? $aro : array($aro), $tree[$node][$type]);
308			}
309		}
310
311		$this->_tree = &$root;
312	}
313
314/**
315 * resolve given ACO string to a path
316 *
317 * @param string $aco ACO string
318 * @return array path
319 */
320	public function resolve($aco) {
321		if (is_array($aco)) {
322			return array_map('strtolower', $aco);
323		}
324
325		// strip multiple occurrences of '/'
326		$aco = preg_replace('#/+#', '/', $aco);
327		// make case insensitive
328		$aco = ltrim(strtolower($aco), '/');
329		return array_filter(array_map('trim', explode('/', $aco)));
330	}
331
332/**
333 * build a tree representation from the given allow/deny informations for ACO paths
334 *
335 * @param array $allow ACO allow rules
336 * @param array $deny ACO deny rules
337 * @return void
338 */
339	public function build(array $allow, array $deny = array()) {
340		$this->_tree = array();
341
342		foreach ($allow as $dotPath => $aros) {
343			if (is_string($aros)) {
344				$aros = array_map('trim', explode(',', $aros));
345			}
346
347			$this->access($aros, $dotPath, null, 'allow');
348		}
349
350		foreach ($deny as $dotPath => $aros) {
351			if (is_string($aros)) {
352				$aros = array_map('trim', explode(',', $aros));
353			}
354
355			$this->access($aros, $dotPath, null, 'deny');
356		}
357	}
358
359}
360
361/**
362 * Access Request Object
363 */
364class PhpAro {
365
366/**
367 * role to resolve to when a provided ARO is not listed in
368 * the internal tree
369 *
370 * @var string
371 */
372	const DEFAULT_ROLE = 'Role/default';
373
374/**
375 * map external identifiers. E.g. if
376 *
377 * array('User' => array('username' => 'jeff', 'role' => 'editor'))
378 *
379 * is passed as an ARO to one of the methods of AclComponent, PhpAcl
380 * will check if it can be resolved to an User or a Role defined in the
381 * configuration file.
382 *
383 * @var array
384 * @see app/Config/acl.php
385 */
386	public $map = array(
387		'User' => 'User/username',
388		'Role' => 'User/role',
389	);
390
391/**
392 * aliases to map
393 *
394 * @var array
395 */
396	public $aliases = array();
397
398/**
399 * internal ARO representation
400 *
401 * @var array
402 */
403	protected $_tree = array();
404
405/**
406 * Constructor
407 *
408 * @param array $aro The aro data
409 * @param array $map The identifier mappings
410 * @param array $aliases The aliases to map.
411 */
412	public function __construct(array $aro = array(), array $map = array(), array $aliases = array()) {
413		if (!empty($map)) {
414			$this->map = $map;
415		}
416
417		$this->aliases = $aliases;
418		$this->build($aro);
419	}
420
421/**
422 * From the perspective of the given ARO, walk down the tree and
423 * collect all inherited AROs levelwise such that AROs from different
424 * branches with equal distance to the requested ARO will be collected at the same
425 * index. The resulting array will contain a prioritized list of (list of) roles ordered from
426 * the most distant AROs to the requested one itself.
427 *
428 * @param string|array $aro An ARO identifier
429 * @return array prioritized AROs
430 */
431	public function roles($aro) {
432		$aros = array();
433		$aro = $this->resolve($aro);
434		$stack = array(array($aro, 0));
435
436		while (!empty($stack)) {
437			list($element, $depth) = array_pop($stack);
438			$aros[$depth][] = $element;
439
440			foreach ($this->_tree as $node => $children) {
441				if (in_array($element, $children)) {
442					array_push($stack, array($node, $depth + 1));
443				}
444			}
445		}
446
447		return array_reverse($aros);
448	}
449
450/**
451 * resolve an ARO identifier to an internal ARO string using
452 * the internal mapping information.
453 *
454 * @param string|array $aro ARO identifier (User.jeff, array('User' => ...), etc)
455 * @return string internal aro string (e.g. User/jeff, Role/default)
456 */
457	public function resolve($aro) {
458		foreach ($this->map as $aroGroup => $map) {
459			list ($model, $field) = explode('/', $map, 2);
460			$mapped = '';
461
462			if (is_array($aro)) {
463				if (isset($aro['model']) && isset($aro['foreign_key']) && $aro['model'] === $aroGroup) {
464					$mapped = $aroGroup . '/' . $aro['foreign_key'];
465				} elseif (isset($aro[$model][$field])) {
466					$mapped = $aroGroup . '/' . $aro[$model][$field];
467				} elseif (isset($aro[$field])) {
468					$mapped = $aroGroup . '/' . $aro[$field];
469				}
470			} elseif (is_string($aro)) {
471				$aro = ltrim($aro, '/');
472
473				if (strpos($aro, '/') === false) {
474					$mapped = $aroGroup . '/' . $aro;
475				} else {
476					list($aroModel, $aroValue) = explode('/', $aro, 2);
477
478					$aroModel = Inflector::camelize($aroModel);
479
480					if ($aroModel === $model || $aroModel === $aroGroup) {
481						$mapped = $aroGroup . '/' . $aroValue;
482					}
483				}
484			}
485
486			if (isset($this->_tree[$mapped])) {
487				return $mapped;
488			}
489
490			// is there a matching alias defined (e.g. Role/1 => Role/admin)?
491			if (!empty($this->aliases[$mapped])) {
492				return $this->aliases[$mapped];
493			}
494		}
495		return static::DEFAULT_ROLE;
496	}
497
498/**
499 * adds a new ARO to the tree
500 *
501 * @param array $aro one or more ARO records
502 * @return void
503 */
504	public function addRole(array $aro) {
505		foreach ($aro as $role => $inheritedRoles) {
506			if (!isset($this->_tree[$role])) {
507				$this->_tree[$role] = array();
508			}
509
510			if (!empty($inheritedRoles)) {
511				if (is_string($inheritedRoles)) {
512					$inheritedRoles = array_map('trim', explode(',', $inheritedRoles));
513				}
514
515				foreach ($inheritedRoles as $dependency) {
516					// detect cycles
517					$roles = $this->roles($dependency);
518
519					if (in_array($role, Hash::flatten($roles))) {
520						$path = '';
521
522						foreach ($roles as $roleDependencies) {
523							$path .= implode('|', (array)$roleDependencies) . ' -> ';
524						}
525
526						trigger_error(__d('cake_dev', 'cycle detected when inheriting %s from %s. Path: %s', $role, $dependency, $path . $role));
527						continue;
528					}
529
530					if (!isset($this->_tree[$dependency])) {
531						$this->_tree[$dependency] = array();
532					}
533
534					$this->_tree[$dependency][] = $role;
535				}
536			}
537		}
538	}
539
540/**
541 * adds one or more aliases to the internal map. Overwrites existing entries.
542 *
543 * @param array $alias alias from => to (e.g. Role/13 -> Role/editor)
544 * @return void
545 */
546	public function addAlias(array $alias) {
547		$this->aliases = $alias + $this->aliases;
548	}
549
550/**
551 * build an ARO tree structure for internal processing
552 *
553 * @param array $aros array of AROs as key and their inherited AROs as values
554 * @return void
555 */
556	public function build(array $aros) {
557		$this->_tree = array();
558		$this->addRole($aros);
559	}
560
561}
562