[rql] Introduces the new security model for RQL statements

This changes introduces the definition of a security model an its evaluation for RQL statements. This change enables the production of a security model that can be built and put in cache and then evaluated multiple time for differnt users with different arguments for the corresponding statement. In this new model, security checks are injected in rewritten RQL queries and the injected nodes can be selectively activated upon SQL generation depending on the current security evaluation for the current user.

authorLaurent Wouters <lwouters@cenotelie.fr>
changesetf22be300dee1
branchdefault
phasedraft
hiddenno
parent revision#a6f0758c32df [rqlrewrite] Introduce scaffolding for annotable query arguments
child revision<not specified>
files modified by this revision
cubicweb/server/rqlsec.py
cubicweb/server/sources/__init__.py
cubicweb/server/sources/native.py
cubicweb/server/sources/rql2sql.py
# HG changeset patch
# User Laurent Wouters <lwouters@cenotelie.fr>
# Date 1524241077 -7200
# Fri Apr 20 18:17:57 2018 +0200
# Node ID f22be300dee138245d51ff7ff6c72f7b40a54b9e
# Parent a6f0758c32df019a997d1b22ea10c390359c2e85
[rql] Introduces the new security model for RQL statements

This changes introduces the definition of a security model an its evaluation for
RQL statements. This change enables the production of a security model that can
be built and put in cache and then evaluated multiple time for differnt users
with different arguments for the corresponding statement. In this new model,
security checks are injected in rewritten RQL queries and the injected nodes can
be selectively activated upon SQL generation depending on the current security
evaluation for the current user.

