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