Thursday, February 18, 2016

Revealing OpenAM's Policy Enforcement Model

In my post on migrating OAM 10g policies to OpenAM I promised to outline how I clarified my understanding of OpenAM's policy enforcement model. The policy evaluation endpoint was the quickest way to test. But right up front you quickly learn that an authenticated user token is required. If it isn't included your results are always negative. So to test I always have an authenticated user token.

When considering policy decisions I have the following variables to consider after assuming that a request URL matches a given policy's resource URL.

  1. subject requirement is satisfied
  2. condition exists
  3. condition requirement is satisfied
  4. corresponding action is enabled
  5. action allows access
So five variables initially. If I start with a policy that has no condition then this drops to three variables. 

First Test: No Conflicting Action Outcomes

If we look at this as a truth table with zero being false and one being true we then need to test the following combinations and see what their outcome is. For the subject satisfied I used a user and group subject with user = demo and I had two users in OpenAM with usernames of demo and test. As noted to the left of the table, test represents subject satisfied being false and demo to it being true.

I also noted that when an action matching that of a request is not enabled the policy essentially doesn't match and makes no contribution to the outcome. What that meant for output results I didn't know prior to the test.

            action allows --------+
                                  |
         action enabled ------+   |
                              |   |
      subject satisfied --+   |   |
                          |   |   |
                          v   v   v
      - - - - - - - -   +---+---+---+
                        | 0 | 0 | 0 | no match
                        +---+---+---+
        test user       | 0 | 0 | 1 | no match
                        +---+---+---+
                        | 0 | 1 | 0 | deny
                        +---+---+---+ 
                        | 0 | 1 | 1 | allow
      - - - - - - - -   +---+---+---+
                        | 1 | 0 | 0 | no match
                        +---+---+---+
        demo user       | 1 | 0 | 1 | no match
                        +---+---+---+
                        | 1 | 1 | 0 | deny
                        +---+---+---+
                        | 1 | 1 | 1 | allow
      - - - - - - - -   +---+---+---+

For testing deny and allow I crafted the following two policies.

name: test-allow
URL: http://test.org/allow
subject: user = demo
actions: GET, POST, PUT enabledallow checked.  

name: test-deny
URL: http://test.org/deny
subject: user = demo
actions: GET, PUT enableddeny checked; POST enabledallow checked

Then with a nodejs script that reads a file of usernames and passwords and another file that specifies a list of URLs, I evaluated access via the policy evaluation rest endpoint for test and demo against the following URLs. That third one was intentionally meant not to match any policy to see what results it provided.

  1. http://test.org/allow
  2. http://test.org/deny
  3. http://test.org/no-match

The console output from running the script is shown below. The additional authentication is for the admin user that can hit the rest endpoint. In following output I'll only include the json results.



localhost:policy-tester boydmr$ node PT.js
loading ptConf.json
using cookie name of: iPlanetDirectoryPro
loading users file: ./users.json
loading policies file: urls.json
---> calling: http://ident-local.lds.org:8080/sso/json/authenticate
---> calling: http://ident-local.lds.org:8080/sso/json/authenticate
---> calling: http://ident-local.lds.org:8080/sso/json/authenticate
---> Acquired token.
---> Acquired token.
---> Acquired token.
{
    "user": "demo",
    "result": [
        {
            "http://test.org/allow": {
                "POST": true,
                "GET": true,
                "PUT": true
            }
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": {
                "POST": true,
                "GET": false,
                "PUT": false
            }
        }
    ]
}
{
    "user": "test",
    "result": [
        {
            "http://test.org/allow": null
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": null
        }
    ]
}

What I learned from this test is:
  1. Only methods that are enabled are returned in the result set.
  2. Where an action is marked to deny, a value of false for that action is returned.
  3. Where an action is marked to allow, a value of true for that action is returned.
  4. Where there is no policy matching a request either due to subject not satisfied or URL not matching a null object is returned. 
  5. When a null object is returned or a value of false is returned for an action the result should be to deny access to the request.

Second Test: When Action Outcomes Conflict

I then wanted to find out what happens when two policies match on URL, subject, and enabled action but the outcomes of that action conflict: allow versus deny. I created the following policy and ran the test again. As you can see from the highlighted portion, when allow and deny conflict, deny wins.

name: test-conflict
URL: http://test.org/all*
subject: user = demo
actions: POST enabled, deny checked. 

{
    "user": "test",
    "result": [
        {
            "http://test.org/allow": null
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": null
        }
    ]
}
{
    "user": "demo",
    "result": [
        {
            "http://test.org/allow": {
                "POST": false,
                "GET": true,
                "PUT": true
            }
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": {
                "POST": true,
                "GET": false,
                "PUT": false
            }
        }
    ]
}