diff --git a/cubicweb/server/rqlsec.py b/cubicweb/server/rqlsec.py
@@ -0,0 +1,828 @@
1 +# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2 +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
3 +#
4 +# This file is part of CubicWeb.
5 +#
6 +# CubicWeb is free software: you can redistribute it and/or modify it under the
7 +# terms of the GNU Lesser General Public License as published by the Free
8 +# Software Foundation, either version 2.1 of the License, or (at your option)
9 +# any later version.
10 +#
11 +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12 +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
13 +# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
14 +# details.
15 +#
16 +# You should have received a copy of the GNU Lesser General Public License along
17 +# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
18 +
19 +"""
20 +This module defines the security model and security checking plan for a RQL statement
21 +"""
22 +from cubicweb.rqlrewrite import ArgumentValue
23 +from rql.nodes import Exists, Not, Relation, VariableRef
24 +from rql.stmts import Select, Union
25 +
26 +READ_ONLY_RTYPES = {'eid', 'has_text', 'is', 'is_instance_of', 'identity'}
27 +
28 +
29 +class SecurityModel:
30 +    """
31 +    Defines the security model of a RQL SELECT statement
32 +    This security model keeps track of the entity and relation schemas that are relevant.
33 +    When the security permissions applied to these entities and relations are modified,
34 +    the security model must be invalidated
35 +    """
36 +
37 +    def __init__(self, statement):
38 +        """
39 +        Initializes an empty security model
40 +        :param statement: The original RQL statement
41 +        """
42 +        self.statement = statement
43 +        # alternative re-written statements
44 +        self.statements = []
45 +        # The children security models for sub-queries
46 +        self.children = []
47 +        # The map of the relevant entity types to their cached security constraints
48 +        self.entity_types = {}
49 +        # The map of the relevant relation types to their cached security constraints
50 +        self.relation_types = {}
51 +        # The map of variables of interest
52 +        self.variables = {}
53 +        # Map of solution to their constraints
54 +        self.solutions = []
55 +        # The argument injectors
56 +        self.injectors = []
57 +
58 +    def uses_entity_or_relation(self, name):
59 +        """
60 +        Determines whether this security model relies on the security constraints of an entity or relation with the specified name
61 +        :param name: The name of an entity or relation
62 +        :return: Whether the name corresponds to an entity or relation used in this security model
63 +        """
64 +        return self.entity_types.has_key(name) or self.relation_types.has_key(name)
65 +
66 +    def resolve_entity_security(self, schema, entity_name):
67 +        """
68 +        Resolves the security constraints of an entity type
69 +        :param schema: The current schema
70 +        :param entity_name: The name of the entity
71 +        :return: The corresponding EntitySecurity
72 +        """
73 +        if self.entity_types.has_key(entity_name):
74 +            return self.entity_types[entity_name]
75 +        eschema = schema.eschema(entity_name)
76 +        groups = eschema.get_groups('read')
77 +        expressions = eschema.get_rqlexprs('read')
78 +        # Create the constraint
79 +        constraints = EntitySecurity(entity_name, eschema)
80 +        constraints.groups.extend(groups)
81 +        for exp in expressions:
82 +            constraints.expressions.append(EntitySecurityExpression(exp))
83 +        self.entity_types[entity_name] = constraints
84 +        return constraints
85 +
86 +    def resolve_relation_security(self, schema, relation_name, get_left_type, get_right_type):
87 +        """
88 +        Resolves the security constraints of a relation
89 +        :param schema: The current schema
90 +        :param relation_name: The name of the relation
91 +        :param get_left_type: Getter for the name of the type on the relation's left
92 +        :param get_right_type: Getter for the name of the type on the relation's right
93 +        :return: The list of corresponding RelationDefinitionSecurity
94 +        """
95 +        if not self.relation_types.has_key(relation_name):
96 +            self.relation_types[relation_name] = RelationSecurity(relation_name, schema.rschema(relation_name))
97 +        constraints = self.relation_types[relation_name]
98 +
99 +        if constraints.schema.final:
100 +            lhs = get_left_type()
101 +            if lhs is not None:
102 +                entity_schema = schema.eschema(lhs)
103 +                relation_definition = entity_schema.rdef(constraints.schema)
104 +                return [constraints.resolve_case(relation_definition)]
105 +            else:
106 +                return [constraints.resolve_case(relation_definition)
107 +                        for types, relation_definition in constraints.schema.rdefs.items()
108 +                        ]
109 +        else:
110 +            lhs = get_left_type()
111 +            rhs = get_right_type()
112 +            if lhs is not None and rhs is not None:
113 +                left_type = schema.eschema(lhs)
114 +                right_type = schema.eschema(rhs)
115 +                relation_definition = constraints.schema.rdef(left_type, right_type)
116 +                return [constraints.resolve_case(relation_definition)]
117 +            else:
118 +                left_type = schema.eschema(lhs) if lhs is not None else None
119 +                right_type = schema.eschema(rhs) if rhs is not None else None
120 +                return [
121 +                    constraints.resolve_case(relation_definition)
122 +                    for types, relation_definition in constraints.schema.rdefs.items()
123 +                    if (left_type is None or left_type == types[0]) and (right_type is None or right_type == types[1])
124 +                ]
125 +
126 +    def inject_constraints(self, connection, args):
127 +        """
128 +        Injects the found RQL constraints into the statement using the specified RQL rewriter
129 +        :param connection: The current connection
130 +        :param args: The current arguments
131 +        :return: The set of variables with no invariant
132 +        """
133 +        # Performs injection in children models
134 +        result = set()
135 +        for child in self.children:
136 +            sub = child.inject_constraints(connection, args)
137 +            result = result.union(sub)
138 +
139 +        # Bail-out if this is not a SELECT statement
140 +        if not isinstance(self.statement, Select):
141 +            return result
142 +
143 +        no_invariant = set()
144 +        injectables, restricted = self._get_injectables()
145 +        if len(restricted) > 0:
146 +            # Rewrite the statement with the injectable constraints
147 +            rewritten, new_args = connection.rql_rewriter.insert_local_checks(self.statement, args, injectables,
148 +                                                                              restricted, no_invariant)
149 +            # New arguments ?
150 +            if len(new_args) > len(args):
151 +                # new arguments generated ...
152 +                for name, value in new_args.items():
153 +                    if name in args:
154 +                        continue
155 +                    if name == "login":
156 +                        self.injectors.append(ArgumentLoginInjector())
157 +                    elif isinstance(value, ArgumentValue) and value.type_hint == "CWUser":
158 +                        self.injectors.append(ArgumentUserEIDInjector(name))
159 +                    else:
160 +                        self.injectors.append(ArgumentValueInjector(name, value))
161 +            if len(rewritten) > 0:
162 +                self.statement = rewritten[0]
163 +                self.statements.extend(rewritten[1:])
164 +            # Recompute the solutions ...
165 +            connection.repo.vreg.solutions(connection, self.statement.parent, new_args)
166 +        return result.union(no_invariant)
167 +
168 +    def _get_injectables(self):
169 +        """
170 +        Gets all the RQL expressions that have to be injected (not all will be activated), and a set of the restricted variables
171 +        :return: A tuple (injectables, restricted) where
172 +                injectables is a map (constraints |-> [solution])
173 +                with constraints is a list [constraint1, constraint2, ...] where each constraint is a tuple (variable, expressions)
174 +                where expressions is a tuple where each member is an RQL expression
175 +                and restricted is the set of variable names that have applicable RQL expressions
176 +        """
177 +        injectables = {}
178 +        restricted = set()
179 +        for sc in self.solutions:
180 +            sc.get_injectables(injectables, restricted)
181 +        return injectables, restricted
182 +
183 +    def evaluate(self, connection, arguments, security=True):
184 +        """
185 +        Evaluates this security model and its children against the current user and arguments
186 +        :param connection: The current connection
187 +        :param arguments: The current arguments
188 +        :param security: Whether the security is active
189 +        :return: The evaluation result
190 +        """
191 +        # Check that security is on
192 +        if not connection.read_security or not security:
193 +            return SecurityModelEvaluation(self, connection, self.variables, arguments)
194 +        return self._evaluate_tree(connection, arguments)
195 +
196 +    def _evaluate_tree(self, connection, arguments):
197 +        """
198 +        Performs the evaluation of this security model and its children against the current user and arguments
199 +        :param connection: The current connection
200 +        :param arguments: The current arguments
201 +        :return: The evaluation result
202 +        """
203 +        result = SecurityModelEvaluation(self, connection, self.variables, arguments)
204 +        for injector in self.injectors:
205 +            injector.inject(connection, arguments)
206 +        for child in self.children:
207 +            sub_result = child._evaluate_tree(connection, arguments)
208 +            result.children.append(sub_result)
209 +            result.problems.extend(sub_result.problems)
210 +            result.activate_expressions(sub_result.rql_expressions)
211 +        self._evaluate_content(result)
212 +        return result
213 +
214 +    def _evaluate_content(self, result):
215 +        """
216 +        Evaluates this security model only against the current user and arguments
217 +        :param result: The evaluation result to build
218 +        :return: Nothing
219 +        """
220 +        # First, evaluate the constraints for each solution
221 +        for sc in self.solutions:
222 +            sc.evaluate(result)
223 +        # Check that all arguments are allowed
224 +        for argument in result.arguments.values():
225 +            if not argument.is_authorised and len(argument.solutions) == 0:
226 +                result.problems.append("No read access on %r with eid %i." % (argument.name, argument.value))
227 +        # Checks that there are still allowed typing solutions
228 +        if isinstance(self.statement, Select) and len(result.solutions) == 0:
229 +            result.problems.append("No remaining authorised typing solution")
230 +
231 +
232 +class ArgumentInjector:
233 +    """
234 +    Represents an entity able to inject query arguments upon evaluation of a security model
235 +    """
236 +
237 +    def __init__(self, name):
238 +        """
239 +        Initializes this injector
240 +        :param name: The name of the argument to be injected
241 +        """
242 +        self.name = name
243 +
244 +    def inject(self, connection, arguments):
245 +        """
246 +        Executes this injection job
247 +        :param connection: The current connection
248 +        :param arguments: The target arguments dictionary
249 +        :return: Nothing
250 +        """
251 +        raise NotImplemented
252 +
253 +
254 +class ArgumentLoginInjector(ArgumentInjector):
255 +    """
256 +    Injects the login of the current user
257 +    """
258 +
259 +    def __init__(self):
260 +        """
261 +        Initializes this injector
262 +        """
263 +        ArgumentInjector.__init__(self, "login")
264 +
265 +    def inject(self, connection, arguments):
266 +        arguments[self.name] = connection.user.name
267 +
268 +
269 +class ArgumentUserEIDInjector(ArgumentInjector):
270 +    """
271 +    Injects the EID of the current user
272 +    """
273 +
274 +    def __init__(self, name):
275 +        """
276 +        Initializes this injector
277 +        :param name: The name of the argument to be injected
278 +        """
279 +        ArgumentInjector.__init__(self, name)
280 +
281 +    def inject(self, connection, arguments):
282 +        arguments[self.name] = connection.user.eid
283 +
284 +
285 +class ArgumentValueInjector(ArgumentInjector):
286 +    """
287 +    Injects the EID of the current user
288 +    """
289 +
290 +    def __init__(self, name, value):
291 +        """
292 +        Initializes this injector
293 +        :param name: The name of the argument to be injected
294 +        :param value: The value for the argument
295 +        """
296 +        ArgumentInjector.__init__(self, name)
297 +        self.value = value.value if isinstance(value, ArgumentValue) else value
298 +
299 +    def inject(self, connection, arguments):
300 +        arguments[self.name] = self.value
301 +
302 +
303 +class EntitySecurity:
304 +    """
305 +    Defines the security constraints specific to an entity type
306 +    """
307 +
308 +    def __init__(self, type_name, schema):
309 +        """
310 +        Initializes this constraint
311 +        :param type_name: The type's name
312 +        :param schema: The entity's schema
313 +        """
314 +        self.type_name = type_name
315 +        self.schema = schema
316 +        # List of security groups
317 +        # The requesting user must be in one of them to satisfy the constraint
318 +        self.groups = []
319 +        # Or, one of these expressions must be satisfied
320 +        self.expressions = []
321 +
322 +    def get_injectables(self):
323 +        """
324 +        Gets the injectable RQL expressions associated to this entity type
325 +        :return: A tuple with the expressions as members, or None is there is none
326 +        """
327 +        if len(self.expressions) == 0:
328 +            return None
329 +        return tuple(e.expression for e in self.expressions)
330 +
331 +    def evaluate(self, evaluation, solution, variable):
332 +        """
333 +        Evaluates the security constraints for a variable of this type
334 +        :param evaluation: The current evaluation
335 +        :param solution: The current typing solution
336 +        :param variable: The name of the variable that is of this type
337 +        :return None if the constraints cannot be verified, True if they unconditionally are, or the list of expressions to be activated
338 +        """
339 +        if self.type_name == "Password":
340 +            evaluation.problems.append("Password selection is not allowed (%s)" % variable)
341 +            return None
342 +        # Check the groups
343 +        for g in self.groups:
344 +            if evaluation.check_group(g):
345 +                if evaluation.arguments.has_key(variable):
346 +                    argument = evaluation.arguments[variable]
347 +                    argument.allow_in(solution)
348 +                return True
349 +        # Not in groups ... do we have RQL expressions?
350 +        if len(self.expressions) == 0:
351 +            return None
352 +        # Is this an argument for which we can evaluate the expressions?
353 +        if not evaluation.arguments.has_key(variable):
354 +            # Not an arguments, activate the expressions
355 +            return set(self.expressions)
356 +        argument = evaluation.arguments[variable]
357 +        # Authorized by the transaction => OK
358 +        if argument.is_authorised:
359 +            return True
360 +        # No, try to evaluate the expressions:
361 +        for ec in self.expressions:
362 +            if ec.evaluate(evaluation, variable):
363 +                # Found a matching expression => OK
364 +                argument.allow_in(solution)
365 +                return True
366 +        # all failed ...
367 +        return None
368 +
369 +
370 +class EntitySecurityExpression:
371 +    """
372 +    Represents a RQL expression for checking the security of a specific type of entity
373 +    """
374 +
375 +    def __init__(self, expression):
376 +        """
377 +        Initializes this constraint
378 +        :param expression: The encapsulated expression
379 +        """
380 +        self.expression = expression
381 +        self.expression.injection_marker = object()
382 +
383 +    def evaluate(self, evaluation, variable):
384 +        """
385 +        Evaluates the security constraints for this expression
386 +        :param evaluation: The current evaluation
387 +        :param variable: The name of the variable that is of this type
388 +        :return Whether the expression is verified
389 +        """
390 +        return self.expression.check(evaluation.connection, evaluation.arguments[variable].value)
391 +
392 +    def is_node_activated(self, node):
393 +        """
394 +        Gets whether the specified injected RQL node is to be activated by this expression
395 +        (it corresponds to this expression)
396 +        :param node: The injected RQL node
397 +        :return: Whether the node shall be activated
398 +        """
399 +        return self.expression.injection_marker == node.injection_marker
400 +
401 +
402 +class RelationSecurity:
403 +    """
404 +    Defines the security constraints specific to a relation
405 +    """
406 +
407 +    def __init__(self, relation_name, schema):
408 +        """
409 +        Initializes this constraint
410 +        :param relation_name: The relation's name
411 +        :param schema: The relation's schema
412 +        """
413 +        self.relation_name = relation_name
414 +        self.schema = schema
415 +        # The different typing cases
416 +        self.cases = {}
417 +
418 +    def resolve_case(self, definition):
419 +        """
420 +        Resolves the constraints for a specific definition of this relation
421 +        :param definition: The definition to look for
422 +        :return: The constraints
423 +        """
424 +        if self.cases.has_key(definition):
425 +            return self.cases[definition]
426 +        result = RelationDefinitionSecurity(definition)
427 +        self.cases[definition] = result
428 +        return result
429 +
430 +
431 +class RelationDefinitionSecurity:
432 +    """
433 +    Defines the security constraints specific to a typing case for a relation
434 +    """
435 +
436 +    def __init__(self, definition):
437 +        """
438 +        Initializes this structure
439 +        :param definition: The relation definition for the typing cases
440 +        """
441 +        self.definition = definition
442 +        # The various typing cases for this relation definition
443 +        self.typing = (definition.subject.type, definition.object.type)
444 +        # List of security groups
445 +        # The requesting user must be in one of them to satisfy the constraint
446 +        self.groups = definition.get_groups('read')
447 +
448 +    def evaluate(self, evaluation):
449 +        """
450 +        Evaluates the security constraints for this relation definition
451 +        :param evaluation: The current evaluation
452 +        :return True if all constraints are satisfied
453 +        """
454 +        for g in self.groups:
455 +            if evaluation.check_group(g):
456 +                return True
457 +        evaluation.problems.append("Cannot read relation")
458 +        return False
459 +
460 +
461 +class SecurityModelArgument:
462 +    """
463 +    Represents the data for the security model about an argument for a RQL statement
464 +    """
465 +
466 +    def __init__(self, name, value, is_authorised):
467 +        """
468 +        Initializes this argument
469 +        :param name: The argument's name
470 +        :param value: The argument's value
471 +        :param is_authorised: Whether the value pointed to by the argument has been explicitly authorised
472 +        """
473 +        self.name = name
474 +        self.value = value
475 +        self.is_authorised = is_authorised
476 +        # The typing solutions
477 +        self.solutions = []
478 +
479 +    def allow_in(self, solution):
480 +        """
481 +        Allows access to the value represented by this argument in the specified typing solution
482 +        :param solution: A typing solution
483 +        :return: Nothing
484 +        """
485 +        if not solution in self.solutions:
486 +            self.solutions.append(solution)
487 +
488 +
489 +class SecurityModelEvaluation:
490 +    """
491 +    Represents an evaluation of a security model
492 +    """
493 +
494 +    def __init__(self, model, connection, variables, arguments):
495 +        """
496 +        Initializes this structure
497 +        :param model: The evaluated security model
498 +        :param connection: The current connection
499 +        :param variables: The variables of interest in the statement
500 +        :param arguments: The arguments passed for the evaluation
501 +        """
502 +        self.model = model
503 +        self.connection = connection
504 +        # The current user
505 +        self.user = connection.user
506 +        # The children evaluation
507 +        self.children = []
508 +        # The evaluation arguments
509 +        self.arguments = {}
510 +        # The allowed typing solutions
511 +        self.solutions = []
512 +        # The activated RQL expressions
513 +        self.rql_expressions = set()
514 +        # The security problems raised during the evaluation
515 +        self.problems = []
516 +        # The cached group checks
517 +        self.groups = {}
518 +        # Prepare the evaluation arguments
519 +        for var, node in variables.items():
520 +            if node is not None:
521 +                eid = int(node.eval(arguments))
522 +                self.arguments[var] = SecurityModelArgument(var, eid, connection.added_in_transaction(eid))
523 +
524 +    def check_group(self, group):
525 +        """
526 +        Checks whether the current user belongs to the specified group
527 +        :param group: The group to check
528 +        :return: Whether the current user belongs to the specified group
529 +        """
530 +        if self.groups.has_key(group):
531 +            return self.groups[group]
532 +        result = self.user.is_in_group(group)
533 +        self.groups[group] = result
534 +        return result
535 +
536 +    def get_evaluation_for(self, statement):
537 +        """
538 +        Retrieves the security evaluation specific to the specified statement
539 +        :param statement: The RQL statement
540 +        :return: The evaluation
541 +        """
542 +        if statement == self.model.statement or statement in self.model.statements:
543 +            return self
544 +        for child in self.children:
545 +            result = child.get_evaluation_for(statement)
546 +            if result is not None:
547 +                return result
548 +        return None
549 +
550 +    def activate_expressions(self, expressions):
551 +        """
552 +        Activates the specified expressions in this evaluation
553 +        :param expressions: The set of expressions to activate
554 +        :return: Nothing
555 +        """
556 +        self.rql_expressions = self.rql_expressions.union(expressions)
557 +
558 +    def is_node_activated(self, node):
559 +        """
560 +        Gets whether the specified injected RQL node is to be activated by this evaluation
561 +        :param node: The injected RQL node
562 +        :return: Whether the node shall be activated
563 +        """
564 +        for expression in self.rql_expressions:
565 +            if expression.is_node_activated(node):
566 +                return True
567 +        return False
568 +
569 +
570 +class SolutionConstraints:
571 +    """
572 +    Defines the security constraints for a typing solution of a RQL SELECT statement
573 +    """
574 +
575 +    def __init__(self, solution):
576 +        """
577 +        Initializes empty constraints for the specified solution
578 +        :param solution: The typing solution being constrained
579 +        """
580 +        self.solution = solution
581 +        # Map of variables to their constraints
582 +        self.variables = {}
583 +        # The constraints on the relations for this typing solution
584 +        self.relations = {}
585 +
586 +    def get_injectables(self, injectables, restricted):
587 +        """
588 +        Gets the injectable RQL expressions associated for this solution
589 +        :param injectables: The resulting dictionary (constraints |-> [solution]) to fill
590 +        :param restricted: The list of the restricted variables (with applicable expressions)
591 +        :return: Nothing
592 +        """
593 +        my_constraints = []
594 +        for vc in self.variables.values():
595 +            constraints = vc.get_injectables()
596 +            if constraints is None:
597 +                continue
598 +            # Register the restricted variable
599 +            restricted.add(constraints[0])
600 +            my_constraints.append(constraints)
601 +        my_constraints = tuple(my_constraints)
602 +        # Register the constraints for this solution
603 +        injectables.setdefault(my_constraints, []).append(self.solution)
604 +
605 +    def evaluate(self, evaluation):
606 +        """
607 +        Evaluates the security constraints for this solution
608 +        :param evaluation: The current evaluation
609 +        :return Nothing
610 +        """
611 +        activated = set()
612 +        for var, vc in self.variables.items():
613 +            r = vc.evaluate(evaluation, self.solution)
614 +            if r is None:
615 +                # This solution is dis-allowed
616 +                return
617 +            elif isinstance(r, set):
618 +                # this is conditioned to expressions being activated
619 +                activated = activated.union(r)
620 +            elif r:
621 +                # all sub-constrained reported matched => continue
622 +                continue
623 +            else:
624 +                # should not happen
625 +                return
626 +
627 +        for relation, rc in self.relations.items():
628 +            if not rc.evaluate(evaluation):
629 +                # This solution is dis-allowed
630 +                return
631 +
632 +        # Looks like this solution is allowed (provision the expressions if any)
633 +        evaluation.solutions.append(self.solution)
634 +        evaluation.activate_expressions(activated)
635 +
636 +
637 +class VariableConstraints:
638 +    """
639 +    Defines the security constraints specific to a variable in a typing solution of a RQL SELECT statement
640 +    """
641 +
642 +    def __init__(self, name, is_eid_evaluable, security):
643 +        """
644 +        Initializes this constraint
645 +        :param name: The name of the constrained variable
646 +        :param is_eid_evaluable: Whether the variable is evaluable (at execution time) to an EID
647 +                            In that case, the associated RQL expressions may be statically checked,
648 +                            instead of being injected within the final SQL statement.
649 +        :param security: The security constraint for the variable's type
650 +        """
651 +        self.name = name
652 +        self.is_eid_evaluable = is_eid_evaluable
653 +        self.security = security
654 +
655 +    def get_injectables(self):
656 +        """
657 +        Gets the injectable RQL expressions associated to this variable, if any
658 +        :return: A tuple (name, expressions) where
659 +                name is the variable's name and
660 +                expressions is the tuple of RQL expression if there are appropriate expression,
661 +                None otherwise
662 +        """
663 +        if self.is_eid_evaluable:
664 +            return None
665 +        expressions = self.security.get_injectables()
666 +        if expressions is None:
667 +            return None
668 +        return self.name, expressions
669 +
670 +    def evaluate(self, evaluation, solution):
671 +        """
672 +        Evaluates the security constraints for this variable
673 +        :param evaluation: The current evaluation
674 +        :param solution: The current typing solution
675 +        :return None if the constraints cannot be verified, True if they unconditionally are, or the list of expressions to be activated
676 +        """
677 +        return self.security.evaluate(evaluation, solution, self.name)
678 +
679 +
680 +class RelationConstraints:
681 +    """
682 +    Represents the set of constraints on a specific relation for a solution
683 +    """
684 +
685 +    def __init__(self, security):
686 +        """
687 +        Initializes this structure
688 +        :param security: The security constraints for the associated relation
689 +        """
690 +        self.security = security
691 +
692 +    def evaluate(self, evaluation):
693 +        """
694 +        Evaluates the security constraints for this relation
695 +        :param evaluation: The current evaluation
696 +        :return True if all constraints are satisfied
697 +        """
698 +        for s in self.security:
699 +            if not s.evaluate(evaluation):
700 +                return False
701 +        return True
702 +
703 +
704 +def build_security_model(connection, statement):
705 +    """
706 +    Builds the security model of a RQL statement
707 +    :param connection: The current connection
708 +    :param statement: The RQL statement
709 +    :return: The security model
710 +    """
711 +    with connection.security_enabled(read=False):
712 +        schema = connection.repo.schema
713 +        return build_security_model_for(statement, schema, None)
714 +
715 +
716 +def build_security_model_for(statement, schema, root_model):
717 +    """
718 +    Builds the security model of a RQL statement
719 +    :param statement: The RQL statement
720 +    :param schema: The current schema
721 +    :param root_model: The root security model, if any (use to resolve the schema-related data)
722 +    :return: The security model
723 +    """
724 +    if isinstance(statement, Union):
725 +        return _build_security_model_for_union(statement, schema, root_model)
726 +    elif isinstance(statement, Select):
727 +        return _build_security_model_for_select(statement, schema, root_model)
728 +    return SecurityModel(statement)
729 +
730 +
731 +def _build_security_model_for_union(union, schema, root_model):
732 +    """
733 +    Builds the security model of a UNION statement
734 +    :param union: The RQL UNION statement
735 +    :param schema: The current schema
736 +    :param root_model: The root security model, if any (use to resolve the schema-related data)
737 +    :return: The security model
738 +    """
739 +    union_model = SecurityModel(union)
740 +    # Setup the root model if required
741 +    root_model = root_model if root_model is not None else union_model
742 +    for select in union.children[:]:
743 +        union_model.children.append(_build_security_model_for_select(select, schema, root_model))
744 +    return union_model
745 +
746 +
747 +def _build_security_model_for_select(select, schema, root_model):
748 +    """
749 +    Builds the security model of a SELECT statement
750 +    :param select: The RQL SELECT statement
751 +    :param schema: The current schema
752 +    :param root_model: The root security model, if any (use to resolve the schema-related data)
753 +    :return: The security model
754 +    """
755 +    model = SecurityModel(select)
756 +    # Setup the root model if required
757 +    root_model = root_model if root_model is not None else model
758 +    # Build the children models for the sub queries
759 +    for subquery in select.with_:
760 +        model.children.append(build_security_model_for(subquery.query, schema, root_model))
761 +
762 +    # Filter out the variables that are not of interest from a security standpoint
763 +    for var_name in select.defined_vars:
764 +        # don't insert security on variable only referenced by 'NOT X relation Y' or
765 +        # 'NOT EXISTS(X relation Y)'
766 +        var_info = select.defined_vars[var_name].stinfo
767 +        if var_info['selected'] or (
768 +                len([r for r in var_info['relations']
769 +                     if (not schema.rschema(r.r_type).final
770 +                         and ((isinstance(r.parent, Exists) and r.parent.neged(strict=True))
771 +                              or isinstance(r.parent, Not)))])
772 +                !=
773 +                len(var_info['relations'])):
774 +            model.variables[var_name] = var_info['constnode']
775 +
776 +    for solution in select.solutions:
777 +        # Register the solution and the associated constraints
778 +        solution_constraints = SolutionConstraints(solution)
779 +        model.solutions.append(solution_constraints)
780 +        # Look for the constraints on the variables
781 +        for var_name in model.variables.keys():
782 +            type_name = solution[var_name]
783 +            eschema = schema.eschema(type_name)
784 +            if eschema.final and type_name != "Password":
785 +                # this is a primitive type (and not password), do nothing
786 +                # if the type is Password, register the variable constraint for later reporting
787 +                continue
788 +            is_eid_evaluable = model.variables[var_name] is not None
789 +            solution_constraints.variables[var_name] = VariableConstraints(
790 +                var_name,
791 +                is_eid_evaluable,
792 +                root_model.resolve_entity_security(schema, type_name))
793 +        # Look for the constraints on the relations
794 +        if select.where is not None:
795 +            for relation_node in select.where.iget_nodes(Relation):
796 +                if relation_node.r_type in READ_ONLY_RTYPES:
797 +                    # This relation si read-only => do nothing
798 +                    continue
799 +                solution_constraints.relations[relation_node.r_type] = RelationConstraints(
800 +                    root_model.resolve_relation_security(
801 +                        schema,
802 +                        relation_node.r_type,
803 +                        lambda rn=relation_node, s=solution: _get_relation_left_type(rn, s),
804 +                        lambda rn=relation_node, s=solution: _get_relation_right_type(rn, s)))
805 +
806 +    return model
807 +
808 +
809 +def _get_relation_left_type(relation_node, solution):
810 +    """
811 +    Gets the type of the left-hand side of a Relation RQL node
812 +    :param relation_node: A Relation RQL node
813 +    :param solution: The current solution
814 +    :return: The type of the left-hand side (or None) if it cannot be known
815 +    """
816 +    node = relation_node.children[0]
817 +    return solution[node.name] if isinstance(node, VariableRef) else None
818 +
819 +
820 +def _get_relation_right_type(relation_node, solution):
821 +    """
822 +    Gets the type of the right-hand side of a Relation RQL node
823 +    :param relation_node: A Relation RQL node
824 +    :param solution: The current solution
825 +    :return: The type of the right-hand side (or None) if it cannot be known
826 +    """
827 +    node = relation_node.children[1].children[0]
828 +    return solution[node.name] if isinstance(node, VariableRef) else None
diff --git a/cubicweb/server/sources/__init__.py b/cubicweb/server/sources/__init__.py
@@ -249,11 +249,11 @@
829          raise NotImplementedError(self)
830 
831      # RQL query api ############################################################
832 
833      def syntax_tree_search(self, cnx, union,
834 -                           args=None, cachekey=None, debug=0):
835 +                           args=None, cachekey=None, security=None, debug=0):
836          """return result from this source for a rql query (actually from a rql
837          syntax tree and a solution dictionary mapping each used variable to a
838          possible type). If cachekey is given, the query necessary to fetch the
839          results (but not the results themselves) may be cached using this key.
840          """
diff --git a/cubicweb/server/sources/native.py b/cubicweb/server/sources/native.py
@@ -501,11 +501,11 @@
841                  return authentifier.authenticate(cnx, login, **kwargs)
842              except AuthenticationError:
843                  continue
844          raise AuthenticationError()
845 
846 -    def syntax_tree_search(self, cnx, union, args=None, cachekey=None):
847 +    def syntax_tree_search(self, cnx, union, args=None, cachekey=None, security=None, debug=0):
848          """return result from this source for a rql query (actually from
849          a rql syntax tree and a solution dictionary mapping each used
850          variable to a possible type). If cachekey is given, the query
851          necessary to fetch the results (but not the results themselves)
852          may be cached using this key.
@@ -513,19 +513,19 @@
853          assert dbg_st_search(self.uri, union, args, cachekey)
854          # remember number of actually selected term (sql generation may append some)
855          if cachekey is None:
856              self.no_cache += 1
857              # generate sql query if we are able to do so (not supported types...)
858 -            sql, qargs, cbs = self._rql_sqlgen.generate(union, args)
859 +            sql, qargs, cbs = self._rql_sqlgen.generate(union, args, security)
860          else:
861              # sql may be cached
862              try:
863                  sql, qargs, cbs = self._cache[cachekey]
864                  self.cache_hit += 1
865              except KeyError:
866                  self.cache_miss += 1
867 -                sql, qargs, cbs = self._rql_sqlgen.generate(union, args)
868 +                sql, qargs, cbs = self._rql_sqlgen.generate(union, args, security)
869                  self._cache[cachekey] = sql, qargs, cbs
870          args = self.merge_args(args, qargs)
871          assert isinstance(sql, string_types), repr(sql)
872          cursor = cnx.system_sql(sql, args)
873          results = self.process_result(cursor, cnx, cbs)
diff --git a/cubicweb/server/sources/rql2sql.py b/cubicweb/server/sources/rql2sql.py
@@ -736,12 +736,16 @@
874              self.union_sql = self.noparen_union_sql
875          self._lock = threading.Lock()
876          if attrmap is None:
877              attrmap = {}
878          self.attr_map = attrmap
879 +        self._args = None
880 +        self._query_attrs = None
881 +        self._state = None
882 +        self._security = None
883 
884 -    def generate(self, union, args=None):
885 +    def generate(self, union, args=None, security=None):
886          """return SQL queries and a variable dictionary from a RQL syntax tree
887 
888          :partrqls: a list of couple (rqlst, solutions)
889          :args: optional dictionary with values of substitutions used in the query
890 
@@ -751,10 +755,11 @@
891              args = {}
892          self._lock.acquire()
893          self._args = args
894          self._query_attrs = {}
895          self._state = None
896 +        self._security = security
897          # self._not_scope_offset = 0
898          try:
899              # union query for each rqlst / solution
900              sql = self.union_sql(union)
901              # we are done
@@ -810,10 +815,15 @@
902                  elif vscope is not scope:
903                      scope = common_parent(scope, vscope).scope
904              if scope is None:
905                  scope = select
906              scope.add_restriction(restr)
907 +
908 +        # get the security evaluation for this select statement
909 +        security = self._security.get_evaluation_for(select) if self._security is not None else None
910 +        # get the activate RQL security constraints:
911 +        activated = list(security.rql_expressions) if security is not None else []
912          # remember selection, it may be changed and have to be restored
913          origselection = select.selection[:]
914          # check if the query will have union subquery, if it need sort term
915          # selection (union or distinct query) and wrapping (union with groups)
916          needwrap = False
@@ -856,11 +866,11 @@
917              needalias = True
918          self._in_wrapping_query = False
919          self._state = state
920          try:
921              sql = self._solutions_sql(select, sols, distinct,
922 -                                      needalias or needwrap)
923 +                                      needalias or needwrap, activated)
924              # generate groups / having before wrapping query selection to get
925              # correct column aliases
926              self._in_wrapping_query = needwrap
927              if groups:
928                  # no constant should be inserted in GROUP BY else the backend
@@ -926,11 +936,11 @@
929                          update_source_cb_stack(state, select, vref, stack)
930                          state.subquery_source_cb[selectidx] = stack
931                  except KeyError:
932                      continue
933 
934 -    def _solutions_sql(self, select, solutions, distinct, needalias):
935 +    def _solutions_sql(self, select, solutions, distinct, needalias, activated):
936          sqls = []
937          for solution in solutions:
938              self._state.reset(solution)
939              # visit restriction subtree
940              if select.where is not None:
@@ -1010,19 +1020,43 @@
941              return csql
942          return 'NOT (%s)' % csql
943 
944      def visit_exists(self, exists):
945          """generate SQL name for a exists subquery"""
946 +        if hasattr(exists, "injection_marker"):
947 +            if not self._is_injectable_activated(exists):
948 +                # this is an injectable that has not been activated
949 +                return ''
950          sqls = []
951          for dummy in self._state.iter_exists_sols(exists):
952              sql = self._visit_exists(exists)
953              if sql:
954                  sqls.append(sql)
955          if not sqls:
956              return ''
957          return 'EXISTS(%s)' % ' UNION '.join(sqls)
958 
959 +    def _is_injectable_activated(self, node):
960 +        """
961 +        Determines whether the specified injectable RQL node is activated for SQL generation
962 +        :param node: An injectable RQL node
963 +        :return: Whether the injectable is activated
964 +        """
965 +        if self._security is None:
966 +            # not security => do not activate
967 +            return False
968 +        # find the parent select node
969 +        select = node
970 +        while not isinstance(select, Select):
971 +            select = select.parent
972 +        # get the security evaluation for this select statement
973 +        security = self._security.get_evaluation_for(select)
974 +        if security is None:
975 +            # no security for this query => do not activate
976 +            return False
977 +        return security.is_node_activated(node)
978 +
979      def _visit_exists(self, exists):
980          self._state.push_scope(exists)
981          restriction = exists.children[0].accept(self)
982          restrictions, tables = self._state.pop_scope()
983          if restriction: