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