Third Test: Resource Matches, Subject Doesn't

I changed the test-conflict policy to match the test user in the subject. The results now show as follows. Note that the conflict is no longer occurring and POST for the demo user is now true.

name: test-conflict
URL: http://test.org/all*
subject: user = test
actions: POST enableddeny checked. 



{
    "user": "demo",
    "result": [
        {
            "http://test.org/allow": {
                "POST": true,
                "GET": true,
                "PUT": true
            }
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": {
                "POST": true,
                "GET": false,
                "PUT": false
            }
        }
    ]
}
{
    "user": "test",
    "result": [
        {
            "http://test.org/allow": {
                "POST": false
            }
        },
        {
            "http://test.org/no-match": null
        },
        {
            "http://test.org/deny": null
        }
    ]
}

Final Test: Using Conditions & Narrowing Access

Once I understood OpenAM's policy matching and enforcement model I now turned to one last hurdle that was critical to solve before we could migrate our policies from OAM to OpenAM. When a narrowly scoped policy is at a high point such as the root of a domain like test.org/* and a policy lower down such as test.org/stuff/* broadened access, the outcome is answered above; the actions will be aggregated and hence the broader audience will indeed have access only in the lower URL space.

But what of the opposite? Can we have a broad subject set at the top such as Authenticated Users and narrow scope below to a smaller set of users perhaps via a condition? As I thought about this the deny outcome of actions seemed to suddenly jump out at me. I could define two policies at that lower location. The first would grant access to the narrowed set. That alone would make no difference since the higher policy's outcomes would be additive and everyone would have access. But if the higher policy weren't there we would need it to explicitly open up access for the target audience. The key to answering this puzzle was the second policy. It was identical except in two aspects: it would wrap the condition in a logical NOT and change the action outcome to deny.

To test this I crafted the following three policies.

name: narrow-broad-top
URL: http://narrow.org/*
subject: Authenticated Users
actions: GET enabledallow checked.

name: narrow-restricted
URL: http://narrow.org/stuff/*
subject: Authenticated Users
condition: ldap-filter: (role=admin)
actions: GET enabledallow checked. 

name: narrow-restricted-deny
URL: http://narrow.org/stuff/*
subject: Authenticated Users
condition: NOT( ldap-filter: (role=admin) )
actions: GET enableddeny checked. 

The problem is I needed to have an ldap server to test these policies. Turns out this was simple to provide.

A Hand Crafted LDAP Server

I just happened to have run across a sweet nodejs library not too long ago that came to mind. It is ldapjs found at http://ldapjs.org/index.html. In about 30 minutes I had a prototype mock LDAP server whose source is included at the end of this post. And ldapsearch thought it was talking to a real ldap server. (See the comment section of the source.) The question was would OpenAM?

Activating LDAP Filter Use in Policy Evaluation

To use LDAP filters in policies in a realm OpenAM must first be told where the LDAP server is located to which it can defer evaluation of those filters. This is done on the realm in its Policy Configuration service. I configured mine with the following values based upon the LDAP server source for the bind user, object classes for users, and the search attribute.

Primary LDAP Server: 127.0.0.1:1389
User base DN: o=lds
Roles base DN: o=lds
Bind DN: cn=root
Bind Password: secret
Org search filter: (objectclass=sunismanagedorganization)
User search filter: (objectclass=inetorgperson)
User search scope: SUB
Role search scope: SUB
User search attribute: cn
all other values left as defaults.

Once it was running I was ready to try my test. When the policy evaluations executed the calls to the LDAP server appeared on its console indicating as desired that only the demo user matched the filter.

localhost:ldapjs-eval boydmr$ node server.js
users = {
  "demo": {
    "dn": "cn=demo, ou=users, o=lds",
    "attributes": {
      "cn": "demo",
      "uid": "demo",
      "role": "admin",
      "objectclass": "inetorgperson"
    }
  },
  "test": {
    "dn": "cn=test, ou=users, o=lds",
    "attributes": {
      "cn": "test",
      "uid": "test",
      "objectclass": "inetorgperson"
    }
  }
}
test LDAP server up at: ldap://0.0.0.0:1389
--   bind: cn=root
--   bind: cn=root
-- C-0 search: (&(objectclass=inetorgperson)(cn=demo)(role=admin))
-- C-0         demo = true
-- C-1 search: (&(objectclass=inetorgperson)(cn=test)(role=admin))
--   bind: cn=root

-- C-2 search: (&(objectclass=inetorgperson)(cn=test)(role=admin))

Conclusions


