Security using Goal Expansion

Recently I had a problem at work where I had an existing set of data and needed to put security around it. The end goal was to restrict access to certain documents based on the user and their permissions. The issue is that there may be several places where the documents are accessed from and all of then need to be secured. Using Prolog, this can be done quite easily by using goal_expansion/2 to create a wrapper that applies to all calls to a predicate.

Consider that there are documents in a system, with different security requirements. Here is a very small set of documents for this example.

% Document (id, title, author, filename).
document(
    1,
    'Something the Government should not Know',
    ['Nick Freedom','Ava Openness'],
    'something_the_government_should_not_know.pdf'
).

document(
    2,
    'The Lives Of Celebrities',
    ['Evie Scandlemaker'],
    'the_lives_of_celebrities.pdf'
).

document(
    3,
    'Hidden Conspiracy Theories of the Roman Empire',
    ['Hidius Conspirius'],
    'hidden_theories_of_the_roman_empire.pdf'
).

document(
    4,
    'Top Secret Spy Circuits',
    ['Mr Black'],
    'DsnEhxwxbViVzUIM.pdf'
).

Then there are facts about who can access which document. user_document/2, simply maps a username to a document id or set of document ids.

user_document('Mr Black', 4).
user_document('The Government', D) :- member(D, [2, 3]).
user_document('Joe Public', D) :- member(D, [1, 2]).

At this point there is no way to restrict the access of the documents because any call to document/4 will be allowed, and we don't have access to the current user either.

So to restrict it, goal_expansion/2 will be used to replace any call to document/4 with a check with the current user against the user_document/2 facts.

The first thing to do is force a user to be set, this is done by setting the user in a global variable that will need to be set when calling. The b_getval/2 call will only allow variables that have been set within the call that is currently being made. Security around users is a different matter not covered here.

So to access the documents a call to b_setval(user, User) will need to be made to set the user. To achieve this a test rule is sufficeint.

document_list(User, document(Id, Title, Authors, Filename)) :-
    b_setval(user, User),
    document(Id, Title, Authors, Filename).

Now to create the security wrapper. Goal expansion is a bit fickle but it is easy if a few rules are remembered.

  1. Any goals being expanded must be after the goal_expansion/2 rule in the source.
  2. If any goals are called within the goal_expansion/2 rules, they must be before the goal expansion in the source.
  3. If you want to call the predicate being expanded before it is manipulated, then that call must be before the goal_expansion/2 rule in the source.

That means that the following code must be placed before the document/4 predicates or the goal expansion won't take place.

has_document_access(document(Id, Name, Authors, Filename)) :-
    b_getval(user, User),
    nonvar(User),
    user_document(User, Id),
    document(Id, Name, Authors, Filename).

goal_expansion(
        document(Id, Name, Authors, Filename),
        has_document_access(
            document(Id, Name, Authors, Filename)
        )).

goal_expansion/2 works by checking every call that is compliled after the expansion rule is defined then if the first parameter matches, that call is replaced with the second parameter.

The has_document_access/1 rule works as follows:

  • Get the user value from the variable.
  • Make sure that the user value is actually set to a value and not a variable, or all users will be checked against.
  • Check that the user has access to the requested document (this works in reverse as well if the document is not set).
  • Retrieve the document details!

Now when the document/4 is called directly, the call fails.

?- document(Id, Title, Authors, Filename).
false.

And when we use the test predicate with a user (say Joe Public), only the documents that can be accessed are unified.

?- document_list('Joe Public', D).
D = document(1, 'Something the Government should not Know', ['Nick Freedom', 'Ava Openness'], 'something_the_government_should_not_know.pdf') ;
D = document(2, 'The Lives Of Celebrities', ['Evie Scandlemaker'], 'the_lives_of_celebrities.pdf').

?-

The question is, how do we know if the expansion worked or not? One easy way is to do a listing of a rule that calls the goal, eg:

?- listing(document_list/2).
document_list(User, document(Id, Title, Authors, Filename)) :-
    b_setval(user, User),
    has_document_access(document(Id,
                                Title,
                                Authors,
                                Filename)).

true.

?-

As you can see the document_list/2 test rule has changed and now calls has_document_access/1 instead of document/4. This transformation will take place if you run from the Prolog shell or if you use a meta call.

This example is a bit derived, but this kind of substitution is quite handy. goal_expansion/2 and term_expansion/2 are designed as refactoring tools when a some part of a program needs to be changed but is already used several times. However, they are key to certain libraries to make code efficient as well.

That's all!