Skip to content

threatgrid/scopula

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

60 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

https://img.shields.io/clojars/v/scopula.svg

scopula

Scopulae, or scopula pads, are dense tufts of hair at the end of a spiders’s legs

https://en.wikipedia.org/wiki/Scopulae

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:

usersfull access to users resource
users/profileaccess to users profile only
users/profile:readaccess to users profile read-only
users/profile/email:writeaccess 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.

Usage

Basic functionalities

is-scope-format-valid?

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/[email protected]/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")

is-subscope?

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")

access-granted and scopes-superset?

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"}))))

root-scope

Returns the root-scope part of a scope

(= (root-scope "foo/bar:read")
   "foo")

is-root-scope?

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-scopes

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"}))

Set-like API

add-scope, or scope-cons

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"})))

scope-union

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")

scope-disj

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)}))))

scope-difference

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")

scopes-superset?

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"}))))

scopes-subset?

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"}))))

scopes-intersection

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")

scopes-missing

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"}))))

scopes-expand

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"}})))))

scopes-length

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"}))))

scopes-compress

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.")))

Notes

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

License

Copyright © 2019- Cisco

Distributed under the Eclipse Public License either version 1.0 or (at your option) any later version.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Contributors 4

  •  
  •  
  •  
  •