[hooks/security] provide attribute "add" permission
As of today, the update permission on attributes is checked at entity
*creation* time. This forbids using update permissions the proper way.
We set it to be checked at entity update time only.
We introduce a specific 'add' permission rule for attributes.
For backward compatibility, its default value will be the same as the
current 'update' permission.
Notes:
* needs a new yams version (ticket #149216)
* introduces two new 'add_permissions' rdefs (attribute - group|rqlexpr)
* if the update permission was () and the bw compat kicks in, the rule
is not enforced, to avoid un-creatable entity types -- this
restriction will be lifted when the bw compat is gone
* small internal refactoring on check_entity_attributes
* one small pre 3.6.1 bw compat snippet must be removed from schemaserial
Closes #2965518.
# copyright 2003-2013 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb. If not, see <http://www.gnu.org/licenses/>.
#
# (disable pylint msg for client obj access to protected member as in obj._cw)
# pylint: disable=W0212
"""The ``ajaxcontroller`` module defines the :class:`AjaxController`
controller and the ``ajax-func`` cubicweb registry.
.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxController
:members:
``ajax-funcs`` registry hosts exposed remote functions, that is
functions that can be called from the javascript world.
To register a new remote function, either decorate your function
with the :func:`~cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
.. sourcecode:: python
from cubicweb.predicates import mactch_user_groups
from cubicweb.web.views.ajaxcontroller import ajaxfunc
@ajaxfunc(output_type='json', selector=match_user_groups('managers'))
def list_users(self):
return [u for (u,) in self._cw.execute('Any L WHERE U login L')]
or inherit from :class:`~cubicweb.web.views.ajaxcontroller.AjaxFunction` and
implement the ``__call__`` method:
.. sourcecode:: python
from cubicweb.web.views.ajaxcontroller import AjaxFunction
class ListUser(AjaxFunction):
__regid__ = 'list_users' # __regid__ is the name of the exposed function
__select__ = match_user_groups('managers')
output_type = 'json'
def __call__(self):
return [u for (u, ) in self._cw.execute('Any L WHERE U login L')]
.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxFunction
:members:
.. autofunction:: cubicweb.web.views.ajaxcontroller.ajaxfunc
"""
__docformat__ = "restructuredtext en"
from warnings import warn
from functools import partial
from logilab.common.date import strptime
from logilab.common.registry import yes
from logilab.common.deprecation import deprecated
from cubicweb import ObjectNotFound, NoSelectableObject
from cubicweb.appobject import AppObject
from cubicweb.utils import json, json_dumps, UStringIO
from cubicweb.uilib import exc_message
from cubicweb.web import RemoteCallFailed, DirectResponse
from cubicweb.web.controller import Controller
from cubicweb.web.views import vid_from_rset
from cubicweb.web.views import basecontrollers
def optional_kwargs(extraargs):
if extraargs is None:
return {}
# we receive unicode keys which is not supported by the **syntax
return dict((str(key), value) for key, value in extraargs.iteritems())
class AjaxController(Controller):
"""AjaxController handles ajax remote calls from javascript
The following javascript function call:
.. sourcecode:: javascript
var d = asyncRemoteExec('foo', 12, "hello");
d.addCallback(function(result) {
alert('server response is: ' + result);
});
will generate an ajax HTTP GET on the following url::
BASE_URL/ajax?fname=foo&arg=12&arg="hello"
The AjaxController controller will therefore be selected to handle those URLs
and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
matching the *fname* parameter.
"""
__regid__ = 'ajax'
def publish(self, rset=None):
self._cw.ajax_request = True
try:
fname = self._cw.form['fname']
except KeyError:
raise RemoteCallFailed('no method specified')
# 1/ check first for old-style (JSonController) ajax func for bw compat
try:
func = getattr(basecontrollers.JSonController, 'js_%s' % fname).im_func
func = partial(func, self)
except AttributeError:
# 2/ check for new-style (AjaxController) ajax func
try:
func = self._cw.vreg['ajax-func'].select(fname, self._cw)
except ObjectNotFound:
raise RemoteCallFailed('no %s method' % fname)
else:
warn('[3.15] remote function %s found on JSonController, '
'use AjaxFunction / @ajaxfunc instead' % fname,
DeprecationWarning, stacklevel=2)
# no <arg> attribute means the callback takes no argument
args = self._cw.form.get('arg', ())
if not isinstance(args, (list, tuple)):
args = (args,)
try:
args = [json.loads(arg) for arg in args]
except ValueError as exc:
self.exception('error while decoding json arguments for '
'js_%s: %s (err: %s)', fname, args, exc)
raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
try:
result = func(*args)
except (RemoteCallFailed, DirectResponse):
raise
except Exception as exc:
self.exception('an exception occurred while calling js_%s(%s): %s',
fname, args, exc)
raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
if result is None:
return ''
# get unicode on @htmlize methods, encoded string on @jsonize methods
elif isinstance(result, unicode):
return result.encode(self._cw.encoding)
return result
class AjaxFunction(AppObject):
"""
Attributes on this base class are:
:attr: `check_pageid`: make sure the pageid received is valid before proceeding
:attr: `output_type`:
- *None*: no processing, no change on content-type
- *json*: serialize with `json_dumps` and set *application/json*
content-type
- *xhtml*: wrap result in an XML node and forces HTML / XHTML
content-type (use ``_cw.html_content_type()``)
"""
__registry__ = 'ajax-func'
__select__ = yes()
__abstract__ = True
check_pageid = False
output_type = None
@staticmethod
def _rebuild_posted_form(names, values, action=None):
form = {}
for name, value in zip(names, values):
# remove possible __action_xxx inputs
if name.startswith('__action'):
if action is None:
# strip '__action_' to get the actual action name
action = name[9:]
continue
# form.setdefault(name, []).append(value)
if name in form:
curvalue = form[name]
if isinstance(curvalue, list):
curvalue.append(value)
else:
form[name] = [curvalue, value]
else:
form[name] = value
# simulate click on __action_%s button to help the controller
if action:
form['__action_%s' % action] = u'whatever'
return form
def validate_form(self, action, names, values):
self._cw.form = self._rebuild_posted_form(names, values, action)
return basecontrollers._validate_form(self._cw, self._cw.vreg)
def _exec(self, rql, args=None, rocheck=True):
"""json mode: execute RQL and return resultset as json"""
rql = rql.strip()
if rql.startswith('rql:'):
rql = rql[4:]
if rocheck:
self._cw.ensure_ro_rql(rql)
try:
return self._cw.execute(rql, args)
except Exception as ex:
self.exception("error in _exec(rql=%s): %s", rql, ex)
return None
return None
def _call_view(self, view, paginate=False, **kwargs):
divid = self._cw.form.get('divid')
# we need to call pagination before with the stream set
try:
stream = view.set_stream()
except AttributeError:
stream = UStringIO()
kwargs['w'] = stream.write
assert not paginate
if divid == 'pageContent':
# ensure divid isn't reused by the view (e.g. table view)
del self._cw.form['divid']
# mimick main template behaviour
stream.write(u'<div id="pageContent">')
vtitle = self._cw.form.get('vtitle')
if vtitle:
stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
paginate = True
nav_html = UStringIO()
if paginate and not view.handle_pagination:
view.paginate(w=nav_html.write)
stream.write(nav_html.getvalue())
if divid == 'pageContent':
stream.write(u'<div id="contentmain">')
view.render(**kwargs)
extresources = self._cw.html_headers.getvalue(skiphead=True)
if extresources:
stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
stream.write(extresources)
stream.write(u'</div>\n')
if divid == 'pageContent':
stream.write(u'</div>%s</div>' % nav_html.getvalue())
return stream.getvalue()
def _ajaxfunc_factory(implementation, selector=yes(), _output_type=None,
_check_pageid=False, regid=None):
"""converts a standard python function into an AjaxFunction appobject"""
class AnAjaxFunc(AjaxFunction):
__regid__ = regid or implementation.__name__
__select__ = selector
output_type = _output_type
check_pageid = _check_pageid
def serialize(self, content):
if self.output_type is None:
return content
elif self.output_type == 'xhtml':
self._cw.set_content_type(self._cw.html_content_type())
return ''.join((u'<div>',
content.strip(), u'</div>'))
elif self.output_type == 'json':
self._cw.set_content_type('application/json')
return json_dumps(content)
raise RemoteCallFailed('no serializer found for output type %s'
% self.output_type)
def __call__(self, *args, **kwargs):
if self.check_pageid:
data = self._cw.session.data.get(self._cw.pageid)
if data is None:
raise RemoteCallFailed(self._cw._('pageid-not-found'))
return self.serialize(implementation(self, *args, **kwargs))
AnAjaxFunc.__name__ = implementation.__name__
# make sure __module__ refers to the original module otherwise
# vreg.register(obj) will ignore ``obj``.
AnAjaxFunc.__module__ = implementation.__module__
# relate the ``implementation`` object to its wrapper appobject
# will be used by e.g.:
# import base_module
# @ajaxfunc
# def foo(self):
# return 42
# assert foo(object) == 42
# vreg.register_and_replace(foo, base_module.older_foo)
implementation.__appobject__ = AnAjaxFunc
return implementation
def ajaxfunc(implementation=None, selector=yes(), output_type=None,
check_pageid=False, regid=None):
"""promote a standard function to an ``AjaxFunction`` appobject.
All parameters are optional:
:param selector: a custom selector object if needed, default is ``yes()``
:param output_type: either None, 'json' or 'xhtml' to customize output
content-type. Default is None
:param check_pageid: whether the function requires a valid `pageid` or not
to proceed. Default is False.
:param regid: a custom __regid__ for the created ``AjaxFunction`` object. Default
is to keep the wrapped function name.
``ajaxfunc`` can be used both as a standalone decorator:
.. sourcecode:: python
@ajaxfunc
def my_function(self):
return 42
or as a parametrizable decorator:
.. sourcecode:: python
@ajaxfunc(output_type='json')
def my_function(self):
return 42
"""
# if used as a parametrized decorator (e.g. @ajaxfunc(output_type='json'))
if implementation is None:
def _decorator(func):
return _ajaxfunc_factory(func, selector=selector,
_output_type=output_type,
_check_pageid=check_pageid,
regid=regid)
return _decorator
# else, used as a standalone decorator (i.e. @ajaxfunc)
return _ajaxfunc_factory(implementation, selector=selector,
_output_type=output_type,
_check_pageid=check_pageid, regid=regid)
###############################################################################
# Cubicweb remote functions for : #
# - appobject rendering #
# - user / page session data management #
###############################################################################
@ajaxfunc(output_type='xhtml')
def view(self):
# XXX try to use the page-content template
req = self._cw
rql = req.form.get('rql')
if rql:
rset = self._exec(rql)
elif 'eid' in req.form:
rset = self._cw.eid_rset(req.form['eid'])
else:
rset = None
vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
try:
viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
except NoSelectableObject:
vid = req.form.get('fallbackvid', 'noresult')
viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
viewobj.set_http_cache_headers()
if req.is_client_cache_valid():
return ''
return self._call_view(viewobj, paginate=req.form.pop('paginate', False))
@ajaxfunc(output_type='xhtml')
def component(self, compid, rql, registry='components', extraargs=None):
if rql:
rset = self._exec(rql)
else:
rset = None
# XXX while it sounds good, addition of the try/except below cause pb:
# when filtering using facets return an empty rset, the edition box
# isn't anymore selectable, as expected. The pb is that with the
# try/except below, we see a "an error occurred" message in the ui, while
# we don't see it without it. Proper fix would probably be to deal with
# this by allowing facet handling code to tell to js_component that such
# error is expected and should'nt be reported.
#try:
comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
**optional_kwargs(extraargs))
#except NoSelectableObject:
# raise RemoteCallFailed('unselectable')
return self._call_view(comp, **optional_kwargs(extraargs))
@ajaxfunc(output_type='xhtml')
def render(self, registry, oid, eid=None,
selectargs=None, renderargs=None):
if eid is not None:
rset = self._cw.eid_rset(eid)
# XXX set row=0
elif self._cw.form.get('rql'):
rset = self._cw.execute(self._cw.form['rql'])
else:
rset = None
viewobj = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
**optional_kwargs(selectargs))
return self._call_view(viewobj, **optional_kwargs(renderargs))
@ajaxfunc(output_type='json')
def i18n(self, msgids):
"""returns the translation of `msgid`"""
return [self._cw._(msgid) for msgid in msgids]
@ajaxfunc(output_type='json')
def format_date(self, strdate):
"""returns the formatted date for `msgid`"""
date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
return self._cw.format_date(date)
@ajaxfunc(output_type='json')
def external_resource(self, resource):
"""returns the URL of the external resource named `resource`"""
return self._cw.uiprops[resource]
@ajaxfunc(output_type='json', check_pageid=True)
def user_callback(self, cbname):
"""execute the previously registered user callback `cbname`.
If matching callback is not found, return None
"""
page_data = self._cw.session.data.get(self._cw.pageid, {})
try:
cb = page_data[cbname]
except KeyError:
self.warning('unable to find user callback %s', cbname)
return None
return cb(self._cw)
@ajaxfunc
def unregister_user_callback(self, cbname):
"""unregister user callback `cbname`"""
self._cw.unregister_callback(self._cw.pageid, cbname)
@ajaxfunc
def unload_page_data(self):
"""remove user's session data associated to current pageid"""
self._cw.session.data.pop(self._cw.pageid, None)
@ajaxfunc(output_type='json')
@deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
def set_cookie(self, cookiename, cookievalue):
"""generates the Set-Cookie HTTP reponse header corresponding
to `cookiename` / `cookievalue`.
"""
cookiename, cookievalue = str(cookiename), str(cookievalue)
self._cw.set_cookie(cookiename, cookievalue)