Splitting rules for semantic clarity and extensibility

Compatibility

This page has not been updated for version 1.0 of the pico-engine

Best practice

A rule expresses how a pico will react to an event that selects the rule. Normally, we use nouns for event types, and I have begun using verbs for rule names. The idea is that an event (something) occurs, and as a result a pico selects it and performs actions and effects in reaction.

It's best practice for a rule to do just one thing (and do it well). If the reaction to an event requires several things to be done, it is best to let one rule chain to another, until all the actions and effects have been accomplished.

Here we present an example of refactoring a rule which does two things into two rules which each do one thing, and show how this is useful.

Example

During owner login, at the point where a person has entered her owner id and clicks on the "Login" button, an event is raised. In reaction, the root pico generates a one-time-use ECI for password validation, and the UI switches to a password input form.

There is a security leak, in that when a person enters an owner id incorrectly, we didn't provide an ECI for password verification (which in this case, still fails, so it's functionally correct). The vulnerability is that an attacker could try many owner ids until he finds one that actually exists, and then set a brute-force attack to work to discover the password for a known-good owner id.

Shown here is the missing ECI when an unknown owner_id was entered in the previous screen:

There is a rule in the account management ruleset which reacts to this owner:eci_requested event. It must 1) verify that the root pico has an ECI for an owner pico with that id, and 2) provide a temporary ECI for the purpose of verifying the password.

Before

In an earlier version of the ruleset, these two purposes were conflated in a single rule, eci_from_owner_id, as shown below. In the prelude line 4 addresses the first purpose, while lines 5-8 prepare for the second purpose. If all is well, the conditional actions fulfill the second purpose by creating a new temporary channel (lines 11-12) and making it available to the endpoint (line 13).

rule eci_from_owner_id{
  select when owner eci_requested owner_id re#(.+)# setting(owner_id)
  pre {
    eci = getEciFromOwnerName(owner_id.klog("owner_id"));
    pico_id = eci != "No user found"
      => engine:getPicoIDByECI(eci.klog("eci"))
       | null;
    channel_name = "authenticate_"+time:now();
  }
  if pico_id then every{
    engine:newChannel(pico_id,channel_name,"temporary",ent:ownerPolicy{"id"})
      setting(new_channel);
    send_directive("Returning eci from owner name", {"eci": new_channel{"id"}});
  }fired{
    raise owner event "login_attempt"
      attributes event:attrs.put({ "timestamp": time:now() });
    schedule owner event "authenticate_channel_expired"
      at time:add(time:now(), {"minutes": 3})
      attributes {"eci": new_channel{"id"}}
  }else{
    raise owner event "login_attempt_failed"
      attributes event:attrs.put({ "timestamp": time:now() });
  }
}

In the postlude, lines 17-19 schedule a clean-up event to ensure that the temporary channel is indeed temporary. The rule ends in either case by raising a further event in case some other ruleset might wish to react. This is called rule chaining, and it is considered a best practice to avoid "terminal" rules. You never know when an event can trigger useful activity and it may not be easy for another person, later, to come back and modify your ruleset so that it raises a needed event. If the rule is successful in providing a new temporary channel to the owner pico, it raises the owner:login_attempt event (lines 15-16). Otherwise it raises the owner:login_attempt_failed event (lines 21-22).

Event diagram

This diagram shows one of the events connected with logging in, owner:eci_requested. Four rules might be selected for this event (depending on their where clauses), and they are shown left-to-right in the order they appear in the ruleset (which is the same order they would be evaluated in, if selected). Suppose that we would like to augment this behavior, in another ruleset (using this one as a module) which would provide a dummy ECI when the owner_id provided isn't known.

The only opportunity to hook into these rules to provide a dummy ECI would be to react to the owner:login_attempt_failed event, but then the other ruleset would have to do all of the work for the second purpose, including scheduling and providing the cleanup of the temporary channel. Besides that, the new temporary channel wouldn't be able to use the policy mentioned in line 11, because it's kept in an entity variable of the account management ruleset (so it would have to be provided and obtained through a module reference). It quickly gets very complicated, and in the interest of DRY, this isn't really the way to go.

As an aside, the name "login_attempt_failed" is not really accurate, because this is only one of the ways a login attempt could fail, so we'll also change that.

After

We will break this rule into two rules, and use chaining to connect them when appropriate. We'll also define three new events in addition to the owner:login_attempt event: owner:eci_found (when the root pico knows an owner pico named by owner_id); owner:no_such_owner_id (when it doesn't); and owner:no_such_pico as a specific kind of login failure (the other kind being an incorrect password).

