tuples. It would be inefficient to check * these tuples every time we want to do a lookup, so we use these intents to create an entire * table of permissions for easy lookup in the Access_Cache_Model. There's a 1:1 mapping * between Item_Model and Access_Cache_Model entries. * * o For efficiency, we create columns in Access_Intent_Model and Access_Cache_Model for each of * the possible Group_Model and Permission_Model combinations. This may lead to performance * issues for very large Gallery installs, but for small to medium sized ones (5-10 groups, 5-10 * permissions) it's especially efficient because there's a single field value for each * group/permission/item combination. * * o For efficiency, we store the cache columns for view permissions directly in the Item_Model. * This means that we can filter items by group/permission combination without doing any table * joins making for an especially efficient permission check at the expense of having to * maintain extra columns for each item. * * o If at any time the Access_Cache_Model becomes invalid, we can rebuild the entire table from * the Access_Intent_Model */ class access_Core { const DENY = "0"; const ALLOW = "1"; const INHERIT = null; // access_intent const UNKNOWN = null; // cache (access_cache, items) /** * Does the active user have this permission on this item? * * @param string $perm_name * @param Item_Model $item * @return boolean */ static function can($perm_name, $item) { return access::user_can(identity::active_user(), $perm_name, $item); } /** * Does the user have this permission on this item? * * @param User_Model $user * @param string $perm_name * @param Item_Model $item * @return boolean */ static function user_can($user, $perm_name, $item) { if (!$item->loaded()) { return false; } if ($user->admin) { return true; } // Use the nearest parent album (including the current item) so that we take advantage // of the cache when checking many items in a single album. $id = ($item->type == "album") ? $item->id : $item->parent_id; $resource = $perm_name == "view" ? $item : model_cache::get("access_cache", $id, "item_id"); foreach ($user->groups() as $group) { if ($resource->__get("{$perm_name}_{$group->id}") === access::ALLOW) { return true; } } return false; } /** * If the active user does not have this permission, failed with an access::forbidden(). * * @param string $perm_name * @param Item_Model $item * @return boolean */ static function required($perm_name, $item) { if (!access::can($perm_name, $item)) { if ($perm_name == "view") { // Treat as if the item didn't exist, don't leak any information. throw new Kohana_404_Exception(); } else { access::forbidden(); } } } /** * Does this group have this permission on this item? * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @return boolean */ static function group_can($group, $perm_name, $item) { // Use the nearest parent album (including the current item) so that we take advantage // of the cache when checking many items in a single album. $id = ($item->type == "album") ? $item->id : $item->parent_id; $resource = $perm_name == "view" ? $item : model_cache::get("access_cache", $id, "item_id"); return $resource->__get("{$perm_name}_{$group->id}") === access::ALLOW; } /** * Return this group's intent for this permission on this item. * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @return boolean access::ALLOW, access::DENY or access::INHERIT (null) for no intent */ static function group_intent($group, $perm_name, $item) { $intent = model_cache::get("access_intent", $item->id, "item_id"); return $intent->__get("{$perm_name}_{$group->id}"); } /** * Is the permission on this item locked by a parent? If so return the nearest parent that * locks it. * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @return ORM_Model item that locks this one */ static function locked_by($group, $perm_name, $item) { if ($perm_name != "view") { return null; } // For view permissions, if any parent is access::DENY, then those parents lock this one. // Return $lock = ORM::factory("item") ->where("left_ptr", "<=", $item->left_ptr) ->where("right_ptr", ">=", $item->right_ptr) ->where("items.id", "<>", $item->id) ->join("access_intents", "items.id", "access_intents.item_id") ->where("access_intents.view_$group->id", "=", access::DENY) ->order_by("level", "DESC") ->limit(1) ->find(); if ($lock->loaded()) { return $lock; } else { return null; } } /** * Terminate immediately with an HTTP 403 Forbidden response. */ static function forbidden() { throw new Kohana_Exception("@todo FORBIDDEN", null, 403); } /** * Internal method to set a permission * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @param boolean $value */ private static function _set(Group_Definition $group, $perm_name, $album, $value) { if (!($group instanceof Group_Definition)) { throw new Exception("@todo PERMISSIONS_ONLY_WORK_ON_GROUPS"); } if (!$album->loaded()) { throw new Exception("@todo INVALID_ALBUM $album->id"); } if (!$album->is_album()) { throw new Exception("@todo INVALID_ALBUM_TYPE not an album"); } $access = model_cache::get("access_intent", $album->id, "item_id"); $access->__set("{$perm_name}_{$group->id}", $value); $access->save(); if ($perm_name == "view") { self::_update_access_view_cache($group, $album); } else { self::_update_access_non_view_cache($group, $perm_name, $album); } access::update_htaccess_files($album, $group, $perm_name, $value); model_cache::clear(); } /** * Allow a group to have a permission on an item. * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item */ static function allow($group, $perm_name, $item) { self::_set($group, $perm_name, $item, self::ALLOW); } /** * Deny a group the given permission on an item. * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item */ static function deny($group, $perm_name, $item) { self::_set($group, $perm_name, $item, self::DENY); } /** * Unset the given permission for this item and use inherited values * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item */ static function reset($group, $perm_name, $item) { if ($item->id == 1) { throw new Exception("@todo CANT_RESET_ROOT_PERMISSION"); } self::_set($group, $perm_name, $item, self::INHERIT); } /** * Recalculate the permissions for an album's hierarchy. */ static function recalculate_album_permissions($album) { foreach (self::_get_all_groups() as $group) { foreach (ORM::factory("permission")->find_all() as $perm) { if ($perm->name == "view") { self::_update_access_view_cache($group, $album); } else { self::_update_access_non_view_cache($group, $perm->name, $album); } } } model_cache::clear(); } /** * Recalculate the permissions for a single photo. */ static function recalculate_photo_permissions($photo) { $parent = $photo->parent(); $parent_access_cache = ORM::factory("access_cache")->where("item_id", "=", $parent->id)->find(); $photo_access_cache = ORM::factory("access_cache")->where("item_id", "=", $photo->id)->find(); foreach (self::_get_all_groups() as $group) { foreach (ORM::factory("permission")->find_all() as $perm) { $field = "{$perm->name}_{$group->id}"; if ($perm->name == "view") { $photo->$field = $parent->$field; } else { $photo_access_cache->$field = $parent_access_cache->$field; } } } $photo_access_cache->save(); $photo->save(); model_cache::clear(); } /** * Register a permission so that modules can use it. * * @param string $name The internal name for for this permission * @param string $display_name The internationalized version of the displayable name * @return void */ static function register_permission($name, $display_name) { $permission = ORM::factory("permission", $name); if ($permission->loaded()) { throw new Exception("@todo PERMISSION_ALREADY_EXISTS $name"); } $permission->name = $name; $permission->display_name = $display_name; $permission->save(); foreach (self::_get_all_groups() as $group) { self::_add_columns($name, $group); } } /** * Delete a permission. * * @param string $perm_name * @return void */ static function delete_permission($name) { foreach (self::_get_all_groups() as $group) { self::_drop_columns($name, $group); } $permission = ORM::factory("permission")->where("name", "=", $name)->find(); if ($permission->loaded()) { $permission->delete(); } } /** * Add the appropriate columns for a new group * * @param Group_Model $group * @return void */ static function add_group($group) { foreach (ORM::factory("permission")->find_all() as $perm) { self::_add_columns($perm->name, $group); } } /** * Remove a group's permission columns (usually when it's deleted) * * @param Group_Model $group * @return void */ static function delete_group($group) { foreach (ORM::factory("permission")->find_all() as $perm) { self::_drop_columns($perm->name, $group); } } /** * Add new access rows when a new item is added. * * @param Item_Model $item * @return void */ static function add_item($item) { $access_intent = ORM::factory("access_intent", $item->id); if ($access_intent->loaded()) { throw new Exception("@todo ITEM_ALREADY_ADDED $item->id"); } $access_intent = ORM::factory("access_intent"); $access_intent->item_id = $item->id; $access_intent->save(); // Create a new access cache entry and copy the parents values. $access_cache = ORM::factory("access_cache"); $access_cache->item_id = $item->id; if ($item->id != 1) { $parent_access_cache = ORM::factory("access_cache")->where("item_id", "=", $item->parent()->id)->find(); foreach (self::_get_all_groups() as $group) { foreach (ORM::factory("permission")->find_all() as $perm) { $field = "{$perm->name}_{$group->id}"; if ($perm->name == "view") { $item->$field = $item->parent()->$field; } else { $access_cache->$field = $parent_access_cache->$field; } } } } $item->save(); $access_cache->save(); } /** * Delete appropriate access rows when an item is deleted. * * @param Item_Model $item * @return void */ static function delete_item($item) { ORM::factory("access_intent")->where("item_id", "=", $item->id)->find()->delete(); ORM::factory("access_cache")->where("item_id", "=", $item->id)->find()->delete(); } /** * Verify our Cross Site Request Forgery token is valid, else throw an exception. */ static function verify_csrf() { $input = Input::instance(); if ($input->post("csrf", $input->get("csrf", null)) !== Session::instance()->get("csrf")) { access::forbidden(); } } /** * Get the Cross Site Request Forgery token for this session. * @return string */ static function csrf_token() { $session = Session::instance(); $csrf = $session->get("csrf"); if (empty($csrf)) { $csrf = random::hash(); $session->set("csrf", $csrf); } return $csrf; } /** * Generate an element containing the Cross Site Request Forgery token for this session. * @return string */ static function csrf_form_field() { return ""; } /** * Internal method to get all available groups. * * @return ORM_Iterator */ private static function _get_all_groups() { // When we build the gallery package, it's possible that there is no identity provider // installed yet. This is ok at packaging time, so work around it. if (module::is_active(module::get_var("gallery", "identity_provider", "user"))) { return identity::groups(); } else { return array(); } } /** * Internal method to remove Permission/Group columns * * @param Group_Model $group * @param string $perm_name * @return void */ private static function _drop_columns($perm_name, $group) { $field = "{$perm_name}_{$group->id}"; $cache_table = $perm_name == "view" ? "items" : "access_caches"; Database::instance()->query("ALTER TABLE {{$cache_table}} DROP `$field`"); Database::instance()->query("ALTER TABLE {access_intents} DROP `$field`"); model_cache::clear(); ORM::factory("access_intent")->clear_cache(); } /** * Internal method to add Permission/Group columns * * @param Group_Model $group * @param string $perm_name * @return void */ private static function _add_columns($perm_name, $group) { $field = "{$perm_name}_{$group->id}"; $cache_table = $perm_name == "view" ? "items" : "access_caches"; $not_null = $cache_table == "items" ? "" : "NOT NULL"; Database::instance()->query( "ALTER TABLE {{$cache_table}} ADD `$field` BINARY $not_null DEFAULT FALSE"); Database::instance()->query( "ALTER TABLE {access_intents} ADD `$field` BINARY DEFAULT NULL"); db::build() ->update("access_intents") ->set($field, access::DENY) ->where("item_id", "=", 1) ->execute(); model_cache::clear(); ORM::factory("access_intent")->clear_cache(); } /** * Update the Access_Cache model based on information from the Access_Intent model for view * permissions only. * * @todo: use database locking * * @param Group_Model $group * @param Item_Model $item * @return void */ private static function _update_access_view_cache($group, $item) { $access = ORM::factory("access_intent")->where("item_id", "=", $item->id)->find(); $field = "view_{$group->id}"; // With view permissions, deny values in the parent can override allow values in the child, // so start from the bottom of the tree and work upwards overlaying negative on top of // positive. // // If the item's intent is ALLOW or DEFAULT, it's possible that some ancestor has specified // DENY and this ALLOW cannot be obeyed. So in that case, back up the tree and find any // non-DEFAULT and non-ALLOW parent and propagate from there. If we can't find a matching // item, then its safe to propagate from here. if ($access->$field !== access::DENY) { $tmp_item = ORM::factory("item") ->where("left_ptr", "<", $item->left_ptr) ->where("right_ptr", ">", $item->right_ptr) ->join("access_intents", "access_intents.item_id", "items.id") ->where("access_intents.$field", "=", access::DENY) ->order_by("left_ptr", "DESC") ->limit(1) ->find(); if ($tmp_item->loaded()) { $item = $tmp_item; } } // We will have a problem if we're trying to change a DENY to an ALLOW because the // access_caches table will already contain DENY values and we won't be able to overwrite // them according the rule above. So mark every permission below this level as UNKNOWN so // that we can tell which permissions have been changed, and which ones need to be updated. db::build() ->update("items") ->set($field, access::UNKNOWN) ->where("left_ptr", ">=", $item->left_ptr) ->where("right_ptr", "<=", $item->right_ptr) ->execute(); $query = ORM::factory("access_intent") ->select(array("access_intents.$field", "items.left_ptr", "items.right_ptr", "items.id")) ->join("items", "items.id", "access_intents.item_id") ->where("left_ptr", ">=", $item->left_ptr) ->where("right_ptr", "<=", $item->right_ptr) ->where("type", "=", "album") ->where("access_intents.$field", "IS NOT", access::INHERIT) ->order_by("level", "DESC") ->find_all(); foreach ($query as $row) { if ($row->$field == access::ALLOW) { // Propagate ALLOW for any row that is still UNKNOWN. db::build() ->update("items") ->set($field, $row->$field) ->where($field, "IS", access::UNKNOWN) // UNKNOWN is NULL so we have to use IS ->where("left_ptr", ">=", $row->left_ptr) ->where("right_ptr", "<=", $row->right_ptr) ->execute(); } else if ($row->$field == access::DENY) { // DENY overwrites everything below it db::build() ->update("items") ->set($field, $row->$field) ->where("left_ptr", ">=", $row->left_ptr) ->where("right_ptr", "<=", $row->right_ptr) ->execute(); } } // Finally, if our intent is DEFAULT at this point it means that we were unable to find a // DENY parent in the hierarchy to propagate from. So we'll still have a UNKNOWN values in // the hierarchy, and all of those are safe to change to ALLOW. db::build() ->update("items") ->set($field, access::ALLOW) ->where($field, "IS", access::UNKNOWN) // UNKNOWN is NULL so we have to use IS ->where("left_ptr", ">=", $item->left_ptr) ->where("right_ptr", "<=", $item->right_ptr) ->execute(); } /** * Update the Access_Cache model based on information from the Access_Intent model for non-view * permissions. * * @todo: use database locking * * @param Group_Model $group * @param string $perm_name * @param Item_Model $item * @return void */ private static function _update_access_non_view_cache($group, $perm_name, $item) { $access = ORM::factory("access_intent")->where("item_id", "=", $item->id)->find(); $field = "{$perm_name}_{$group->id}"; // If the item's intent is DEFAULT, then we need to back up the chain to find the nearest // parent with an intent and propagate from there. // // @todo To optimize this, we wouldn't need to propagate from the parent, we could just // propagate from here with the parent's intent. if ($access->$field === access::INHERIT) { $tmp_item = ORM::factory("item") ->join("access_intents", "items.id", "access_intents.item_id") ->where("left_ptr", "<", $item->left_ptr) ->where("right_ptr", ">", $item->right_ptr) ->where($field, "IS NOT", access::UNKNOWN) // UNKNOWN is NULL so we have to use IS NOT ->order_by("left_ptr", "DESC") ->limit(1) ->find(); if ($tmp_item->loaded()) { $item = $tmp_item; } } // With non-view permissions, each level can override any permissions that came above it // so start at the top and work downwards, overlaying permissions as we go. $query = ORM::factory("access_intent") ->select(array("access_intents.$field", "items.left_ptr", "items.right_ptr")) ->join("items", "items.id", "access_intents.item_id") ->where("left_ptr", ">=", $item->left_ptr) ->where("right_ptr", "<=", $item->right_ptr) ->where($field, "IS NOT", access::INHERIT) ->order_by("level", "ASC") ->find_all(); foreach ($query as $row) { $value = ($row->$field === access::ALLOW) ? true : false; db::build() ->update("access_caches") ->set($field, $value) ->where("item_id", "IN", db::build() ->select("id") ->from("items") ->where("left_ptr", ">=", $row->left_ptr) ->where("right_ptr", "<=", $row->right_ptr)) ->execute(); } } /** * Rebuild the .htaccess files that prevent direct access to albums, resizes and thumbnails. We * call this internally any time we change the view or view_full permissions for guest users. * This function is only public because we use it in maintenance tasks. * * @param Item_Model the album * @param Group_Model the group whose permission is changing * @param string the permission name * @param string the new permission value (eg access::DENY) */ static function update_htaccess_files($album, $group, $perm_name, $value) { if ($group->id != identity::everybody()->id || !($perm_name == "view" || $perm_name == "view_full")) { return; } $dirs = array($album->file_path()); if ($perm_name == "view") { $dirs[] = dirname($album->resize_path()); $dirs[] = dirname($album->thumb_path()); } $base_url = url::base(true); $sep = "?"; if (strpos($base_url, "?") !== false) { $sep = "&"; } $base_url .= $sep . "kohana_uri=/file_proxy"; // Replace "/index.php/?kohana..." with "/index.php?koahan..." // Doesn't apply to "/?kohana..." or "/foo/?kohana..." // Can't check for "index.php" since the file might be renamed, and // there might be more Apache aliases / rewrites at work. $url_path = parse_url($base_url, PHP_URL_PATH); // Does the URL path have a file component? if (preg_match("#[^/]+\.php#i", $url_path)) { $base_url = str_replace("/?", "?", $base_url); } foreach ($dirs as $dir) { if ($value === access::DENY) { $fp = fopen("$dir/.htaccess", "w+"); fwrite($fp, "\n"); fwrite($fp, " RewriteEngine On\n"); fwrite($fp, " RewriteRule (.*) $base_url/\$1 [L]\n"); fwrite($fp, "\n"); fwrite($fp, "\n"); fwrite($fp, " Order Deny,Allow\n"); fwrite($fp, " Deny from All\n"); fwrite($fp, "\n"); fclose($fp); } else { @unlink($dir . "/.htaccess"); } } } static function private_key() { return module::get_var("gallery", "private_key"); } /** * Verify that our htaccess based permission system actually works. Create a temporary * directory containing an .htaccess file that uses mod_rewrite to redirect /verify to * /success. Then request that url. If we retrieve it successfully, then our redirects are * working and our permission system works. */ static function htaccess_works() { $success_url = url::file("var/security_test/success"); @mkdir(VARPATH . "security_test"); try { if ($fp = @fopen(VARPATH . "security_test/.htaccess", "w+")) { fwrite($fp, "Options +FollowSymLinks\n"); fwrite($fp, "RewriteEngine On\n"); fwrite($fp, "RewriteRule verify $success_url [L]\n"); fclose($fp); } if ($fp = @fopen(VARPATH . "security_test/success", "w+")) { fwrite($fp, "success"); fclose($fp); } // Proxy our authorization headers so that if the entire Gallery is covered by Basic Auth // this callback will still work. $headers = array(); if (function_exists("apache_request_headers")) { $arh = apache_request_headers(); if (!empty($arh["Authorization"])) { $headers["Authorization"] = $arh["Authorization"]; } } list ($status, $headers, $body) = remote::do_request(url::abs_file("var/security_test/verify"), "GET", $headers); $works = ($status == "HTTP/1.1 200 OK") && ($body == "success"); } catch (Exception $e) { @dir::unlink(VARPATH . "security_test"); throw $e; } @dir::unlink(VARPATH . "security_test"); return $works; } }