The results of test are shown below. Note that we do indeed narrow access for the demo user how has a role of admin while the test user does not. By defining these tests and then gathering their results from the policy evaluation rest endpoint I now have a clearly defined path for migrating out policies from OAM to OpenAM. Two key aspects that I have not covered of that strategy is how authorization expressions are translated and how OAMs URLs are translated in view of their richer meta character patterns. I'll see if I can't cover those topics soon.

{
    "user": "demo",
    "result": [
        {
            "http://narrow.org/stuff/for/admins": {
                "GET": true
            }
        },
        {
            "http://narrow.org/something": {
                "GET": true
            }
        }
    ]
}
{
    "user": "test",
    "result": [
        {
            "http://narrow.org/stuff/for/admins": {
                "GET": false
            }
        },
        {
            "http://narrow.org/something": {
                "GET": true
            }
        }
    ]
}



LDAP Server Source

/** * Quick and simple ldap server with two users having cn of: demo, and test. You can search for any of * them with: * * ldapsearch -H ldap://localhost:1389 -x -D cn=root -w secret -LLL -b "o=lds" cn=<cn-here>
 * * Or search for all of them with: * * ldapsearch -H ldap://localhost:1389 -x -D cn=root -w secret -LLL -b "o=lds" objectclass=* * * Created by boydmr on 2/4/16. */
var ldap = require('ldapjs');
//var express = require('expressjs');
var server = ldap.createServer();

// map of user attributes by cnvar users = {};
var cn = 'demo';

function createUser(cn, role) {
  users[cn] = {
    dn: 'cn=' + cn + ', ou=users, o=lds',
    attributes: {
      cn: cn,
      uid: cn,
      role: role,
      objectclass: 'inetorgperson'    }
  };
}

createUser('demo', 'admin');
createUser('test');

console.log("users = " + JSON.stringify(users, null, 2));

// add support for bindingserver.bind('cn=root', function(req, res, next) {
  if (req.dn.toString() !== 'cn=root' || req.credentials !== 'secret') {
    // log failed attemtps to bind    console.log("!    bind: " + req.dn.toString() + " pwd: " + req.credentials);
    return next(new ldap.InvalidCredentialsError());
  }

  // log that we were bound to successfully and show username  console.log("--   bind: " + req.dn.toString());
  res.end();
  return next();
});

// filter that ensures we only handle non-anonymous requestsfunction authorize(req, res, next) {
  if (!req.connection.ldap.bindDN.equals('cn=root'))
    return next(new ldap.InsufficientAccessRightsError());

  return next();
}

var connId = 0;

server.search('o=lds', authorize, function(req, res, next) {
  var cid = 'C-' + connId++;
  console.log("-- " + cid + " search: " + req.filter.toString());

  Object.keys(users).forEach(function(k) {
    if (req.filter.matches(users[k].attributes)) {
      console.log("-- " + cid + "         " + k + " = true");
      res.send(users[k]);
    }

  });

  res.end();
  return next();
});

// now start listeningserver.listen(1389, function() {
  console.log('test LDAP server up at: %s', server.url);
});


2 comments:

  1. Thanks for sharing your findings, Mark.

    I see the experimental character of your investigation. A top-level allow rule is maybe not the most common thing to do. In practice you'd stick to the implicit top level deny, narrowing allow scopes.

    Testing could be simplified I suppose by using OpenAM's embedded data store (OpenDJ), groups and group membership for "Final Test" instead of separate LDAP server.

    I like the fact you were using the REST endpoint for policy evaluation. You probably also created and managed policies by via the REST endpoints as well.

    ReplyDelete
  2. Hi Joachim. We have several domains that have large swaths of URL space that are open content and other URL subsets that are apps. The content sections are intentionally open for everyone to read while the apps are generally restricted to specific audiences. But since they reside on the same domain we then run into this scenario. Regardless, it is useful to understand if and how openam can fulfill such a requirement. And as shown here it can.

    As for LDAP server, I'm not familiar with OpenDJ as much as with OpenAM. I had heard that it had rest endpoints and I should look at that to see if it could be used for automated tests like these will ultimately be. I had planned on adding rest endpoints to this ldap script so that users could be injected in prior to each test and then removed. If OpenDJ has rest endpoints for that then it does simplify the environment.

    Yes you are right. We have our own tool for managing policies. In fact, it supports the concept of "lanes" of development allowing us to have different OpenAM clusters for one to many lanes and associate applications and policies between them. In that way an application can run in the "dev" lane, then migrate through our tool to the "test", "stage", "uat", and ultimately "prod" or whatever your flow might be. And our tool can diff policies between the environments so help us see why a problem exists in prod that isn't seen in stage for example. And OpenAM's rest endpoints are invaluable for this approach.

    Perhaps I should create a post on our tool at some point if there is interest.

    ReplyDelete