The first rule fulfills only the first purpose of the original rule. If successful, it chains to the second rule (line 8). If not it chains to a yet-to-be-invented rule (line 11).

  rule eci_from_owner_id{
    select when owner eci_requested owner_id re#(.+)# setting(owner_id)
    pre {
      eci = getEciFromOwnerName(owner_id.klog("owner_id"));
    }
    if eci != "No user found" then noop();
    fired{
      raise owner event "eci_found" attributes event:attrs.put({"eci":eci});
    }
    else{
      raise owner event "no_such_owner_id" attributes event:attrs;
    }
  }

The second rule will always have a channel to an owner pico which exists, and fulfills the second purpose which is to make available a temporary channel to that pico. Lines 4-6 in the prelude prepare, and the action in lines 9-10 creates the temporary channel while the action in line 11 makes its ECI available to the endpoint. 

  rule provide_temporary_owner_eci {
    select when owner eci_found eci re#^(.+)$# setting(eci)
    pre {
      pico_id = eci => engine:getPicoIDByECI(eci.klog("eci"))
                     | null;
      channel_name = "authenticate_"+time:now();
    }
    if pico_id then every{
      engine:newChannel(pico_id,channel_name,"temporary",ent:ownerPolicy{"id"})
        setting(new_channel);
      send_directive("Returning eci from owner name", {"eci": new_channel{"id"}});
    }
    fired {
      raise owner event "login_attempt"
        attributes event:attrs.put({ "timestamp": time:now() });
      schedule owner event "authenticate_channel_expired"
        at time:add(time:now(), {"minutes": 3})
        attributes {"eci": new_channel{"id"}};
    }
    else{
      raise owner event "no_such_pico"
        attributes event:attrs.put({ "timestamp": time:now() });
    }
  }

If all is well, the owner:login_attempt event is raised (lines 14-15) and an owner:authenticate_channel_expired event is scheduled (lines 16-18) to ensure the temporary channel does not exist longer than 3 minutes. Otherwise, the owner:no_such_pico event is raised in case, someday, someone needs it.

Event diagram

Here the left-hand branch (from the third rule) shows the chaining to the new provide_temporary_eci rule. Also shown is how another ruleset can hook into the owner:no_such_owner_id event to provide a dummy ECI. By raising the owner:eci_found event, it can cause evaluation to rejoin the main ruleset. The code is shown in the next section, and is very simple.

How this is useful

With the rule split in this way, it is now easy to put a rule, in a new ruleset, to react to the owner:no_such_owner_id event. Here this is done in such a way that an attacker could not tell the difference between a valid owner_id and one which did not correspond to an actual owner pico.

In line 4 the rule finds a "honeypot" pico (which will not accept any password as correct) to which the root pico has a subscription. In line 7 it raises the owner:eci_found event so that the main ruleset will treat this phony owner pico exactly the way it treats a real one. The endpoint will not be able to differentiate between the two cases.

  rule provide_phony_eci{
    select when owner no_such_owner_id
    pre {
      eci = subs:established("Tx_role","honeypot").head(){"Tx"};
    }
    fired{
      raise owner event "eci_found" attributes event:attrs.put({"eci":eci});
    }
  }

Attacker foiled

Shown here is the directive returned to the endpoint (say, our hypothetical attacker using curl to POST an event seeking an ECI) for an incorrect owner_id:

$ curl -d "owner_id=roof" -X POST http://localhost:8088/sky/event/VA1qz6i68SfzMNM689Friu/hack/owner/eci_requested
{"directives":[
  {"options":{"eci":"AGqRi2utAJKmPPtQEN1xCX"},
   "name":"Returning eci from owner name",
   "meta":{"rid":"io.picolabs.account_management",
           "rule_name":"provide_temporary_owner_eci",
           "txn_id":"cjizyyvoe001blynogso15gqk",
           "eid":"hack"
          }
  }
]}

For comparision, here is the same POST request, this time for a known owner_id:

$ curl -d "owner_id=root" -X POST http://localhost:8088/sky/event/VA1qz6i68SfzMNM689Friu/hack/owner/eci_requested
{"directives":[
  {"options":{"eci":"EzMzsAdMJ5TubEu5vZSFht"},
   "name":"Returning eci from owner name",
   "meta":{"rid":"io.picolabs.account_management",
           "rule_name":"provide_temporary_owner_eci",
           "txn_id":"cjj06jqbr0007nfnogpph7l41",
           "eid":"hack"
           }
  }
]}

An attacker cannot differentiate between these two results.

Copyright Picolabs | Licensed under Creative Commons.