Scopulae, or scopula pads, are dense tufts of hair at the end of a spiders’s legs
A Clojure library designed to manage a scope convention to handle fine grained authorization access.
OAuth2 make all the authorization access pass through a single dimension: the scopes.
scopes
are case sensitive strings without any space that represent and
authorization access. From OAuth2 RFC
(https://tools.ietf.org/html/rfc6749#section-3.3):
The value of the scope parameter is expressed as a list of space- delimited, case-sensitive strings. The strings are defined by the authorization server. If the value contains multiple space-delimited strings, their order does not matter, and each string adds an additional access range to the requested scope.
scope = scope-token *( SP scope-token ) scope-token = 1*( %x21 / %x23-5B / %x5D-7E )
In order to manage a fine grained authorizations this lib use a convention for scope formats. For example, we often need to distinguish between a full scope that will provide full access to some resource, and read-only access. Sometimes we also want to limit the access to some sub-resource. Here are some example for our convention:
users | full access to users resource |
users/profile | access to users profile only |
users/profile:read | access to users profile read-only |
users/profile/email:write | access to users profile only email write-only |
Mainly :
is only authorized to split between access read=/=write=/=rw
(nothing implies rw)
Sub resources are separated by /
we can
This library provide helper functions to check that users scope will also grants
users/profile/email
and users/profile:read
We also provide helpers to normalize set of scopes:
>>> (normalize-scopes #{\"users\" \"users/profile/email:read\" \"admin\"})
#{\"users\" \"admin\"}
as users/profile/email:read
is redundant it was removed.
Note scopes are meant to be used in an OAuth2 access in mind and thus are generally manipulated as a set of scopes.
Scopes that do not have any subpath are called root scopes.
This is important because it is easy to add some scopes to a set of scopes. But it is generally impossible to remove just a sub-scope as it would mean we should know all the sub-paths of some root-scope and add the difference. Scopes are additive by their nature.
Return true if the string is a valid scope for our convention,
(is-scope-format-valid? "foo")
(is-scope-format-valid? "foo/bar")
(is-scope-format-valid? "foo-bar")
(is-scope-format-valid? "foo.bar")
(is-scope-format-valid? "foo/bar:read")
(is-scope-format-valid? "foo/bar:write")
(is-scope-format-valid? "foo/bar:rw")
(is-scope-format-valid? "foo/bar@hsome.dns/sub/url")
(not (is-scope-format-valid? "foo/bar:query"))
(not (is-scope-format-valid? "foo/bar query"))
(not (is-scope-format-valid? "foo/bar\nquery"))
(not (is-scope-format-valid? "https://hsome.dns/sub/url")
Return true if the provided scope is a subscope of the the second one
(is-subscope? "foo" "foo")
(is-subscope? "foo:read" "foo")
(is-subscope? "foo/bar:read" "foo")
(is-subscope? "foo/bar:read" "foo/bar")
(is-subscope? "foo/bar:read" "foo:read")
(not (is-subscope? "root/foo" "foo")
Return true
if the first scopes contains all scopes of the second argument.
(testing "subset is accepted"
(is (access-granted #{"foo"} #{"foo"})
"an identical set of scopes should match")
(is (not (access-granted #{"foo"} #{"foo" "bar"}))
"A single scope when two are required should not be accepted")
(is (not (access-granted #{"bar"} #{"foo"})))
(is (access-granted #{"foo" "bar"} #{"foo"}))
(is (access-granted #{"foo" "bar"} #{"foo" "bar"}))
(is (not (access-granted #{"foo" "bar"} #{"foo" "bar" "baz"}))))
(testing "superpath are accepted"
(is (not (access-granted #{"foo/bar"} #{"foo"})))
(is (not (access-granted #{"foo/bar/baz"} #{"foo"})))
(is (not (access-granted #{"foobar/baz"} #{"foo"}))))
(testing "access are respected"
(is (access-granted #{"foo"} #{"foo/bar:read"} ))
(is (access-granted #{"foo"} #{"foo/bar/baz:write"}))
(is (access-granted #{"foo"} #{"foo/bar/baz:rw"} ))
(is (access-granted #{"foo"} #{"foo/bar/baz:rw"} ))
(is (access-granted #{"foo:read"} #{"foo/bar/baz:read"} ))
(is (not (access-granted #{"foo:read"} #{"foo/bar/baz:write"})))
(is (access-granted #{"foo" "bar"} #{"foo/bar:read"}))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:write"}))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:rw"} ))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:rw"} ))
(is (access-granted #{"foo:read" "bar"} #{"foo/bar/baz:read"} ))
(is (not (access-granted #{"foo:read" "bar"} #{"foo/bar/baz:write"})))
(is (access-granted #{"foo" "bar"} #{"foo/bar:read" "bar"} ))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:write" "bar"}))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:rw" "bar"} ))
(is (access-granted #{"foo" "bar"} #{"foo/bar/baz:rw" "bar"} ))
(is (access-granted #{"foo:read" "bar"} #{"foo/bar/baz:read" "bar"}))
(is (not (access-granted #{"foo:read" "bar"} #{"foo/bar/baz:write" "bar"}))))
Returns the root-scope part of a scope
(= (root-scope "foo/bar:read")
"foo")
Returns true if the scope is a root-scope (access are authorized)
(is (is-root-scope? "foo"))
(is (is-root-scope? "foo:read"))
(is (not (is-root-scope? "foo/bar:read")))
(is (not (is-root-scope? "foo/bar")))
Normalize a set of scopes, remove all duplicates, and merge scopes with all
possible accesses, normalize-scopes
is idempotent:
(= identity (comp normalize-scopes normalize-scopes))
(= #{"foo/bar"}
(sut/normalize-scopes #{"foo/bar/baz:read"
"foo/bar:write"
"foo/bar"}))
(= #{"foo/bar"}
(sut/normalize-scopes #{"foo/bar:read"
"foo/bar:write"
"foo/bar/tux"}))
(= #{"foo/bar" "root"}
(sut/normalize-scopes #{"foo/bar:read"
"foo/bar:write"
"foo/bar/tux"
"root"}))
Add a scope to a set of scopes, and take cares of normalizing the result.
(is (= #{"foo" "bar"}
(add-scope "bar" #{"foo"})))
(is (= #{"foo"}
(add-scope "foo:read" #{"foo:write"})))
(is (= #{"foo"}
(add-scope "foo/bar:read" #{"foo"})))
Union of two set of scopes
(is (= #{"root2" "foo/bar" "root1"}
(sut/scope-union #{"foo/bar:read" "root2"}
#{"foo/bar:write" "root1"}))
"Should union the scopes and take care of normalization")
remove one scope from a set of scopes
(is (= #{}
(sut/scope-disj #{"foo/bar" "foo/baz:read"} "foo")))
(is (= #{"foo/baz:read"}
(sut/scope-disj #{"foo/bar" "foo/baz:read"} "foo/bar")))
(is (= #{"foo/bar:write"}
(sut/scope-disj #{"foo/bar"} "foo:read")))
(is (= {:ex-msg
"We can't remove a sub subscope of some other scope (access part is still supported)",
:ex-data {:scope "foo/bar/quux", :conflicting-scope "foo/bar"}}
(try (sut/scope-disj #{"foo/bar" "foo/baz:read"} "foo/bar/quux")
(catch Exception e
{:ex-msg (.getMessage e)
:ex-data (ex-data e)}))))
Remove scopes from the second set of scopes to the first set of scopes. Notice, this function is not total. For some entry it could throw an exception.
For example, there is no way value for: (scope-difference #{"foo"} #{"foo/bar"})
Because it would mean that we should be able to know all subscopes used in our application. So this operation is not supported
(is (= #{} (sut/scope-difference #{"foo:read"}
#{"foo:read"})))
(is (= #{"baz"}
(sut/scope-difference #{"foo" "bar" "baz"}
#{"foo" "bar"})))
(is (= #{"baz" "bar/bar-1:write"}
(sut/scope-difference #{"foo" "bar/bar-1" "baz"}
#{"foo" "bar:read"})))
(is (= #{"foo/foo-1:write"}
(sut/scope-difference #{"foo:read" "foo/foo-1"}
#{"foo:read"})))
(is (= {:ex-msg
"We can't remove a sub subscope of some other scope (access part is still supported)",
:ex-data {:scope "foo/foo-1/sub:read", :conflicting-scope "foo/foo-1"}}
(try (sut/scope-difference #{"foo/foo-1"}
#{"foo/foo-1/sub:read"})
(catch Exception e
{:ex-msg (.getMessage e)
:ex-data (ex-data e)}))))
(is (= #{"foo/bar"} (sut/scope-difference
#{"foo/bar:read"
"foo/bar:write"
"baz/quux"}
#{"baz:read"
"baz:write"}))
"Should take care of normalization on both inputs and outputs")
Check if the first set of scopes contains the second set of scopes. Mainly it is true if the first set of scopes provide access to all scopes of the second set of scopes.
(deftest scopes-superset-test
(testing "root scopes"
(is (sut/scopes-superset? #{} #{}))
(is (sut/scopes-superset? #{"foo"} #{}))
(is (sut/scopes-superset? #{"foo" "bar"} #{}))
(is (sut/scopes-superset? #{"foo" "bar"} #{"foo"}))
(is (sut/scopes-superset? #{"foo" "bar"} #{"foo" "bar"}))
(is (not (sut/scopes-superset? #{"foo" "bar"} #{"foo" "bar" "baz"}))))
(testing "sub scopes"
(is (sut/scopes-superset? #{"foo"} #{"foo/foo-1"}))
(is (sut/scopes-superset? #{"foo"} #{"foo/foo-1:read"}))
(is (sut/scopes-superset? #{"foo"} #{"foo:read"}))
(is (sut/scopes-superset? #{"foo"} #{"foo:read" "foo/foo-1"}))
(is (not (sut/scopes-superset? #{"foo:read"}
#{"foo:read" "foo/foo-1"}))))
(testing "un-normalized scopes"
(is (sut/scopes-superset? #{"foo:read" "foo:write"}
#{"foo:read" "foo/foo-1"}))))
Test if a set of scopes is contained by another set of scopes. Mainly it is true if the second set of scopes provide access to all scopes of the first set of scopes.
(deftest scopes-subset-test
(testing "root scopes"
(is (sut/scopes-subset? #{} #{}))
(is (sut/scopes-subset? #{} #{"foo"}))
(is (sut/scopes-subset? #{} #{"foo" "bar"}))
(is (sut/scopes-subset? #{"foo"} #{"foo" "bar"}))
(is (sut/scopes-subset? #{"foo" "bar"} #{"foo" "bar"}))
(is (not (sut/scopes-subset? #{"foo" "bar" "baz"} #{"foo" "bar"}))))
(testing "sub scopes"
(is (sut/scopes-subset? #{"foo/foo-1"} #{"foo"}))
(is (sut/scopes-subset? #{"foo/foo-1:read"} #{"foo"}))
(is (sut/scopes-subset? #{"foo:read"} #{"foo"}))
(is (sut/scopes-subset? #{"foo:read" "foo/foo-1"} #{"foo"}))
(is (not (sut/scopes-subset? #{"foo:read" "foo/foo-1"}
#{"foo:read"}))))
(testing "un-normalized scopes"
(is (sut/scopes-subset? #{"foo:read" "foo/foo-1"}
#{"foo:read" "foo:write"}))))
Returns the intersection between two set of scopes.
(deftest scopes-interception-test
(is (= #{}
(sut/scopes-intersection #{"bar:read"}
#{"bar:write"})))
(is (= #{"foo/bar:write"}
(sut/scopes-intersection #{"foo:write"}
#{"foo/bar"})))
(is (= #{"foo/bar:write"}
(sut/scopes-intersection #{"foo:write" "bar:read"}
#{"foo/bar" "bar:write"})))
(is (= #{"bar" "foo/bar:write"}
(sut/scopes-intersection #{"foo:write" "bar:read" "bar:write"}
#{"foo/bar" "bar"}))
"Normalize input and output")
Returns elements of the first set of scopes removing those in the second set of scopes.
While close to scope-difference
the behavior is slightly different. Sometime
you want to provide the list of scopes in a set and not construct new one when
presenting messages to the customer.
(deftest scopes-missing-test
(is (= #{"foo/foo-1"}
(sut/scopes-missing #{"foo:read" "foo/foo-1"}
#{"foo:read"})))
(is (= #{} (sut/scopes-missing #{"foo:read"}
#{"foo:read"})))
(is (= #{"baz"}
(sut/scopes-missing #{"foo" "bar" "baz"}
#{"foo" "bar"})))
(is (= #{"baz" "bar/bar-1"}
(sut/scopes-missing #{"foo" "bar/bar-1" "baz"}
#{"foo" "bar:read"}))))
The scopes-expand
takes a set of scopes and a scope-aliases set and returns the
expanded raw scopes.
(deftest scopes-expand-test
(is (= #{"foo:write" "bar"}
(sut/scopes-expand #{"+admin"} {"+admin" #{"foo:write" "bar"}})))
(is (= #{"foo:write" "bar" "baz"}
(sut/scopes-expand #{"+admin" "baz"} {"+admin" #{"foo:write" "bar"}})))
(is (= #{"foo:write" "bar" "baz" "subrole+x"}
(sut/scopes-expand #{"+admin" "subrole+x" "baz"} {"+admin" #{"foo:write" "bar"}
"+x" #{"x" "y"}})))
(is (= #{"admin"}
(sut/scopes-expand #{"admin"} {"admin" #{"foo"}}))
"scope expansion should only be performed on scope aliases starting with +")
(testing "missing scope alias"
(is (thrown? clojure.lang.ExceptionInfo
(sut/scopes-expand #{"+admin"} {})))
(is (thrown? clojure.lang.ExceptionInfo
(sut/scopes-expand #{"+admin"} {"admin" #{"foo"}})))))
This functions takes a set of scopes as input and return the sum of the lengths of all scopes it contains.
(deftest scopes-length-test
(is (= 0 (sut/scopes-length #{})))
(is (= 3 (sut/scopes-length #{"foo"})))
(is (= 9 (sut/scopes-length #{"foo" "bar" "baz"})))
(is (= 22 (sut/scopes-length #{"foo/bar/baz" "foo" "foo:read"})))
(is (= 11 (sut/scopes-length #{"foo-bar-baz"}))))
A function using a fast heuristic to try to use scopes aliases to reduce the size of a set of scopes. It is more important to have a fast non optimal result than to lose time trying to achieve optimal compression as this appear to be an NP-complete problem.
This function is intended to be used in places where we don’t care too much about reaching optimal compression but just doing a best effort in finding a shorter scopes set description that could easily be expanded to the full set of scopes.
(deftest scopes-compress-test
(is (= #{"+admin" "baz"}
(sut/scopes-compress #{"foo" "bar" "baz"}
{"+admin" #{"foo" "bar"}
"+foo" #{"foo"}}))
"This test check that the biggest matching alias is preferred to improve compression")
(is (= #{"+admin" "+baz" "x"}
(sut/scopes-compress #{"foo" "bar" "baz" "x"}
{"+admin" #{"foo" "bar"}
"+baz" #{"baz"}})))
(is (= #{"+admin" "+baz" "baz:write" "x"}
(sut/scopes-compress #{"foo" "bar" "baz" "x"}
{"+admin" #{"foo" "bar"}
"+baz" #{"baz:read"}})))
(is (= #{"foo" "bar" "baz" "+admin"}
(sut/scopes-compress #{"foo" "bar" "baz" "x" "very-very-long-scope-name"}
{"+admin" #{"x" "very-very-long-scope-name"}
"+baz" #{"foo" "bar" "x"}}))
(str "Example of potentially missing an opportunity to compress,"
" but show that the length of the string of scopes is more important"
" than the number of scopes."
" This is still a pretty good-enough for most intended use cases.")))
The functions starting with repr
takes scope representation as arguments. You
shall generally not use them. This is why I dont mention them in this document.
Still they are publicly exposed for advanced lib usage.
For a lot more examples take a look at: ./test/scopula/core_test.clj
Copyright © 2019- Cisco
Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.