PageRenderTime 44ms CodeModel.GetById 17ms RepoModel.GetById 1ms app.codeStats 0ms

/inc/acl.php

https://bitbucket.org/yoander/mtrack
PHP | 650 lines | 508 code | 59 blank | 83 comment | 79 complexity | 3f4cc92119e693363fd429b4ac071ae0 MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0
  1. <?php # vim:ts=2:sw=2:et:
  2. /* For licensing and copyright terms, see the file named LICENSE */
  3. class MTrackAuthorizationException extends Exception {
  4. public $rights;
  5. function __construct($msg, $rights) {
  6. parent::__construct($msg);
  7. $this->rights = $rights;
  8. }
  9. }
  10. /* Each object in the system has an identifier, like 'ticket:XYZ' that
  11. * indicates the type of object as well as its own identifier.
  12. *
  13. * Each object may also have a discressionary access control list (DACL) that
  14. * describes what actions members of particular roles are permitted to
  15. * the object. The DACL can apply either to the object itself, or be
  16. * a cascading (or inherited) entry that applies only to objects that are
  17. * children of the object in question.
  18. *
  19. * When determining whether access is permitted, the DACL is walked from
  20. * the object being accessed up to the root. As soon as the allow/deny
  21. * status us known for a specific (role, action) combination, the search
  22. * stops.
  23. *
  24. * DACL entries can be explicitly ordered so that a particular user from
  25. * a group can be excepted from a blanket allow/deny rule that follows.
  26. */
  27. class MTrackACL {
  28. static $cache = array();
  29. static public function addRootObjectAndRoles($name) {
  30. /* construct some roles that encapsulate read, modify, write */
  31. $rolebase = preg_replace('/s$/', '', $name);
  32. $ents = array(
  33. array("{$rolebase}Viewer", "read", true),
  34. array("{$rolebase}Editor", "read", true),
  35. array("{$rolebase}Editor", "modify", true),
  36. array("{$rolebase}Creator", "read", true),
  37. array("{$rolebase}Creator", "modify", true),
  38. array("{$rolebase}Creator", "create", true),
  39. array("{$rolebase}Creator", "delete", true),
  40. );
  41. MTrackACL::setACL($name, true, $ents);
  42. $ents = array(
  43. array("{$rolebase}Viewer", "read", true),
  44. array("{$rolebase}Editor", "read", true),
  45. array("{$rolebase}Creator", "read", true),
  46. array("{$rolebase}Creator", "modify", true),
  47. array("{$rolebase}Creator", "create", true),
  48. array("{$rolebase}Creator", "delete", true),
  49. );
  50. MTrackACL::setACL($name, false, $ents);
  51. }
  52. /* functions that we can call to determine ancestry */
  53. static $genealogist = array();
  54. static public function registerAncestry($objtype, $func) {
  55. self::$genealogist[$objtype] = $func;
  56. }
  57. /* returns the objectid path that leads from the root to the specified
  58. * object, including the object itself as the last element */
  59. static public function getParentPath($objectid, $steps = -1)
  60. {
  61. $path = array();
  62. while (strlen($objectid)) {
  63. if ($steps != -1 && $steps-- == 0) {
  64. break;
  65. }
  66. $path[] = $objectid;
  67. if (isset(self::$genealogist[$objectid])) {
  68. $func = self::$genealogist[$objectid];
  69. if (is_string($func)) {
  70. $parent = $func;
  71. } else {
  72. $parent = call_user_func($func, $objectid);
  73. }
  74. if (!$parent) break;
  75. $objectid = $parent;
  76. continue;
  77. }
  78. if (preg_match("/^(.*):([^:]+)$/", $objectid, $M)) {
  79. $class = $M[1];
  80. if (isset(self::$genealogist[$class])) {
  81. $func = self::$genealogist[$class];
  82. if (is_string($func)) {
  83. $parent = $func;
  84. } else {
  85. $parent = call_user_func($func, $objectid);
  86. }
  87. if (!$parent) break;
  88. $objectid = $parent;
  89. continue;
  90. }
  91. $objectid = $class;
  92. continue;
  93. }
  94. break;
  95. }
  96. return $path;
  97. }
  98. /* computes the overall ACL as it applies to someone that belongs to the
  99. * indicated set of roles. */
  100. static public function computeACL($objectid, $role_list)
  101. {
  102. $key = $objectid . '~' . join('~', $role_list);
  103. if (isset(self::$cache[$key])) {
  104. return self::$cache[$key];
  105. }
  106. /* we calculate the path to the object from its parent, and pull
  107. * out all ACL entries on those objects that match the provided
  108. * role list, ordering from the object up to the root.
  109. */
  110. $rlist = array();
  111. $db = MTrackDB::get();
  112. foreach ($role_list as $r => $rname) {
  113. $rlist[] = $db->quote($r);
  114. }
  115. // Always want the special wildcard 'everybody' entry
  116. $rlist[] = $db->quote('*');
  117. $role_list = join(',', $rlist);
  118. $actions = array();
  119. $oidlist = array();
  120. $path = self::getParentPath($objectid);
  121. foreach ($path as $oid) {
  122. $oidlist[] = $db->quote($oid);
  123. }
  124. $oidlist = join(',', $oidlist);
  125. $sql = <<<SQL
  126. select objectid as id, action, cascade, allow
  127. from
  128. acl
  129. where
  130. role in ($role_list)
  131. and objectid in ($oidlist)
  132. order by
  133. cascade desc,
  134. seq asc
  135. SQL
  136. ;
  137. # echo $sql;
  138. # Collect the results and index by objectid; we'll need to walk over
  139. # them in path order
  140. $res_by_oid = array();
  141. foreach (MTrackDB::q($sql)->fetchAll(PDO::FETCH_ASSOC) as $row) {
  142. $res_by_oid[$row['id']][] = $row;
  143. }
  144. foreach ($path as $oid) {
  145. if (!isset($res_by_oid[$oid])) continue;
  146. foreach ($res_by_oid[$oid] as $row) {
  147. if ($row['id'] == $objectid && $row['cascade']) {
  148. /* ignore items below the object of interest */
  149. continue;
  150. }
  151. if (!isset($actions[$row['action']])) {
  152. $actions[$row['action']] = $row['allow'] ? true : false;
  153. }
  154. }
  155. }
  156. self::$cache[$key] = $actions;
  157. return $actions;
  158. }
  159. /* Entries is an array of [role, action, allow] tuples in the order
  160. * that they should be checked.
  161. * If cascade is true, then these entries will replace the
  162. * inheritable set, otherwise they will replace the entries
  163. * on the object.
  164. * If entries is an empty array, or not an array, then the appropriate
  165. * ACL will be removed.
  166. */
  167. static public function setACL($object, $cascade, $entries)
  168. {
  169. self::$cache = array();
  170. $cascade = (int)$cascade;
  171. MTrackDB::q('delete from acl where objectid = ? and cascade = ?',
  172. $object, $cascade);
  173. $seq = 0;
  174. if (is_array($entries)) {
  175. foreach ($entries as $ent) {
  176. if (isset($ent['role'])) {
  177. $role = $ent['role'];
  178. $action = $ent['action'];
  179. $allow = $ent['allow'];
  180. } else {
  181. list($role, $action, $allow) = $ent;
  182. }
  183. MTrackDB::q('insert into acl (objectid, cascade, seq, role,
  184. action, allow) values (?, ?, ?, ?, ?, ?)',
  185. $object, $cascade, $seq++,
  186. $role, $action, (int)$allow);
  187. }
  188. }
  189. }
  190. /* Obtains the ACL entries for the specified object.
  191. * If cascade is true, it will return the inheritable ACL.
  192. */
  193. static public function getACL($object, $cascade)
  194. {
  195. return MTrackDB::q('select role, action, allow from acl
  196. where objectid = ? and cascade = ? order by seq',
  197. $object, (int)$cascade)->fetchAll(PDO::FETCH_ASSOC);
  198. }
  199. static public function hasAllRights($object, $rights)
  200. {
  201. if (MTrackAuth::getUserClass() == 'admin') {
  202. return true;
  203. }
  204. if (!is_array($rights)) {
  205. $rights = array($rights);
  206. }
  207. if (!count($rights)) {
  208. throw new Exception("can't have all of no rights");
  209. }
  210. $acl = self::computeACL($object, MTrackAuth::getGroups());
  211. # echo "ACL: $object<br>";
  212. # var_dump($rights);
  213. # echo "<br>";
  214. # var_dump($acl);
  215. # echo "<br>";
  216. foreach ($rights as $action) {
  217. if (!isset($acl[$action]) || !$acl[$action]) {
  218. return false;
  219. }
  220. }
  221. return true;
  222. }
  223. static public function hasAnyRights($object, $rights)
  224. {
  225. if (MTrackAuth::getUserClass() == 'admin') {
  226. return true;
  227. }
  228. if (!is_array($rights)) {
  229. $rights = array($rights);
  230. }
  231. if (!count($rights)) {
  232. throw new Exception("can't have any of no rights");
  233. }
  234. $acl = self::computeACL($object, MTrackAuth::getGroups());
  235. $ok = false;
  236. foreach ($rights as $action) {
  237. if (isset($acl[$action]) && $acl[$action]) {
  238. $ok = true;
  239. }
  240. }
  241. return $ok;
  242. }
  243. static public function requireAnyRights($object, $rights)
  244. {
  245. if (!self::hasAnyRights($object, $rights)) {
  246. throw new MTrackAuthorizationException("Not authorized", $rights);
  247. }
  248. }
  249. static public function requireAllRights($object, $rights)
  250. {
  251. if (!self::hasAllRights($object, $rights)) {
  252. throw new MTrackAuthorizationException("Not authorized", $rights);
  253. }
  254. }
  255. /* computes the ACL object suitable for including in the JSON
  256. * object returned (and settable) via the REST API.
  257. * This is used by the various views to create ACL editors.
  258. */
  259. static public function computeACLObject($objectid) {
  260. $o = new stdclass;
  261. $o->acl = array();
  262. $o->inherited = array();
  263. foreach (self::getACL($objectid, 0) as $ent) {
  264. $o->acl[] = array($ent['role'], $ent['action'], (int)$ent['allow']);
  265. }
  266. $path = self::getParentPath($objectid, 2);
  267. if (count($path) == 2) {
  268. foreach (self::getACL($path[1], 1) as $ent) {
  269. $o->inherited[] = array($ent['role'], $ent['action'], (int)$ent['allow']);
  270. }
  271. }
  272. return $o;
  273. }
  274. static function rest_roles($method, $uri, $captures) {
  275. MTrackAPI::checkAllowed($method, 'GET');
  276. /* avoid leaking information to anonymous users */
  277. $me = MTrackAuth::whoami();
  278. if ($me == 'anonymous' || MTrackAuth::getUserClass() == 'anonymous') {
  279. $o = new stdclass;
  280. return $o;
  281. }
  282. $groups = MTrackAuth::enumGroups();
  283. /* merge in users */
  284. foreach (MTrackDB::q(
  285. 'select userid, fullname from userinfo where active = 1')
  286. ->fetchAll() as $row) {
  287. if (isset($groups[$row[0]])) continue;
  288. if (strlen($row[1])) {
  289. $disp = "$row[0] - $row[1]";
  290. } else {
  291. $disp = $row[0];
  292. }
  293. $groups[$row[0]] = $disp;
  294. }
  295. if (!isset($groups['*'])) {
  296. $groups['*'] = '(Everybody)';
  297. }
  298. return $groups;
  299. }
  300. /* helper for generating an ACL editor.
  301. * As parameters, takes an objectid indicating the object being edited,
  302. * and an action map which breaks down tasks into groups.
  303. * Each group consists of a set of permissions, starting with the least
  304. * permissive in that group, through to most permissive.
  305. * Each group will be rendered as a select box, and a synthetic "none"
  306. * entry will be generated for the group that explicitly excludes each
  307. * of the other permission levels in that group.
  308. *
  309. * The form element that is generated will contain a JSON representation
  310. * of an "ents" array that can be passed to setACL().
  311. */
  312. static public function renderACLForm($formprefix, $objectid, $map) {
  313. $ident = preg_replace("/[^a-zA-Z]/", '', $formprefix);
  314. $entities = array();
  315. $groups = MTrackAuth::enumGroups();
  316. /* merge in users */
  317. foreach (MTrackDB::q('select userid, fullname from userinfo where active = 1')
  318. ->fetchAll() as $row) {
  319. if (isset($groups[$row[0]])) continue;
  320. if (strlen($row[1])) {
  321. $disp = "$row[0] - $row[1]";
  322. } else {
  323. $disp = $row[0];
  324. }
  325. $groups[$row[0]] = $disp;
  326. }
  327. if (!isset($groups['*'])) {
  328. $groups['*'] = '(Everybody)';
  329. }
  330. // Encode the map into an object
  331. $mobj = new stdClass;
  332. $reng = array();
  333. $rank = array();
  334. foreach ($map as $group => $actions) {
  335. // Each subsequent action in a group implies access greater than
  336. // the item that preceeds it
  337. $all_perms = array_keys($actions);
  338. $prohibit = array();
  339. foreach ($all_perms as $p) {
  340. $prohibit[$p] = "-$p";
  341. }
  342. $none = join('|', $prohibit);
  343. $a = array();
  344. $a[] = array($none, 'None');
  345. $accum = array();
  346. $i = 0;
  347. foreach ($actions as $perm => $label) {
  348. $accum[] = $perm;
  349. unset($prohibit[$perm]);
  350. $p = join('|', array_merge($accum, $prohibit));
  351. $a[] = array($p, $label);
  352. /* save this for reverse engineering the right group in the current
  353. * ACL data */
  354. $reng[$perm] = $group;
  355. $rank[$group][$perm] = $i++;
  356. }
  357. $mobj->{$group} = $a;
  358. }
  359. $mobj = json_encode($mobj);
  360. $roledefs = new stdclass;
  361. $acl = self::getACL($objectid, 0);
  362. foreach ($acl as $ent) {
  363. $group = $reng[$ent['action']];
  364. $act = ($ent['allow'] ? '' : '-') . $ent['action'];
  365. $roledefs->{$ent['role']}->{$group}[] = $act;
  366. if (!isset($groups[$ent['role']])) {
  367. $groups[$ent['role']] = $ent['role'];
  368. }
  369. }
  370. $roledefs = json_encode($roledefs);
  371. /* let's figure out the inherited ACL */
  372. $path = self::getParentPath($objectid, 2);
  373. $inherited = new stdclass;
  374. if (count($path) == 2) {
  375. $pacl = self::getACL($path[1], 1);
  376. foreach ($pacl as $ent) {
  377. // Not relevant per the specified action map
  378. if (!isset($reng[$ent['action']])) continue;
  379. $group = $reng[$ent['action']];
  380. $act = ($ent['allow'] ? '' : '-') . $ent['action'];
  381. $inherited->{$ent['role']}->{$group}[] = $act;
  382. if (!isset($groups[$ent['role']])) {
  383. $groups[$ent['role']] = $ent['role'];
  384. }
  385. }
  386. // Inheritable set may not be specified directly in
  387. // the same terms as the action_map, so we need to infer it
  388. // Example: we may have read|modify leaving delete unspecified.
  389. // We treat this as read|modify|-delete
  390. foreach ($inherited as $role => $agroups) {
  391. foreach ($agroups as $group => $actions) {
  392. $highest = null;
  393. foreach ($actions as $act) {
  394. if ($act[0] == '-') continue;
  395. if ($highest === null || $rank[$group][$act] > $highest) {
  396. $highest = $rank[$group][$act];
  397. $hact = $act;
  398. }
  399. }
  400. if ($highest === null) {
  401. unset($inherited->{$role}->{$group});
  402. continue;
  403. }
  404. // Compute full value
  405. $comp = array();
  406. foreach ($rank[$group] as $act => $i) {
  407. if ($i <= $highest) {
  408. $comp[] = $act;
  409. } else {
  410. $comp[] = "-$act";
  411. }
  412. }
  413. $inherited->{$role}->{$group} = join('|', $comp);
  414. }
  415. }
  416. }
  417. $inherited = json_encode($inherited);
  418. //var_dump($acl);
  419. $groups = json_encode($groups);
  420. $cat_order = json_encode(array_keys($map));
  421. echo <<<HTML
  422. <div class='permissioneditor'>
  423. <p>
  424. <b>Permissions</b>
  425. </p>
  426. <p>
  427. <em>Select "Add" to define permissions for an entity.
  428. The first matching permission is taken as definitive,
  429. so if a given user belongs to multiple groups and matches
  430. multiple permission rows, the first is taken. You may
  431. drag to re-order permissions.
  432. </em>
  433. </p>
  434. <p>
  435. <em>Permissions inherited from the parent of this object are
  436. shown as non-editable entries at the top of the list. You may
  437. override them by adding your own explicit entry.</em>
  438. </p>
  439. <br>
  440. <input type='hidden' id='$formprefix' name='$formprefix'>
  441. <table id='acl$ident'>
  442. <thead>
  443. <tr>
  444. <th>Entity</th>
  445. </tr>
  446. </thead>
  447. <tbody></tbody>
  448. </table>
  449. <script>
  450. $(document).ready(function () {
  451. var cat_order = $cat_order;
  452. var groups = $groups;
  453. var roledefs = $roledefs;
  454. var inherited = $inherited;
  455. var mobj = $mobj;
  456. var disp = $('#acl$ident');
  457. var tbody = $('tbody', disp);
  458. var sel;
  459. var field = $('#$formprefix');
  460. function add_acl_entity(role)
  461. {
  462. // Delete role from select box
  463. $('option', sel).each(function () {
  464. if ($(this).attr('value') == role) {
  465. $(this).remove();
  466. }
  467. });
  468. // Create a row for this role
  469. var sp = $('<tr style="cursor:pointer"/>');
  470. sp.append(
  471. $('<td/>')
  472. .html('<span style="position: absolute; margin-left: -1.3em" class="ui-icon ui-icon-arrowthick-2-n-s"></span>')
  473. .append(groups[role])
  474. );
  475. tbody.append(sp);
  476. for (var gi in cat_order) {
  477. var group = cat_order[gi];
  478. var gsel = $('<select/>');
  479. gsel.data('acl.role', role);
  480. var data = mobj[group];
  481. for (var i in data) {
  482. var a = data[i];
  483. gsel.append(
  484. $('<option/>')
  485. .attr('value', a[0])
  486. .text(a[1])
  487. );
  488. }
  489. if (roledefs[role]) {
  490. gsel.val(roledefs[role][group].join('|'));
  491. }
  492. sp.append(
  493. $('<td/>')
  494. .append(gsel)
  495. );
  496. }
  497. var b = $('<button>x</button>');
  498. sp.append(
  499. $('<td/>')
  500. .append(b)
  501. );
  502. b.click(function () {
  503. sp.remove();
  504. sel.append(
  505. $('<option/>')
  506. .attr('value', role)
  507. .text(groups[role])
  508. );
  509. });
  510. }
  511. var tr = $('thead tr', disp);
  512. // Add columns for action groups
  513. for (var gi in cat_order) {
  514. var group = cat_order[gi];
  515. tr.append($('<th/>').text(group));
  516. }
  517. // Add fixed inherited rows
  518. var thead = $('thead', disp);
  519. for (var role in inherited) {
  520. tr = $('<tr class="inheritedacl"/>');
  521. tr.append($('<td/>').text(groups[role]));
  522. for (var group in mobj) {
  523. var d = inherited[role][group];
  524. if (d) {
  525. // Good old fashioned look up (we don't have this hashed)
  526. for (var i in mobj[group]) {
  527. var ent = mobj[group][i];
  528. if (ent[0] == d) {
  529. d = ent[1];
  530. break;
  531. }
  532. }
  533. tr.append($('<td/>').text(d));
  534. } else {
  535. tr.append($('<td>(Not Specified)</td>'));
  536. }
  537. }
  538. thead.append(tr);
  539. }
  540. sel = $('<select/>');
  541. sel.append(
  542. $('<option/>')
  543. .text('Add...')
  544. );
  545. for (var i in groups) {
  546. var g = groups[i];
  547. sel.append(
  548. $('<option/>')
  549. .attr('value', i)
  550. .text(g)
  551. );
  552. }
  553. disp.append(sel);
  554. /* make the tbody sortable. Note that we append the "Add..." to the table,
  555. * not the tbody, so that we don't allow dragging it around */
  556. tbody.sortable();
  557. for (var role in roledefs) {
  558. add_acl_entity(role);
  559. }
  560. sel.change(function () {
  561. var v = sel.val();
  562. if (v && v.length) {
  563. add_acl_entity(v);
  564. }
  565. });
  566. field.parents('form:first').submit(function () {
  567. var acl = [];
  568. $('select', tbody).each(function () {
  569. var role = $(this).data('acl.role');
  570. var val = $(this).val().split('|');
  571. for (var i in val) {
  572. var action = val[i];
  573. var allow = 1;
  574. if (action.substring(0, 1) == '-') {
  575. allow = 0;
  576. action = action.substring(1);
  577. }
  578. acl.push([role, action, allow]);
  579. }
  580. });
  581. field.val(JSON.stringify(acl));
  582. });
  583. });
  584. </script>
  585. </div>
  586. HTML;
  587. }
  588. }
  589. MTrackAPI::register('/acl/roles', 'MTrackACL::rest_roles');