From 0813d32a516e723843b0ab00f9ecb02325d0b7d0 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 2 Apr 2021 18:13:26 -0300 Subject: [PATCH 01/32] Feat: First draft of attribute conditional DSL --- src/core/access/authorization.py | 0 src/core/access/test_authorization.py | 353 ++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 src/core/access/authorization.py create mode 100644 src/core/access/test_authorization.py diff --git a/src/core/access/authorization.py b/src/core/access/authorization.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/access/test_authorization.py b/src/core/access/test_authorization.py new file mode 100644 index 0000000..65d16ee --- /dev/null +++ b/src/core/access/test_authorization.py @@ -0,0 +1,353 @@ +# test_authorization.py + +# Requirements: +# A user tries to access a given resource. We need to check if it has enough +# access running a policy check under a context of available data. +# +# User policies are resolved through its associated Roles. +# +# Relationships: +# User 1 .. N Role +# Role 1 .. N Policy +# Policy 1 .. 1 Operation +# Policy 1 .. 1 Resource +# Policy 1 .. N Where Clauses +# +# Policy: +# - operation: [READ, CREATE, UPDATE, DELETE] +# - resource: [ACCOUNT] +# - conditionals: [resource.id = *] +# +# INTERPRETATION of a Policy: +# +# A role {r} +# is allowed to do {operation} +# on all instances of {resource} +# that satisfies {conditional[0]} or {conditional[1]} or {conditional[N-1]} +# +# SPECIFICATION of Conditionals: +# +# Vocabulary. These contains current implementation expected limitations and +# simplifications: +# primary ::= "resource" +# identifier ::= "id" +# selector ::= "*" | string +# attrref ::= primary "." identifier +# or_expr ::= "||" +# compr_op ::= "==" +# cond ::= attrref compr_op selector +# conds ::= (cond or_expr)* +# +# There are two available selectors: +# 1. All instances: "*" +# 2. Individual instances: "" +# +# There are some conditions to selectors: +# 1. All selector must **always** be alone. +# + +import string +from typing import List + + +valid_conditionals = [ + "resource.id == 128f12334hjg || resource.id == 12839712893791823", + "resource.id==128f12334hjg||resource.id==12839712893791823", + "resource.id==*", + "resource.id ==*", + "resource.id== *" +] + +simplified_conditionals = { + # This meanings that list[0] should be translated to list[1] + "selector conditional": [ + "resource.id==*||resource.id==8123798fcd89||resource.id==8197498127", + "resource.id == *" + ] +} + +invalid_conditionals = [ + "resource.id == 9284234 ||", + "resource.id = 1238912fd || resource.id ==182789123", + "resource.id 12381723", + "resource.id == 1928309182 resource.id = 12930918", + "resource.id == 81927398123 || resource.id = 9128398", + "resource .id == 12893789123", + "resource. id == 89071239", + "resource.id == 182937 12893 827381723 || resource.id == 1892 9283" + "resource.id == 18297389f|resource.id==1827398f", + "resouce.id == 1283971283ff || subject.id == 1891273987123", + "resource.created_at == 2319833012707 || resource.id == faf76bc7", + "resource.id == 8129370192ff || resource.id == 8123091283908", + "resource.id!=291f767d8bc" +] + + +def test_conditionals_split(): + input = [ + "expr || newexpr", + "expr||newexpr", + " expr || newexpr ", + "expr|newexpr", + "expr || newexpr||neewexpr||", + "ex pr || new expr || ", + " expr ||| newexpr ", + " expr|||newexpr ", + ] + + want = [ + ("expr", "newexpr"), + ("expr", "newexpr"), + ("expr", "newexpr"), + False, + False, + False, + False, + False, + ] + + got = [conditionals_split(sequence) for sequence in input] + assert got == want + + +def conditionals_split(sequence: str): + expressions = sequence.split("||") + if "" in expressions: + # meaning that we had a loose OR + return False + + parsed_expr = [ + expression.strip() for expression in expressions + ] + for expr in parsed_expr: + for char in expr: + if char.isspace(): + # illegal whitespaces + return False + + if char in set(string.punctuation): + # illegal characteres + return False + + return tuple(parsed_expr) + + +def test_parse_identifier(): + input = [ + "resource.id", + "resourceeee.id", + "r esource.id", + "resource .id", + " r esource.id", + "resource..id", + ] + want = [ + ("resource", ".id"), + ("resourceeee", ".id"), + (False,), + (False,), + (False,), + ("resource", "..id"), + ] + got = [parse_identifier(i) for i in input] + assert got == want + + +def parse_identifier(expression): + identifier = "" + for idx, token in enumerate(expression): + if token.isidentifier(): + identifier += token + else: + if token == ".": + return identifier, expression[idx:] + return (False,) + + +def test_parse_reference(): + input = [ + ".id", + " .id", + " id", + ".id ", + "..id..", + ".nope", + ".that!", + "[index]", + ".fine0", + ".name@", + ".na me", + ".name", + ] + + want = [ + "id", + False, + False, + False, + False, + "nope", + False, + False, + False, + False, + False, + "name" + ] + + got = [parse_reference(ref) for ref in input] + assert got == want + + +def parse_reference(reference_expr): + reference = "" + for idx, token in enumerate(reference_expr): + if idx == 0: + if token != ".": + return False + continue + + if token.isidentifier(): + reference += token + else: + return False + return reference + + +def test_parse_identifier_reference(): + input = [ + "resource.id", + "resourceeee.id", + "r esource.id", + "resource .id", + " r esource.id", + "resource.id", + "resource.name", + "resource.na me", + "resource.name!", + "rsrc!name", + ] + want = [ + ("RESOURCE", "ID"), + ("RESOURCEEEE", "ID"), + False, + False, + False, + ("RESOURCE", "ID"), + ("RESOURCE", "NAME"), + False, + False, + False + ] + got = [parse_identifier_reference(expr) for expr in input] + assert got == want + + +def parse_identifier_reference(expression): + identifier, *reference_expr = parse_identifier(expression) + if identifier is False: + return False + reference = parse_reference(*reference_expr) + if reference is False: + return False + + return (identifier.upper(), reference.upper()) + + +def test_parse_operator(): + input = [ + "==", + " ==", + "== ", + "=", + "<", + " <", + " > ", + "===", + ">=", + "!=", + " + ", + "--", + "/", + "=+", + "=-", + ] + want = [ + "==", + "==", + "==", + False, + "<", + "<", + ">", + False, + ">=", + "<=", + "!=", + False, + False, + False, + False, + False, + ] + got = [parse_operator(op) for op in input] + assert got == want + + +def parse_operator(operator_expr): + VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} + VALID_TOKEN = {token for operator in VALID_OPS for token in operator} + + operator = "" + found = False + for idx, token in enumerate(operator_expr): + if token in VALID_TOKEN: + # we need to properly *walk* precisely the number of characters + # to find a valid operator + if operator in VALID_OPS and not found: + # first time found an operator + found = True + if operator in VALID_OPS and found: + # we are dealing with ===, !==, >>, etc + return False + + # here we are with 1-token + operator += token + else: + if token.isspace(): + continue + else: + return False + + if operator in VALID_OPS: + return operator + return False + +def tst_parse_valid_conditional(): + input = [ + "resource.id == 128f12334hjg" + "resource.id == 128f12334hjg" + "resource.id ==12839712893791823", + "resource.id== 12839712893791823", + "resource.id==128f12334hjg", + "resource.id==*", + "resource.id ==*", + "resource.id== *" + ] + + want = [ + ("RESOURCE", "ID", "==", "128f12334hjg"), + ("RESOURCE", "ID", "==", "128f12334hjg"), + ("RESOURCE", "ID", "==", "12839712893791823"), + ("RESOURCE", "ID", "==", "12839712893791823"), + ("RESOURCE", "ID", "==", "128f12334hjg"), + ("RESOURCE", "ID", "==", "*"), + ("RESOURCE", "ID", "==", "*"), + ("RESOURCE", "ID", "==", "*"), + ] + + got = [parse_condition(cond) for cond in input] + + assert got == want + + From a2de890f34aa7e7c28a749f37754b53c93e0be28 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 5 Apr 2021 20:41:02 -0300 Subject: [PATCH 02/32] Improve: Finished parsing a policy statement --- permissions.py | 1 + src/core/access/test_authorization.py | 377 ++++++++++++++++++-------- 2 files changed, 270 insertions(+), 108 deletions(-) create mode 100644 permissions.py diff --git a/permissions.py b/permissions.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/permissions.py @@ -0,0 +1 @@ + diff --git a/src/core/access/test_authorization.py b/src/core/access/test_authorization.py index 65d16ee..70c9440 100644 --- a/src/core/access/test_authorization.py +++ b/src/core/access/test_authorization.py @@ -134,14 +134,16 @@ def conditionals_split(sequence: str): def test_parse_identifier(): input = [ + ".id", "resource.id", - "resourceeee.id", - "r esource.id", + " resourceeee.id", + "r es ource.id", "resource .id", " r esource.id", "resource..id", ] want = [ + (False,), ("resource", ".id"), ("resourceeee", ".id"), (False,), @@ -154,78 +156,110 @@ def test_parse_identifier(): def parse_identifier(expression): + # since it's the beginning, we can strip this, to avoid overly + # complicated iterations :-) + expression = expression.strip() identifier = "" for idx, token in enumerate(expression): if token.isidentifier(): identifier += token else: - if token == ".": + if token == "." and len(identifier) > 0: return identifier, expression[idx:] return (False,) def test_parse_reference(): input = [ - ".id", - " .id", - " id", - ".id ", + ".", + ".id==", + " .id>", + " id==", + ".id === ", "..id..", - ".nope", - ".that!", - "[index]", - ".fine0", - ".name@", - ".na me", - ".name", + ".nope =", + ".that!=", + "[index]>=", + ".fine0 ==", + ".name@ <=", + ".n a m e ===", + ".name >>", ] want = [ - "id", - False, - False, - False, - False, - "nope", - False, - False, - False, - False, - False, - "name" + (False,), + ("id", "=="), + (False,), + (False,), + ("id", "=== "), + (False,), + ("nope", "="), + ("that", "!="), + (False,), + (False,), + (False,), + (False,), + ("name", ">>"), ] got = [parse_reference(ref) for ref in input] assert got == want +VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} +VALID_OPS_TOKEN = {token for operator in VALID_OPS for token in operator} + + def parse_reference(reference_expr): reference = "" + parsing_idx = -1 + + # first we try to read all valid tokens after "." and before a valid + # operator for idx, token in enumerate(reference_expr): if idx == 0: + # first token **must** be a ".", no other one allowed if token != ".": - return False + return (False,) + parsing_idx = idx continue + if token.isspace() or token in VALID_OPS_TOKEN: + # we have found our potential reference + parsing_idx = idx + break + if token.isidentifier(): reference += token else: - return False - return reference + return (False,) + + # check for illegality on the rest of the expression + # meaning we must only accept white spaces between end of ref and operator + rest = reference_expr[parsing_idx:] + for idx, token in enumerate(rest): + if token in VALID_OPS_TOKEN: + return (reference, rest[idx:]) + + if token.isspace(): + # the only acceptable token between a ref and operator + continue + else: + return (False,) def test_parse_identifier_reference(): input = [ - "resource.id", - "resourceeee.id", - "r esource.id", - "resource .id", - " r esource.id", - "resource.id", - "resource.name", - "resource.na me", - "resource.name!", - "rsrc!name", + "resource.id==", + "resourceeee.id>=", + "r esource.id ==", + "resource .id <=", + " r esource.id =", + "resource.id <<", + "resource.name <=", + "resource.na me !=", + "resource.name ===", + "rsrc!name ==", ] want = [ ("RESOURCE", "ID"), @@ -236,8 +270,8 @@ def test_parse_identifier_reference(): ("RESOURCE", "ID"), ("RESOURCE", "NAME"), False, + ("RESOURCE", "NAME"), False, - False ] got = [parse_identifier_reference(expr) for expr in input] assert got == want @@ -247,7 +281,7 @@ def parse_identifier_reference(expression): identifier, *reference_expr = parse_identifier(expression) if identifier is False: return False - reference = parse_reference(*reference_expr) + reference, *operator_expr = parse_reference(*reference_expr) if reference is False: return False @@ -256,98 +290,225 @@ def parse_identifier_reference(expression): def test_parse_operator(): input = [ - "==", - " ==", - "== ", - "=", - "<", - " <", - " > ", - "===", - ">=", - "!=", - " + ", - "--", - "/", - "=+", - "=-", + "== 'd8f7s9d8f7'", # valid + "== '8dfs8d7f9'", # valid + "== ''", # valid + "== \"'21f90912'\" ", # invalid + "== '21f90912' ", # valid + "= '*' ", # invalid + "< \"ddx\"", # invalid + " < '3030fk30'", # valid + " > ''", # valid + " >> ''", # invalid + "=== '*'", # invalid + ">= '2fd04'", # valid + "!= '*'", # valid + " + '*'", # invalid + "-- 'd'", # invalid + "/ 'xxvc'", # invalid + "=+ '*'", # invalid + "=- '*'", # invalid ] want = [ - "==", - "==", - "==", - False, - "<", - "<", - ">", - False, - ">=", - "<=", - "!=", - False, - False, - False, - False, - False, + ("==", "'d8f7s9d8f7'"), # valid + ("==", "'8dfs8d7f9'"), # valid + ("==", "''"), # valid + (False,), # invalid + ("==", "'21f90912'"), # valid + (False,), # invalid + (False,), # invalid + ("<", "'3030fk30'"), # valid + (">", "''"), # valid + (False,), # valid + (False,), # invalid + (">=", "'2fd04'"), # valid + ("!=", "'*'"), # valid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid ] got = [parse_operator(op) for op in input] assert got == want def parse_operator(operator_expr): + operator_expr = operator_expr.strip() # just to make sure VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} VALID_TOKEN = {token for operator in VALID_OPS for token in operator} operator = "" - found = False + parsing_idx = -1 for idx, token in enumerate(operator_expr): if token in VALID_TOKEN: - # we need to properly *walk* precisely the number of characters - # to find a valid operator - if operator in VALID_OPS and not found: - # first time found an operator - found = True - if operator in VALID_OPS and found: - # we are dealing with ===, !==, >>, etc - return False - - # here we are with 1-token + # the only valid thing that is being parsed operator += token else: - if token.isspace(): - continue - else: - return False + parsing_idx = idx + break + + rest = operator_expr[parsing_idx:] + for idx, token in enumerate(rest): + if token == "'": + # valid stopping point + if operator in VALID_OPS: + return (operator, rest[idx:]) + # invalid operator + return (False,) + + if token.isspace(): + continue + else: + # illegal characteres + return (False,) - if operator in VALID_OPS: - return operator - return False -def tst_parse_valid_conditional(): +def test_parse_valid_selector(): input = [ - "resource.id == 128f12334hjg" - "resource.id == 128f12334hjg" - "resource.id ==12839712893791823", - "resource.id== 12839712893791823", - "resource.id==128f12334hjg", - "resource.id==*", - "resource.id ==*", - "resource.id== *" + " '128f12334hjg'", + " '128f12334hjg'", + "'12839712893791823'", + " '12839712893791823'", + " '128f12334hjg'", + "'*'", + " '*'", + " '**'", + " '!'", + "'12837891ff", + "'123123'123123'", + "''", + "'fff\"dddsd'", + "'''", + "';1234234fgds00x;;", + "'f5f65b65c!!'", + "f4'dfadf7'", ] want = [ - ("RESOURCE", "ID", "==", "128f12334hjg"), - ("RESOURCE", "ID", "==", "128f12334hjg"), - ("RESOURCE", "ID", "==", "12839712893791823"), - ("RESOURCE", "ID", "==", "12839712893791823"), - ("RESOURCE", "ID", "==", "128f12334hjg"), - ("RESOURCE", "ID", "==", "*"), - ("RESOURCE", "ID", "==", "*"), - ("RESOURCE", "ID", "==", "*"), + ("128f12334hjg",), + ("128f12334hjg",), + ("12839712893791823",), + ("12839712893791823",), + ("128f12334hjg",), + ("*",), + ("*",), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), ] - got = [parse_condition(cond) for cond in input] + got = [parse_selector(cond) for cond in input] assert got == want +def parse_selector(selector_expr): + ALL_TOKEN = "*" + selector_expr = selector_expr.strip() + selector = "" + parsing_idx = -1 + + def isselector(token): + return ( + token.isnumeric() + or token.isidentifier() + or token == ALL_TOKEN + ) + + for idx, token in enumerate(selector_expr): + if idx == 0: + # first token + if token != "'": + return (False,) + continue + + if token == "'": + parsing_idx = idx + 1 + break + + if isselector(token): + selector += token + else: + return (False,) + + # are we dealig with an *? + if ALL_TOKEN in selector and selector != '*': + # plain comparison + return (False,) + + rest = selector_expr[parsing_idx + 1:] + if len(rest) > 0 or len(selector) == 0: + # we shouldn't have anything left + return (False,) + return (selector,) + + +def test_parse_expressions(): + valid_input = [ + "resource.id=='d8s7f987sdf'", + "resource.id == 'd8s7f987sdf'", + "resource.id == '*'", + "subject.salary > '1800'", + "subject.salary <= '1800'", + "some.name == 'ab9f8d0'", + ] + + invalid_input = [ + "resource..id == '8d9f7a8f'", + ".id == 'xxx'", + "'resource'.id == '*'", + "reso urce.id == '*'", + "resource.id === '*'", + "resource.id='dgv8bf'", + "resource.id == \"*\"", + "subject.salary > 1800", + ] + + got = [parse_expression(expr) for expr in valid_input] + want = [ + ("resource", "id", "==", "d8s7f987sdf"), + ("resource", "id", "==", "d8s7f987sdf"), + ("resource", "id", "==", "*"), + ("subject", "salary", ">", "1800"), + ("subject", "salary", "<=", "1800"), + ("some", "name", "==", "ab9f8d0"), + ] + assert got == want + + got = [parse_expression(expr) for expr in invalid_input] + want = [ + False, + False, + False, + False, + False, + False, + False, + False, + ] + assert got == want + + +def parse_expression(expr): + identifier, *reference_expr = parse_identifier(expr) + if identifier is False: + return False + reference, *operator_expr = parse_reference(*reference_expr) + if reference is False: + return False + operator, *selector_expr = parse_operator(*operator_expr) + if operator is False: + return False + selector, *end = parse_selector(*selector_expr) + if selector is False: + return False + return (identifier, reference, operator, selector) + From 89b9c2c388e71cdf0776f5f298a1edd8e67ae1af Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 6 Apr 2021 23:20:12 -0300 Subject: [PATCH 03/32] Feat: Permission check design --- echo | 1 - src/core/access/authorization.py | 143 +++++++++++++++ src/core/access/test_authorization.py | 22 +-- src/core/access/test_permission.py | 250 ++++++++++++++++++++++++++ 4 files changed, 405 insertions(+), 11 deletions(-) delete mode 100644 echo create mode 100644 src/core/access/test_permission.py diff --git a/echo b/echo deleted file mode 100644 index 408d22b..0000000 --- a/echo +++ /dev/null @@ -1 +0,0 @@ -M README.md diff --git a/src/core/access/authorization.py b/src/core/access/authorization.py index e69de29..d5d97d3 100644 --- a/src/core/access/authorization.py +++ b/src/core/access/authorization.py @@ -0,0 +1,143 @@ +# authorization.py +# +# Context: +# When a user logs in, front-end receives a JWT on user's behalf. +# User has roles associated to him/her. +# And each of these roles have policies attached to them. +# +# USE CASE 1: Policy Packing and Unpacking +# Whenever a user logs in, their policies gets packed into a JWT that's +# transferred within system's services boundaries. +# +# USE CASE 2: Policy Enforcement +# Whenever a user tries to perform an operation on a given resource +# i.e. an AccessRequest, it's up to authorization module to enforce +# whether user has enough privileges. + +# USE CASE 1 -- In-depth +# Parse a list of Policy and return a mapping of resource-selector-operation +# This mapping should also *simplify* the policy and remove redundant policies + +from collections import namedtuple +from dataclasses import dataclass +from enum import Enum +from typing import List + +# Operation order: +# Split between READ and WRITE operations. +# READ: READ +# WRITE: CREATE | UPDATE | DELETE +# +# Rules: +# 1. WRITE permission overrides READ permission. +# e.g. If a USER have any of WRITE permission, him/her implicitly +# have READ permission. +# 2. No WRITE permissions override one-another. +# e.g. If a user has DELETE permission and it doesn't have +# UPDATE or CREATE permissions, it is only allowed to delete. + + +class Permission(Enum): + READ = 0b000 + CREATE = 0b001 + UPDATE = 0b010 + DELETE = 0b100 + + def __or__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value | other.value + + def __ror__(self, other): + return self.__or__(other) + + def __and__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value & other.value + + +@dataclass +class Resource: + name: str + + +@dataclass +class Policy(object): + resource: Resource + permissions: List[Permission] + selectors: str + + +def test_tries_to_create_unauthorized(): + # user have no creation permissions + input = [ + (Permission.READ), + (Permission.READ, Permission.UPDATE), + (Permission.READ, Permission.UPDATE, Permission.DELETE), + (Permission.UPDATE, Permission.DELETE) + ] + for perm in input: + assert has_permission(perm, Permission.CREATE) is False + + +def test_tries_to_update_anauthorized(): + # user have no update permission + input = [ + (Permission.READ), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.DELETE) + ] + for perm in input: + assert has_permission(perm, Permission.UPDATE) is False + + +def test_tries_to_delete_anauthorized(): + # user have no delete permission + input = [ + (Permission.READ), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.UPDATE), + (Permission.CREATE, Permission.UPDATE) + ] + for perm in input: + assert has_permission(perm, Permission.DELETE) is False + + +def test_tries_to_read_without_explicit_read_authorized(): + # user have only write permissions and tries to read + input = [ + (Permission.CREATE), + (Permission.UPDATE), + (Permission.DELETE), + (Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.UPDATE), + (Permission.DELETE, Permission.UPDATE), + (Permission.CREATE, Permission.DELETE, Permission.UPDATE), + ] + for perm in input: + assert has_permission(perm, Permission.READ) + + +def test_tries_to_write_but_has_only_read(): + # user have only read permissions and tries to do all writes + input = [ + Permission.CREATE, + Permission.UPDATE, + Permission.DELETE, + ] + for perm in input: + assert has_permission((Permission.READ), perm) is False + + +def has_permission( + owned_permissions: tuple, + requested_operation: Permission +) -> bool: + for permission in owned_permissions: + permission |= permission + + return permission & requested_operation > 0 + + diff --git a/src/core/access/test_authorization.py b/src/core/access/test_authorization.py index 70c9440..93b84ec 100644 --- a/src/core/access/test_authorization.py +++ b/src/core/access/test_authorization.py @@ -456,10 +456,22 @@ def test_parse_expressions(): "resource.id=='d8s7f987sdf'", "resource.id == 'd8s7f987sdf'", "resource.id == '*'", + "product.id=='*'", "subject.salary > '1800'", "subject.salary <= '1800'", "some.name == 'ab9f8d0'", ] + got = [parse_expression(expr) for expr in valid_input] + want = [ + ("resource", "id", "==", "d8s7f987sdf"), + ("resource", "id", "==", "d8s7f987sdf"), + ("resource", "id", "==", "*"), + ("product", "id", "==", "*"), + ("subject", "salary", ">", "1800"), + ("subject", "salary", "<=", "1800"), + ("some", "name", "==", "ab9f8d0"), + ] + assert got == want invalid_input = [ "resource..id == '8d9f7a8f'", @@ -472,16 +484,6 @@ def test_parse_expressions(): "subject.salary > 1800", ] - got = [parse_expression(expr) for expr in valid_input] - want = [ - ("resource", "id", "==", "d8s7f987sdf"), - ("resource", "id", "==", "d8s7f987sdf"), - ("resource", "id", "==", "*"), - ("subject", "salary", ">", "1800"), - ("subject", "salary", "<=", "1800"), - ("some", "name", "==", "ab9f8d0"), - ] - assert got == want got = [parse_expression(expr) for expr in invalid_input] want = [ diff --git a/src/core/access/test_permission.py b/src/core/access/test_permission.py new file mode 100644 index 0000000..419835e --- /dev/null +++ b/src/core/access/test_permission.py @@ -0,0 +1,250 @@ +# authorization.py +# +# Context: +# When a user logs in, front-end receives a JWT on user's behalf. +# User has roles associated to him/her. +# And each of these roles have policies attached to them. +# + +from collections import namedtuple +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, Tuple + +class Permission(Enum): + READ = 0b000 + CREATE = 0b001 + UPDATE = 0b010 + DELETE = 0b100 + + def __or__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value | other + + def __ror__(self, other): + return self.__or__(other) + + def __and__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value & other + + def __rand__(self, other): + return self.__and__(other) + + +@dataclass +class Resource: + name: str + + +@dataclass +class Conditional: + identifier: str + selector: str + + +@dataclass +class Policy(object): + resource: Resource + permissions: Tuple[Permission] + conditionals: List[Conditional] + + +# USE CASE 1: Policy Packing and Unpacking +# Whenever a user logs in, their policies gets packed into a JWT that's +# transferred within system's services boundaries. +# +# In-depth: +# Parse a list of Policy and return a mapping of resource-selector-operation +# This mapping should also *simplify* the policy and remove redundant policies + +def test_simple_policy_packing(): + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "ab4f"), + Conditional("resource.id", "13fa"), + ] + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE,), + conditionals=[ + Conditional("resource.id", "*"), + ] + ) + + account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.READ,), + conditionals=[ + Conditional("resource.id", "*"), + ] + ) + + ya_account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "0bf3"), + Conditional("resource.id", "bc0e"), + ] + ) + + role_policies = [ + product_policy, ya_product_policy, account_policy, ya_account_policy + ] + + owned_perm = { + Resource("Product"): { + "*": (Permission.READ,), + "ab4f": (Permission.UPDATE, Permission.DELETE), + "13fa": (Permission.UPDATE, Permission.DELETE), + }, + Resource("Account"): { + "*": (Permission.READ,), + "0bf3": (Permission.UPDATE), + "bc0e": (Permission.UPDATE), + } + } + + assert pack_policies(role_policies) == owned_perm + + +def pack_policies(policies: List[Policy]) -> Dict: + owned = {} + + # iterative straightforward approach: on every iteration we keep on + # building our output dictionary + for policy in policies: + if policy.resource not in owned: + owned_permissions[resource] = dict() + + # iterate on every conditional + for conditional in policy.conditionals: + if conditional.selector not in owned[resource]: + owned[resource] = policy.permissions + # continue so code won't get too spaghetti + continue + + # meaning that selector is already on owned_permissions[resource] + # we have to figure out first if it's a redundant permission + existing_perm = set(owned[resource][conditional.selector]) + diff = existing_perm - set(policy.permissions) + + + + +# USE CASE 2: Policy Enforcement +# Whenever a user tries to perform an operation on a given resource +# i.e. an AccessRequest, it's up to authorization module to enforce +# whether user has enough privileges. +# Scenario: +# 1. Subject has a map of OwnedPermission +# 2. Subject emits a AccessRequest +# +# Given that +# 1. AccessRequest.resource in OwnedPermission.resource +# 2. And that either +# 2.1. "*" in OwnedPermission.resource.selectors Or +# 2.2. AccessRequest.resource.selector in OwnedPermission.resource.selectors +# 3. We must check that +# AccessRequest.resource.selector.operation has permissions against +# OwnedPermission.resource.selector.operations +# +# Operation order: +# Split between READ and WRITE operations. +# READ: READ +# WRITE: CREATE | UPDATE | DELETE +# +# Rules: +# 1. WRITE permission overrides READ permission. +# e.g. If a Subject has any of WRITE permission, him/her implicitly +# have READ permission. +# 2. No WRITE permissions override one-another. +# e.g. If a Subject has DELETE permission and it doesn't have +# UPDATE or CREATE permissions, it is only allowed to delete. + + +def test_tries_to_create_unauthorized(): + # user have no creation permissions + input = [ + (Permission.READ,), + (Permission.READ, Permission.UPDATE), + (Permission.READ, Permission.UPDATE, Permission.DELETE), + (Permission.UPDATE, Permission.DELETE) + ] + for perm in input: + assert has_permission(perm, Permission.CREATE) is False + + +def test_tries_to_update_anauthorized(): + # user have no update permission + input = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.DELETE) + ] + for perm in input: + assert has_permission(perm, Permission.UPDATE) is False + + +def test_tries_to_delete_anauthorized(): + # user have no delete permission + input = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.UPDATE), + (Permission.CREATE, Permission.UPDATE) + ] + for perm in input: + assert has_permission(perm, Permission.DELETE) is False + + +def test_tries_to_read_without_explicit_read_authorized(): + # user have only write permissions and tries to read + input = [ + (Permission.CREATE,), + (Permission.UPDATE,), + (Permission.DELETE,), + (Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.UPDATE), + (Permission.DELETE, Permission.UPDATE), + (Permission.CREATE, Permission.DELETE, Permission.UPDATE), + ] + for perm in input: + assert has_permission(perm, Permission.READ) + + +def test_tries_to_write_but_has_only_read(): + # user have only read permissions and tries to do all writes + input = [ + Permission.CREATE, + Permission.UPDATE, + Permission.DELETE, + ] + for perm in input: + assert has_permission((Permission.READ,), perm) is False + + +def has_permission( + owned_permissions: tuple, + requested_operation: Permission +) -> bool: + # corner case is when requested permission is READ: + if requested_operation == Permission.READ and len(owned_permissions) > 0: + # if we have ANY permission, it means that the user is able to read + return True + + for permission in owned_permissions: + permission |= permission + + return permission & requested_operation > 0 + + + From 6e3c6551148f10b7483d0b1eb9e881114ce8f702 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 8 Apr 2021 16:50:11 -0300 Subject: [PATCH 04/32] Feat: Implements policy simple and redundant packing --- src/core/access/test_permission.py | 300 +++++++++++++++++++---------- 1 file changed, 201 insertions(+), 99 deletions(-) diff --git a/src/core/access/test_permission.py b/src/core/access/test_permission.py index 419835e..75fd8fe 100644 --- a/src/core/access/test_permission.py +++ b/src/core/access/test_permission.py @@ -1,22 +1,28 @@ # authorization.py -# -# Context: -# When a user logs in, front-end receives a JWT on user's behalf. -# User has roles associated to him/her. -# And each of these roles have policies attached to them. -# +""" + Context: + When a user logs in, front-end receives a JWT on user's behalf. + User has roles associated to him/her. + And each of these roles have policies attached to them. +""" -from collections import namedtuple from dataclasses import dataclass from enum import Enum from typing import Dict, List, Tuple +TOKEN_ALL = "*" + + class Permission(Enum): + """Operation-permission""" READ = 0b000 CREATE = 0b001 UPDATE = 0b010 DELETE = 0b100 + def __hash__(self) -> int: + return hash(self.value) + def __or__(self, other): if isinstance(other, Permission): other = other.value @@ -36,23 +42,32 @@ def __rand__(self, other): @dataclass class Resource: + """An enterprise-wide resource wrapper""" name: str + def __init__(self, name: str): + self.name = name + self.alias = name.lower() + @dataclass class Conditional: + """One and only one conditional clause""" identifier: str selector: str @dataclass -class Policy(object): +class Policy: + """An association of permissions that are allowed to be performed on + on instances of a given resource that match ANY of the conditionals + criteria""" resource: Resource permissions: Tuple[Permission] conditionals: List[Conditional] -# USE CASE 1: Policy Packing and Unpacking +# USE CASE 1: Policy Packing and Unpacking # Whenever a user logs in, their policies gets packed into a JWT that's # transferred within system's services boundaries. # @@ -60,83 +75,182 @@ class Policy(object): # Parse a list of Policy and return a mapping of resource-selector-operation # This mapping should also *simplify* the policy and remove redundant policies + def test_simple_policy_packing(): - product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "ab4f"), - Conditional("resource.id", "13fa"), - ] - ) + """Given a list of Policies with no overlapping conditional selectors, + we expect a well-formatted DTO dict""" + product_policy = Policy(resource=Resource("Product"), + permissions=(Permission.UPDATE, ), + conditionals=[ + Conditional("resource.id", "ab4f"), + Conditional("resource.id", "13fa"), + ]) + + ya_product_policy = Policy(resource=Resource("Product"), + permissions=(Permission.CREATE, ), + conditionals=[ + Conditional("resource.id", "*"), + ]) + + account_policy = Policy(resource=Resource("Account"), + permissions=(Permission.READ, ), + conditionals=[ + Conditional("resource.id", "*"), + ]) + + ya_account_policy = Policy(resource=Resource("Account"), + permissions=(Permission.UPDATE, ), + conditionals=[ + Conditional("resource.id", "0bf3"), + Conditional("resource.id", "bc0e"), + ]) - ya_product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.CREATE,), - conditionals=[ - Conditional("resource.id", "*"), - ] - ) + role_policies = [ + product_policy, ya_product_policy, account_policy, ya_account_policy + ] - account_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.READ,), - conditionals=[ - Conditional("resource.id", "*"), - ] - ) + owned_perm = { + "product": { + "*": (Permission.CREATE, ), + "ab4f": (Permission.UPDATE, ), + "13fa": (Permission.UPDATE, ), + }, + "account": { + "*": (Permission.READ, ), + "0bf3": (Permission.UPDATE, ), + "bc0e": (Permission.UPDATE, ), + } + } - ya_account_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "0bf3"), - Conditional("resource.id", "bc0e"), - ] - ) + assert pack_policies(role_policies) == owned_perm + + +def test_redundant_policy_packing(): + """Given a list of Policies with overlapping conditional selectors, + we expect a well-formatted DTO dict""" + product_policy = Policy(resource=Resource("Product"), + permissions=(Permission.READ, Permission.CREATE), + conditionals=[ + Conditional("resource.id", "*"), + ]) + + ya_product_policy = Policy(resource=Resource("Product"), + permissions=(Permission.READ, + Permission.UPDATE), + conditionals=[ + Conditional("resource.id", "7fb4"), + Conditional("resource.id", "49f3"), + Conditional("resource.id", "abc9"), + ]) + + yao_product_policy = Policy(resource=Resource("Product"), + permissions=( + Permission.READ, + Permission.DELETE, + ), + conditionals=[ + Conditional("resource.id", "aaa"), + Conditional("resource.id", "abc9"), + ]) role_policies = [ - product_policy, ya_product_policy, account_policy, ya_account_policy + product_policy, + ya_product_policy, + yao_product_policy, ] owned_perm = { - Resource("Product"): { - "*": (Permission.READ,), - "ab4f": (Permission.UPDATE, Permission.DELETE), - "13fa": (Permission.UPDATE, Permission.DELETE), + "product": { + "*": ( + Permission.READ, + Permission.CREATE, + ), + "7fb4": (Permission.UPDATE, ), + "49f3": (Permission.UPDATE, ), + "abc9": (Permission.UPDATE, Permission.DELETE), + "aaa": (Permission.DELETE, ), }, - Resource("Account"): { - "*": (Permission.READ,), - "0bf3": (Permission.UPDATE), - "bc0e": (Permission.UPDATE), - } } assert pack_policies(role_policies) == owned_perm def pack_policies(policies: List[Policy]) -> Dict: - owned = {} + """ + Pack a list of policies into a known PolicyDTO format. + TODO: Refactor this in order to properly encapsulate output DTO format. + + >>> a_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[Conditional("resource.id", "*")] + ) + >>> ya_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "5f34")] + ) + >>> pack_policies([a_policy, ya_policy]) + { + "product": { + "5f34": (Permission.READ,) + }, + "account": { + "*": (Permission.UPDATE,) + } + } + """ + owned: Dict[str, Dict[str, tuple]] = {} # iterative straightforward approach: on every iteration we keep on # building our output dictionary for policy in policies: - if policy.resource not in owned: - owned_permissions[resource] = dict() + resource = policy.resource.alias + permissions = policy.permissions + if resource not in owned: + owned[resource] = {} # iterate on every conditional for conditional in policy.conditionals: - if conditional.selector not in owned[resource]: - owned[resource] = policy.permissions - # continue so code won't get too spaghetti + selector = conditional.selector + # There are two kinds of selectors: specific and generics. + # And a selector already exist for a given resource or it doesn't. + if selector in owned[resource]: + # When a selector already exists, we make sure we keep + # permissions unique + current_perms = set(owned[resource][selector]) + incoming_perms = set(permissions) + updated_permissions = tuple(incoming_perms | current_perms) + owned[resource][selector] = updated_permissions + else: + # Otherwise, it's a new selector, hence a new entry on + # dict. + owned[resource][selector] = tuple(permissions) + + # Brute-forcing, we now iterate over each resource and remove + # redundant permissions + for resource, selector_dict in owned.items(): + if TOKEN_ALL not in selector_dict: + # nothing to do + continue + + all_token_perms = set(selector_dict[TOKEN_ALL]) + for selector, permissions in selector_dict.items(): + # We now subtract "*" from all of the other permissions + if selector == TOKEN_ALL: + # Not this one. continue + current_perms = set(permissions) + updated_perms = current_perms - all_token_perms - # meaning that selector is already on owned_permissions[resource] - # we have to figure out first if it's a redundant permission - existing_perm = set(owned[resource][conditional.selector]) - diff = existing_perm - set(policy.permissions) - + if len(updated_perms) == 0: + # Meaning that all of this selector permissions are already + # contemplated by "*" + del owned[resource][selector] + else: + owned[resource][selector] = tuple(updated_perms) + return owned # USE CASE 2: Policy Enforcement @@ -144,14 +258,15 @@ def pack_policies(policies: List[Policy]) -> Dict: # i.e. an AccessRequest, it's up to authorization module to enforce # whether user has enough privileges. # Scenario: -# 1. Subject has a map of OwnedPermission -# 2. Subject emits a AccessRequest +# 1. Subject has a map of OwnedPermission +# 2. Subject emits a AccessRequest # # Given that # 1. AccessRequest.resource in OwnedPermission.resource -# 2. And that either -# 2.1. "*" in OwnedPermission.resource.selectors Or -# 2.2. AccessRequest.resource.selector in OwnedPermission.resource.selectors +# 2. And that either +# 2.1. "*" in OwnedPermission.resource.selectors Or +# 2.2. AccessRequest.resource.selector in +# OwnedPermission.resource.selectors # 3. We must check that # AccessRequest.resource.selector.operation has permissions against # OwnedPermission.resource.selector.operations @@ -160,11 +275,11 @@ def pack_policies(policies: List[Policy]) -> Dict: # Split between READ and WRITE operations. # READ: READ # WRITE: CREATE | UPDATE | DELETE -# +# # Rules: # 1. WRITE permission overrides READ permission. # e.g. If a Subject has any of WRITE permission, him/her implicitly -# have READ permission. +# have READ permission. # 2. No WRITE permissions override one-another. # e.g. If a Subject has DELETE permission and it doesn't have # UPDATE or CREATE permissions, it is only allowed to delete. @@ -172,36 +287,27 @@ def pack_policies(policies: List[Policy]) -> Dict: def test_tries_to_create_unauthorized(): # user have no creation permissions - input = [ - (Permission.READ,), - (Permission.READ, Permission.UPDATE), - (Permission.READ, Permission.UPDATE, Permission.DELETE), - (Permission.UPDATE, Permission.DELETE) - ] + input = [(Permission.READ, ), (Permission.READ, Permission.UPDATE), + (Permission.READ, Permission.UPDATE, Permission.DELETE), + (Permission.UPDATE, Permission.DELETE)] for perm in input: assert has_permission(perm, Permission.CREATE) is False def test_tries_to_update_anauthorized(): # user have no update permission - input = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.DELETE) - ] + input = [(Permission.READ, ), (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.DELETE)] for perm in input: assert has_permission(perm, Permission.UPDATE) is False def test_tries_to_delete_anauthorized(): # user have no delete permission - input = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.UPDATE), - (Permission.CREATE, Permission.UPDATE) - ] + input = [(Permission.READ, ), (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.UPDATE), + (Permission.CREATE, Permission.UPDATE)] for perm in input: assert has_permission(perm, Permission.DELETE) is False @@ -209,9 +315,9 @@ def test_tries_to_delete_anauthorized(): def test_tries_to_read_without_explicit_read_authorized(): # user have only write permissions and tries to read input = [ - (Permission.CREATE,), - (Permission.UPDATE,), - (Permission.DELETE,), + (Permission.CREATE, ), + (Permission.UPDATE, ), + (Permission.DELETE, ), (Permission.CREATE, Permission.DELETE), (Permission.CREATE, Permission.UPDATE), (Permission.DELETE, Permission.UPDATE), @@ -229,22 +335,18 @@ def test_tries_to_write_but_has_only_read(): Permission.DELETE, ] for perm in input: - assert has_permission((Permission.READ,), perm) is False + assert has_permission((Permission.READ, ), perm) is False -def has_permission( - owned_permissions: tuple, - requested_operation: Permission -) -> bool: +def has_permission(owned_permissions: tuple, + requested_operation: Permission) -> bool: # corner case is when requested permission is READ: if requested_operation == Permission.READ and len(owned_permissions) > 0: # if we have ANY permission, it means that the user is able to read return True - for permission in owned_permissions: - permission |= permission - - return permission & requested_operation > 0 - - + permissions = 0 + for owned_permission in owned_permissions: + permissions |= owned_permission + return (permissions & requested_operation) > 0 From 5dd9a0a41bd28bba3e12c6b3602804ee96f9e8a4 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Thu, 8 Apr 2021 21:32:23 -0300 Subject: [PATCH 05/32] Feat: Finishes whole checking verification --- src/core/access/test_permission.py | 344 +++++++++++++++++++++++++---- 1 file changed, 297 insertions(+), 47 deletions(-) diff --git a/src/core/access/test_permission.py b/src/core/access/test_permission.py index 75fd8fe..efb1bac 100644 --- a/src/core/access/test_permission.py +++ b/src/core/access/test_permission.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from enum import Enum -from typing import Dict, List, Tuple +from typing import Dict, List, NamedTuple, Tuple, Union TOKEN_ALL = "*" @@ -67,13 +67,18 @@ class Policy: conditionals: List[Conditional] -# USE CASE 1: Policy Packing and Unpacking -# Whenever a user logs in, their policies gets packed into a JWT that's -# transferred within system's services boundaries. -# -# In-depth: -# Parse a list of Policy and return a mapping of resource-selector-operation -# This mapping should also *simplify* the policy and remove redundant policies +""" +USE CASE 1: Policy Packing +- Whenever a user logs in, their policies gets packed into a JWT that's +transferred within system's services boundaries. +- We don't need to unpack it, since it's a unidirectional step from +authorization service to JWTs. Incoming logic relies on unpacked data +structure + +In-depth: +Parse a list of Policy and return a mapping of resource-selector-operation +This mapping should also *simplify* the policy and remove redundant policies +""" def test_simple_policy_packing(): @@ -220,6 +225,7 @@ def pack_policies(policies: List[Policy]) -> Dict: # permissions unique current_perms = set(owned[resource][selector]) incoming_perms = set(permissions) + # Union updated_permissions = tuple(incoming_perms | current_perms) owned[resource][selector] = updated_permissions else: @@ -236,7 +242,7 @@ def pack_policies(policies: List[Policy]) -> Dict: all_token_perms = set(selector_dict[TOKEN_ALL]) for selector, permissions in selector_dict.items(): - # We now subtract "*" from all of the other permissions + # We now subtract "*"'s permissions from the other permissions if selector == TOKEN_ALL: # Not this one. continue @@ -244,7 +250,7 @@ def pack_policies(policies: List[Policy]) -> Dict: updated_perms = current_perms - all_token_perms if len(updated_perms) == 0: - # Meaning that all of this selector permissions are already + # Meaning that all of this selector's permissions are already # contemplated by "*" del owned[resource][selector] else: @@ -253,36 +259,277 @@ def pack_policies(policies: List[Policy]) -> Dict: return owned -# USE CASE 2: Policy Enforcement -# Whenever a user tries to perform an operation on a given resource -# i.e. an AccessRequest, it's up to authorization module to enforce -# whether user has enough privileges. -# Scenario: -# 1. Subject has a map of OwnedPermission -# 2. Subject emits a AccessRequest -# -# Given that -# 1. AccessRequest.resource in OwnedPermission.resource -# 2. And that either -# 2.1. "*" in OwnedPermission.resource.selectors Or -# 2.2. AccessRequest.resource.selector in -# OwnedPermission.resource.selectors -# 3. We must check that -# AccessRequest.resource.selector.operation has permissions against -# OwnedPermission.resource.selector.operations -# -# Operation order: -# Split between READ and WRITE operations. -# READ: READ -# WRITE: CREATE | UPDATE | DELETE -# -# Rules: -# 1. WRITE permission overrides READ permission. -# e.g. If a Subject has any of WRITE permission, him/her implicitly -# have READ permission. -# 2. No WRITE permissions override one-another. -# e.g. If a Subject has DELETE permission and it doesn't have -# UPDATE or CREATE permissions, it is only allowed to delete. +""" +USE CASE 2: Incoming AccessRequest + Whenever a subject tries to perform an operation on a given resource + i.e. an AccessRequest, it's up to authorization module to decide whether + that operation is allowed. + + There are two authorization scenarios: + 1. A READ scenario, in which authorization module should pass-along to + the server which instances that subject is allowed to read. + 2. A WRITE scenario, in which authorization module should tell whether + user has permission or not. + + READ SCENARIO: + Given that + 1. AccessRequest.operation is READ + 2. And AccessRequest.resource in OwnedPermission.resource + 3. Return OwnedPermission.resource.selectors keys. + 3.1. If "*" in OwnedPermission.resource.selectors, return only "*". + + PS: Since READ is the least privilege level, every key should hold at least + enough privileges for read. +""" + + +@dataclass +class AccessRequest: + operation: int + resource: str + selector: str + + def __init__(self, operation: Permission, resource: Resource, + selector: str): + self.operation = operation.value + self.resource = resource.alias + self.selector = selector + + +def test_read_permission_filtering_unauthorized(): + access_request = AccessRequest(operation=Permission.READ, + resource=Resource("Product"), + selector="*") + owned_perm = { + "account": { + "*": (Permission.CREATE, ), + }, + } + assert check_read_permission(owned_perm, access_request) == [] + + access_request = AccessRequest(operation=Permission.READ, + resource=Resource("Account"), + selector="*") + owned_perm = { + "product": { + "*": (Permission.READ, ), + }, + } + assert check_read_permission(owned_perm, access_request) == [] + + +def test_read_permission_filtering_authorized_but_specific(): + access_request = AccessRequest(operation=Permission.READ, + resource=Resource("Coupon"), + selector="*") + owned_perm = { + "coupon": { + "ab4c": (Permission.READ, ), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE, ), + "b4a3": (Permission.DELETE, ), + }, + } + assert check_read_permission( + owned_perm, access_request) == ["ab4c", "bc3f", "cc4a", "b4a3"] + + access_request = AccessRequest(operation=Permission.READ, + resource=Resource("Coupon"), + selector="*") + owned_perm = { + "coupon": { + "*": (Permission.CREATE, ), + }, + } + assert check_read_permission(owned_perm, access_request) == ["*"] + + +def check_read_permission(owned_permissions: Dict, + access_request: AccessRequest) -> List: + # Sanity check + assert access_request.operation == Permission.READ.value + + if access_request.resource not in owned_permissions: + # Subject has no permission related to requested resource. + return [] + + # Subject has at least one selector that it can read. + if TOKEN_ALL in owned_permissions[access_request.resource]: + # If it has any binding to "*", then it can read it all. + return [TOKEN_ALL] + + # Subject has specific bindings, we shall return them. + allowed_ids = owned_permissions[access_request.resource].keys() + return list(allowed_ids) + + +""" + WRITE SCENARIO: + Given that + 1. AccessRequest.operation is CREATE|DELETE|UPDATE + 3. AccessRequest.resource in OwnedPermission.resource + 4. And that either + 2.1. "*" in OwnedPermission.resource.selectors Or + 2.2. AccessRequest.resource.selector in + OwnedPermission.resource.selectors + 5. Check that + AccessRequest.resource.selector.operation has permissions against + OwnedPermission.resource.selector.operations + + PS: Note that in each of these steps, if a condition is not satisfied + we instantly revoke. +""" + + +def test_write_permission_unauthorized(): + # Tries to write in an unknown resource to the subject + access_request = AccessRequest(operation=Permission.CREATE, + resource=Resource("User"), + selector="*") + owned_perm = { + "product": { + "*": (Permission.READ, ), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without enough privileges. + access_request = AccessRequest(operation=Permission.CREATE, + resource=Resource("Coupon"), + selector="*") + owned_perm = { + "coupon": { + "ab4c": (Permission.READ, ), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE, ), + "b4a3": (Permission.DELETE, ), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest(operation=Permission.UPDATE, + resource=Resource("User"), + selector="ffc0") + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE), + "abc3": (Permission.UPDATE, ), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest(operation=Permission.DELETE, + resource=Resource("User"), + selector="c4fd") + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), + "d3fc": (Permission.DELETE, ), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + +def test_write_permission_authorized(): + # Tries to create a new item. + access_request = AccessRequest(operation=Permission.CREATE, + resource=Resource("User"), + selector="*") + owned_perm = { + "user": { + "*": ( + Permission.UPDATE, + Permission.CREATE, + ), + }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to update a specific value on a wildcard policy. + access_request = AccessRequest(operation=Permission.UPDATE, + resource=Resource("Coupon"), + selector="43df") + owned_perm = { + "coupon": { + "*": (Permission.READ, Permission.UPDATE, Permission.DELETE), + }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to delete a specific value on a specific policy. + access_request = AccessRequest(operation=Permission.DELETE, + resource=Resource("User"), + selector="3dc4") + owned_perm = { + "user": { + "*": (Permission.CREATE, ), + "3dc4": (Permission.DELETE, ), + }, + } + assert check_write_permission(owned_perm, access_request) is True + + +def check_write_permission(owned_permissions: Dict, + access_request: AccessRequest) -> bool: + # Sanity check. + assert (access_request.operation \ + & (Permission.CREATE | Permission.UPDATE | Permission.DELETE)) + + if access_request.resource not in owned_permissions: + # Resource is unknown to subject. + return False + + owned_resource = owned_permissions[access_request.resource] + # CREATE case is different than UPDATE and DELETE. + # Because it requires a "*" selector. + if access_request.operation == Permission.CREATE.value: + # To CREATE, subject must have at least one '*' policy. + if TOKEN_ALL not in owned_resource: + return False + + # Checking is a simple O(1) step. + return Permission.CREATE in owned_resource[TOKEN_ALL] + + # Deal with UPDATE or DELETE + # Check for generics. + if TOKEN_ALL in owned_resource: + if is_allowed(owned_resource[TOKEN_ALL], access_request.operation): + # Wildcard matches permission. + return True + # if access_request.operation in owned_resource[TOKEN_ALL]: + # return True + + # Then specify. + if access_request.selector not in owned_resource: + # No rule for requested instance. Denied. + return False + + return is_allowed(owned_resource[access_request.selector], + access_request.operation) + # return access_request.operation in owned_resource[access_request.selector] + + +""" +USE CASE 3: Permission check +Given that an incoming AccessRequest is dispatched and a common selector +exist in both OwnedPermission and AccessRequest, it's up to this logic +to return whether those permissions results in an Allow statement. + + Operation order: + Split between READ and WRITE operations. + READ: READ + WRITE: CREATE | UPDATE | DELETE + + Rules: + 1. WRITE permission overrides READ permission. + e.g. If a Subject has any of WRITE permission, him/her implicitly + have READ permission. + 2. No WRITE permissions override one-another. + e.g. If a Subject has DELETE permission and it doesn't have + UPDATE or CREATE permissions, it is only allowed to delete. +""" def test_tries_to_create_unauthorized(): @@ -291,7 +538,7 @@ def test_tries_to_create_unauthorized(): (Permission.READ, Permission.UPDATE, Permission.DELETE), (Permission.UPDATE, Permission.DELETE)] for perm in input: - assert has_permission(perm, Permission.CREATE) is False + assert is_allowed(perm, Permission.CREATE) is False def test_tries_to_update_anauthorized(): @@ -300,7 +547,7 @@ def test_tries_to_update_anauthorized(): (Permission.READ, Permission.CREATE, Permission.DELETE), (Permission.CREATE, Permission.DELETE)] for perm in input: - assert has_permission(perm, Permission.UPDATE) is False + assert is_allowed(perm, Permission.UPDATE) is False def test_tries_to_delete_anauthorized(): @@ -309,7 +556,7 @@ def test_tries_to_delete_anauthorized(): (Permission.READ, Permission.CREATE, Permission.UPDATE), (Permission.CREATE, Permission.UPDATE)] for perm in input: - assert has_permission(perm, Permission.DELETE) is False + assert is_allowed(perm, Permission.DELETE) is False def test_tries_to_read_without_explicit_read_authorized(): @@ -324,7 +571,7 @@ def test_tries_to_read_without_explicit_read_authorized(): (Permission.CREATE, Permission.DELETE, Permission.UPDATE), ] for perm in input: - assert has_permission(perm, Permission.READ) + assert is_allowed(perm, Permission.READ) def test_tries_to_write_but_has_only_read(): @@ -335,11 +582,14 @@ def test_tries_to_write_but_has_only_read(): Permission.DELETE, ] for perm in input: - assert has_permission((Permission.READ, ), perm) is False + assert is_allowed((Permission.READ, ), perm) is False + +def is_allowed(owned_permissions: tuple, + requested_operation: Union[int, Permission]) -> bool: + if isinstance(requested_operation, int): + requested_operation = Permission(requested_operation) -def has_permission(owned_permissions: tuple, - requested_operation: Permission) -> bool: # corner case is when requested permission is READ: if requested_operation == Permission.READ and len(owned_permissions) > 0: # if we have ANY permission, it means that the user is able to read From f3804ce4c3d7245b0b20a8ec4824e5db63176472 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 12 Apr 2021 21:44:48 -0300 Subject: [PATCH 06/32] Chore: Proper structure changing and documenting --- kingdom/__init__.py | 0 kingdom/access/__init__.py | 0 .../access/authentication/authenticator.py | 50 ++ kingdom/access/authorization/__init__.py | 0 .../access/authorization/dsl.py | 170 ++--- kingdom/access/authorization/encode.py | 110 ++++ kingdom/access/authorization/model.py | 84 +++ kingdom/access/authorization/verify.py | 175 +++++ kingdom/access/tests/__init__.py | 0 kingdom/access/tests/test_encode.py | 112 ++++ kingdom/access/tests/test_verify.py | 205 ++++++ kingdom/access/utils.py | 21 + src/auth/domain/model.py | 2 + src/core/access/authorization.py | 143 ----- src/core/access/test_permission.py | 602 ------------------ 15 files changed, 828 insertions(+), 846 deletions(-) create mode 100644 kingdom/__init__.py create mode 100644 kingdom/access/__init__.py create mode 100644 kingdom/access/authentication/authenticator.py create mode 100644 kingdom/access/authorization/__init__.py rename src/core/access/test_authorization.py => kingdom/access/authorization/dsl.py (75%) create mode 100644 kingdom/access/authorization/encode.py create mode 100644 kingdom/access/authorization/model.py create mode 100644 kingdom/access/authorization/verify.py create mode 100644 kingdom/access/tests/__init__.py create mode 100644 kingdom/access/tests/test_encode.py create mode 100644 kingdom/access/tests/test_verify.py create mode 100644 kingdom/access/utils.py delete mode 100644 src/core/access/authorization.py delete mode 100644 src/core/access/test_permission.py diff --git a/kingdom/__init__.py b/kingdom/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kingdom/access/__init__.py b/kingdom/access/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kingdom/access/authentication/authenticator.py b/kingdom/access/authentication/authenticator.py new file mode 100644 index 0000000..40e8a02 --- /dev/null +++ b/kingdom/access/authentication/authenticator.py @@ -0,0 +1,50 @@ +""" +authenticator.py +Handles authentication throughout services +""" +from dataclasses import dataclass +from typing import Dict + +import jwt + +from src.core import config + + +class InvalidToken(Exception): + def __init__(self): + super().__init__("Invalid JWT token.") + + +@dataclass +class JWT: + "Simple JWT Wrapper" + token: str + + def decode(self) -> Dict: + """Decodes a JWT""" + try: + self.payload = jwt.decode( + jwt=self.token, + key=config.get_jwt_secret_key(), + algorithms=config.JWT_ALGORITHM, + ) + return self.payload + except jwt.PyJWTError: + raise InvalidToken() + + +class Authenticator: + token: JWT + payload: Dict + + def __init__(self, token: str): + self.jwt = JWT(token) + + def __call__(self): + self.payload = self.jwt.decode() + + +def test_authenticator(): + token = "abr9f8c09adj!lsd.sdxxf.ldfj" + auth = Authenticator(token) + assert auth() diff --git a/kingdom/access/authorization/__init__.py b/kingdom/access/authorization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/core/access/test_authorization.py b/kingdom/access/authorization/dsl.py similarity index 75% rename from src/core/access/test_authorization.py rename to kingdom/access/authorization/dsl.py index 93b84ec..580b011 100644 --- a/src/core/access/test_authorization.py +++ b/kingdom/access/authorization/dsl.py @@ -1,68 +1,44 @@ # test_authorization.py - -# Requirements: -# A user tries to access a given resource. We need to check if it has enough -# access running a policy check under a context of available data. -# -# User policies are resolved through its associated Roles. -# -# Relationships: -# User 1 .. N Role -# Role 1 .. N Policy -# Policy 1 .. 1 Operation -# Policy 1 .. 1 Resource -# Policy 1 .. N Where Clauses -# -# Policy: -# - operation: [READ, CREATE, UPDATE, DELETE] -# - resource: [ACCOUNT] -# - conditionals: [resource.id = *] -# -# INTERPRETATION of a Policy: -# -# A role {r} -# is allowed to do {operation} -# on all instances of {resource} -# that satisfies {conditional[0]} or {conditional[1]} or {conditional[N-1]} -# -# SPECIFICATION of Conditionals: -# -# Vocabulary. These contains current implementation expected limitations and -# simplifications: -# primary ::= "resource" -# identifier ::= "id" -# selector ::= "*" | string -# attrref ::= primary "." identifier -# or_expr ::= "||" -# compr_op ::= "==" -# cond ::= attrref compr_op selector -# conds ::= (cond or_expr)* -# -# There are two available selectors: -# 1. All instances: "*" -# 2. Individual instances: "" -# -# There are some conditions to selectors: -# 1. All selector must **always** be alone. -# +"""" +dsl.py + + SPECIFICATION of Conditionals: + + Vocabulary. These contains current implementation expected limitations and + simplifications: + primary ::= "resource" + identifier ::= "id" + selector ::= "*" | string + attrref ::= primary "." identifier + or_expr ::= "||" + compr_op ::= "==" + cond ::= attrref compr_op selector + conds ::= (cond or_expr)* + + There are two available selectors: + 1. All instances: "*" + 2. Individual instances: "" + + There are some conditions to selectors: + 1. All selector must **always** be alone. +""" import string from typing import List - valid_conditionals = [ "resource.id == 128f12334hjg || resource.id == 12839712893791823", "resource.id==128f12334hjg||resource.id==12839712893791823", "resource.id==*", "resource.id ==*", - "resource.id== *" + "resource.id== *", ] simplified_conditionals = { # This meanings that list[0] should be translated to list[1] "selector conditional": [ "resource.id==*||resource.id==8123798fcd89||resource.id==8197498127", - "resource.id == *" + "resource.id == *", ] } @@ -79,7 +55,7 @@ "resouce.id == 1283971283ff || subject.id == 1891273987123", "resource.created_at == 2319833012707 || resource.id == faf76bc7", "resource.id == 8129370192ff || resource.id == 8123091283908", - "resource.id!=291f767d8bc" + "resource.id!=291f767d8bc", ] @@ -116,9 +92,7 @@ def conditionals_split(sequence: str): # meaning that we had a loose OR return False - parsed_expr = [ - expression.strip() for expression in expressions - ] + parsed_expr = [expression.strip() for expression in expressions] for expr in parsed_expr: for char in expr: if char.isspace(): @@ -234,7 +208,7 @@ def parse_reference(reference_expr): else: return (False,) - # check for illegality on the rest of the expression + # check for illegality on the rest of the expression # meaning we must only accept white spaces between end of ref and operator rest = reference_expr[parsing_idx:] for idx, token in enumerate(rest): @@ -290,44 +264,44 @@ def parse_identifier_reference(expression): def test_parse_operator(): input = [ - "== 'd8f7s9d8f7'", # valid - "== '8dfs8d7f9'", # valid - "== ''", # valid - "== \"'21f90912'\" ", # invalid - "== '21f90912' ", # valid - "= '*' ", # invalid - "< \"ddx\"", # invalid - " < '3030fk30'", # valid - " > ''", # valid - " >> ''", # invalid - "=== '*'", # invalid - ">= '2fd04'", # valid - "!= '*'", # valid - " + '*'", # invalid - "-- 'd'", # invalid - "/ 'xxvc'", # invalid - "=+ '*'", # invalid - "=- '*'", # invalid + "== 'd8f7s9d8f7'", # valid + "== '8dfs8d7f9'", # valid + "== ''", # valid + "== \"'21f90912'\" ", # invalid + "== '21f90912' ", # valid + "= '*' ", # invalid + '< "ddx"', # invalid + " < '3030fk30'", # valid + " > ''", # valid + " >> ''", # invalid + "=== '*'", # invalid + ">= '2fd04'", # valid + "!= '*'", # valid + " + '*'", # invalid + "-- 'd'", # invalid + "/ 'xxvc'", # invalid + "=+ '*'", # invalid + "=- '*'", # invalid ] want = [ - ("==", "'d8f7s9d8f7'"), # valid - ("==", "'8dfs8d7f9'"), # valid - ("==", "''"), # valid - (False,), # invalid - ("==", "'21f90912'"), # valid - (False,), # invalid - (False,), # invalid - ("<", "'3030fk30'"), # valid - (">", "''"), # valid - (False,), # valid - (False,), # invalid - (">=", "'2fd04'"), # valid - ("!=", "'*'"), # valid - (False,), # invalid - (False,), # invalid - (False,), # invalid - (False,), # invalid - (False,), # invalid + ("==", "'d8f7s9d8f7'"), # valid + ("==", "'8dfs8d7f9'"), # valid + ("==", "''"), # valid + (False,), # invalid + ("==", "'21f90912'"), # valid + (False,), # invalid + (False,), # invalid + ("<", "'3030fk30'"), # valid + (">", "''"), # valid + (False,), # valid + (False,), # invalid + (">=", "'2fd04'"), # valid + ("!=", "'*'"), # valid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid ] got = [parse_operator(op) for op in input] assert got == want @@ -417,11 +391,7 @@ def parse_selector(selector_expr): parsing_idx = -1 def isselector(token): - return ( - token.isnumeric() - or token.isidentifier() - or token == ALL_TOKEN - ) + return token.isnumeric() or token.isidentifier() or token == ALL_TOKEN for idx, token in enumerate(selector_expr): if idx == 0: @@ -440,13 +410,13 @@ def isselector(token): return (False,) # are we dealig with an *? - if ALL_TOKEN in selector and selector != '*': - # plain comparison + if ALL_TOKEN in selector and selector != "*": + # plain comparison return (False,) rest = selector_expr[parsing_idx + 1:] if len(rest) > 0 or len(selector) == 0: - # we shouldn't have anything left + # we shouldn't have anything left return (False,) return (selector,) @@ -480,11 +450,10 @@ def test_parse_expressions(): "reso urce.id == '*'", "resource.id === '*'", "resource.id='dgv8bf'", - "resource.id == \"*\"", + 'resource.id == "*"', "subject.salary > 1800", ] - got = [parse_expression(expr) for expr in invalid_input] want = [ False, @@ -513,4 +482,3 @@ def parse_expression(expr): if selector is False: return False return (identifier, reference, operator, selector) - diff --git a/kingdom/access/authorization/encode.py b/kingdom/access/authorization/encode.py new file mode 100644 index 0000000..59d2865 --- /dev/null +++ b/kingdom/access/authorization/encode.py @@ -0,0 +1,110 @@ +""" +encode.py + +Whenever a user is successfully authenticated, application should be able to +properly encode its policies in an associative mapping form to be packed within +JWT payload. +""" + +from typing import Dict, List, Tuple + +from kingdom.access.authorization.model import ( + TOKEN_ALL, + Conditional, + Permission, + Policy, + PolicyContext, + Resource, + Selector, +) + + +def unique_permissions( + current_permissions: Tuple[Permission, ...], + incoming_permissions: Tuple[Permission, ...], +) -> Tuple[Permission, ...]: + current = set(current_permissions) + incoming = set(incoming_permissions) + # Union + return tuple(current | incoming) + + +def encode(policies: List[Policy]) -> PolicyContext: + """ + This functions parses a list of Policy and return a mapping of + resource-selector-perimssion, known as PolicyContext. + This mapping should also contain a simplification of redundant policies + that a subject might have. + + >>> a_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[Conditional("resource.id", "*")] + ) + >>> ya_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "5f34")] + ) + >>> pack_policies([a_policy, ya_policy]) + { + "product": { + "5f34": (Permission.READ,) + }, + "account": { + "*": (Permission.UPDATE,) + } + } + + TODO: This is currently a side-effectful implementation, we could def. + implement a pure one. + """ + owned: PolicyContext = {} + + # Iterative approach: on every iteration we keep on + # building output dictionary + for policy in policies: + resource = policy.resource.alias + permissions = policy.permissions + if resource not in owned: + owned[resource] = {} + + # Iterate on every conditional + for conditional in policy.conditionals: + selector = conditional.selector + # There are two kinds of selectors: specific and generics. + # And a selector already exist for a given resource or it doesn't. + if selector in owned[resource]: + # When a selector already exists, we make sure we keep + # permissions unique + owned[resource][selector] = unique_permissions( + owned[resource][selector], permissions + ) + else: + # Otherwise, it's a new selector, hence a new entry + owned[resource][selector] = tuple(permissions) + + # Now remove any redundant permission due to a possible "*" selector + # Brute-force. + for resource, selector_perm in owned.items(): + if TOKEN_ALL not in selector_perm: + # Well, nothing to do then. + continue + + all_token_perms = set(selector_perm[TOKEN_ALL]) + for selector, permissions in selector_perm.items(): + # Subtract "*"'s permissions from the other permissions + if selector == TOKEN_ALL: + # Not this one. + continue + current_perms = set(permissions) + updated_perms = current_perms - all_token_perms + + if not updated_perms: + # All of this selector's permissions are already + # contemplated by "*" + del owned[resource][selector] + else: + owned[resource][selector] = tuple(updated_perms) + + return owned diff --git a/kingdom/access/authorization/model.py b/kingdom/access/authorization/model.py new file mode 100644 index 0000000..2cc596b --- /dev/null +++ b/kingdom/access/authorization/model.py @@ -0,0 +1,84 @@ +from dataclasses import dataclass +from enum import Enum +from typing import Dict, List, NamedTuple, Tuple, Union + +TOKEN_ALL = "*" + + +class Permission(Enum): + """Fine-grained mapping of a permission.""" + + READ = 0b000 + CREATE = 0b001 + UPDATE = 0b010 + DELETE = 0b100 + + def __hash__(self) -> int: + return hash(self.value) + + def __or__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value | other + + def __ror__(self, other): + return self.__or__(other) + + def __and__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value & other + + def __rand__(self, other): + return self.__and__(other) + + +@dataclass +class Resource: + """An enterprise-wide resource wrapper""" + + name: str + + def __init__(self, name: str): + self.name = name + self.alias = name.lower() + + +@dataclass +class Conditional: + """One and only one conditional clause""" + + identifier: str + selector: str + + +@dataclass +class Policy: + """ + Aggregate of authorization module. + + An association of permissions that are allowed to be performed on + on instances of a given resource that match ANY of the conditionals + criteria""" + + resource: Resource + permissions: Tuple[Permission] + conditionals: List[Conditional] + + +@dataclass +class AccessRequest: + operation: int + resource: str + selector: str + + def __init__(self, operation: Permission, resource: Resource, selector: str): + self.operation = operation.value + self.resource = resource.alias + self.selector = selector + + +ResourceAlias = str +Selector = str +SelectorPermissionMap = Dict[Selector, Tuple[Permission, ...]] +PolicyContext = Dict[ResourceAlias, SelectorPermissionMap] diff --git a/kingdom/access/authorization/verify.py b/kingdom/access/authorization/verify.py new file mode 100644 index 0000000..a57fe08 --- /dev/null +++ b/kingdom/access/authorization/verify.py @@ -0,0 +1,175 @@ +""" +verify.py + +Responsible for checking and handling whether a given subject is allowed to do +a given action on a given resource. + +There are two authorization scenarios considered: + 1. A read scenario, in which authorization module should pass-along to + the server which instances of a given resource that subject is allowed + to read. + 2. A write scenario, in which authorization module should tell whether + user has permission or not to perform such action. +""" +from typing import Dict, List, Tuple, Union + +from kingdom.access.authorization.model import ( + TOKEN_ALL, + AccessRequest, + Permission, + Policy, + PolicyContext, + Selector, +) + + +def check_read_permission( + owned_policies: PolicyContext, access_request: AccessRequest +) -> List[Selector]: + """ + Consider a subject that tries to access a resource. The access attempt is + abstracted as AccessRequest and the whole set of policies it owns is + represented as PolicyContext. + + This function returns the list of Selectors of requested resource that + the subject is allowed to read. + + >>> access_request = AccessRequest( + operation=Permission.READ, resource=Resource("Coupon"), selector="*" + ) + >>> owned_perm = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + }, + "users": { + "ccf3": (Permission.READ,), + "abbc": (Permission.UPDATE, Permission.DELETE), + }, + } + >>> check_read_permission(owned_perm, access_request) + ["ab4c", "bc3f"] + + More examples on test suite. + """ + # Sanity check + assert access_request.operation == Permission.READ.value + + resource = access_request.resource + if resource not in owned_policies: + # Subject has no permission related to requested resource. + return [] + + # Subject has at least one selector that it can read. + if TOKEN_ALL in owned_policies[resource]: + # If it has any binding to "*", then it can read it all. + return [TOKEN_ALL] + + # Subject has specific identifiers, we shall return them. + allowed_ids = owned_policies[resource].keys() + return list(allowed_ids) + + +def check_write_permission( + owned_policies: PolicyContext, access_request: AccessRequest +) -> bool: + """ + Consider a subject that tries to access a resource. The access attempt is + abstracted as AccessRequest and the whole set of policies it owns is + represented as PolicyContext. + + This function returns whether the user has enough permissions to do + the write operation on AccessRequest. + + >>> access_request = AccessRequest( + operation=Permission.CREATE, resource=Resource("Coupon"), selector="*" + ) + >>> owned_perm = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE,), + "b4a3": (Permission.DELETE,), + }, + } + >>> check_write_permission(owned_perm, access_request) + False + + More examples on test suite. + """ + # Sanity check. + assert access_request.operation & ( + Permission.CREATE | Permission.UPDATE | Permission.DELETE + ) + + resource = access_request.resource + if resource not in owned_policies: + # Resource is unknown to subject. + return False + # resource_owned = owned_policies[resource] + + # CREATE case is different than UPDATE and DELETE. + # It requires a "*" selector. + if access_request.operation == Permission.CREATE.value: + # To CREATE, subject must have at least one '*' policy. + if TOKEN_ALL not in owned_policies[resource]: + return False + + # Checking is a simple O(1) step. + return Permission.CREATE in owned_policies[resource][TOKEN_ALL] + + # Deal with UPDATE or DELETE + # Check for generics. + if TOKEN_ALL in owned_policies[resource]: + permissions = owned_policies[resource][TOKEN_ALL] + if is_allowed(permissions, access_request.operation): + # Wildcard matches permission. + return True + # if access_request.operation in owned_resource[TOKEN_ALL]: + # return True + + # Then specify. + if access_request.selector not in owned_policies[resource]: + # No rule for requested instance. Denied. + return False + + permissions = owned_policies[resource][access_request.selector] + return is_allowed(permissions, access_request.operation) + + +def is_allowed( + owned_permissions: Tuple[Permission, ...], + requested_operation: Union[int, Permission], +) -> bool: + """ + When a subject tries to perform a write, this function calculates whether + the requested operation is allowed on a set of owned permissions. + + There are a few caveats: + 1. Write permission overrides read permission. + e.g. If a Subject has any write permission, him/her implicitly + have read permission. + 2. No write permissions override one-another. + e.g. If a Subject has DELETE permission and it doesn't have + UPDATE or CREATE permissions, it is only allowed to delete. + + + >>> owned_perm = (Permission.READ, Permission.UPDATE, Permission.DELETE), + >>> requested_op = Permission.CREATE + >>> is_allowed(owned_perm, requested_op) + False + """ + + if isinstance(requested_operation, int): + requested_operation = Permission(requested_operation) + + # corner case is when requested permission is READ: + if requested_operation == Permission.READ and owned_permissions: + # if we have ANY permission, it means that the user is able to read + return True + + permissions = 0 + for owned_permission in owned_permissions: + permissions |= owned_permission + + return int(permissions & requested_operation) > 0 diff --git a/kingdom/access/tests/__init__.py b/kingdom/access/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kingdom/access/tests/test_encode.py b/kingdom/access/tests/test_encode.py new file mode 100644 index 0000000..22660dc --- /dev/null +++ b/kingdom/access/tests/test_encode.py @@ -0,0 +1,112 @@ +from kingdom.access.authorization.encode import encode +from kingdom.access.authorization.model import ( + Conditional, + Permission, + Policy, + PolicyContext, + Resource, + Selector, +) + + +def test_simple_policy_packing(): + """Given a list of Policies with no overlapping conditional selectors, + we expect a well-formatted DTO dict""" + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "ab4f"), + Conditional("resource.id", "13fa"), + ], + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE,), + conditionals=[Conditional("resource.id", "*"), ], + ) + + account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "*"), ], + ) + + ya_account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "0bf3"), + Conditional("resource.id", "bc0e"), + ], + ) + + role_policies = [ + product_policy, + ya_product_policy, + account_policy, + ya_account_policy, + ] + + owned_perm = { + "product": { + "*": (Permission.CREATE,), + "ab4f": (Permission.UPDATE,), + "13fa": (Permission.UPDATE,), + }, + "account": { + "*": (Permission.READ,), + "0bf3": (Permission.UPDATE,), + "bc0e": (Permission.UPDATE,), + }, + } + + assert encode(role_policies) == owned_perm + + +def test_redundant_policy_packing(): + """Given a list of Policies with overlapping conditional selectors, + we expect a well-formatted DTO dict""" + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ, Permission.CREATE), + conditionals=[Conditional("resource.id", "*"), ], + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ, Permission.UPDATE), + conditionals=[ + Conditional("resource.id", "7fb4"), + Conditional("resource.id", "49f3"), + Conditional("resource.id", "abc9"), + ], + ) + + yao_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ, Permission.DELETE,), + conditionals=[ + Conditional("resource.id", "aaa"), + Conditional("resource.id", "abc9"), + ], + ) + + role_policies = [ + product_policy, + ya_product_policy, + yao_product_policy, + ] + + owned_perm = { + "product": { + "*": (Permission.READ, Permission.CREATE,), + "7fb4": (Permission.UPDATE,), + "49f3": (Permission.UPDATE,), + "abc9": (Permission.UPDATE, Permission.DELETE), + "aaa": (Permission.DELETE,), + }, + } + + assert encode(role_policies) == owned_perm diff --git a/kingdom/access/tests/test_verify.py b/kingdom/access/tests/test_verify.py new file mode 100644 index 0000000..732f398 --- /dev/null +++ b/kingdom/access/tests/test_verify.py @@ -0,0 +1,205 @@ +from kingdom.access.authorization.model import ( + AccessRequest, + Permission, + Policy, + Resource, +) +from kingdom.access.authorization.verify import ( + check_read_permission, + check_write_permission, + is_allowed, +) + +# Test read scenario, check_read_permission + + +def test_read_permission_filtering_unauthorized(): + access_request = AccessRequest( + operation=Permission.READ, resource=Resource("Product"), selector="*" + ) + owned_perm = { + "account": {"*": (Permission.CREATE,), }, + } + assert check_read_permission(owned_perm, access_request) == [] + + access_request = AccessRequest( + operation=Permission.READ, resource=Resource("Account"), selector="*" + ) + owned_perm = { + "product": {"*": (Permission.READ,), }, + } + assert check_read_permission(owned_perm, access_request) == [] + + +def test_read_permission_filtering_authorized_but_specific(): + access_request = AccessRequest( + operation=Permission.READ, resource=Resource("Coupon"), selector="*" + ) + owned_perm = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE,), + "b4a3": (Permission.DELETE,), + }, + } + assert check_read_permission(owned_perm, access_request) == [ + "ab4c", + "bc3f", + "cc4a", + "b4a3", + ] + + access_request = AccessRequest( + operation=Permission.READ, resource=Resource("Coupon"), selector="*" + ) + owned_perm = { + "coupon": {"*": (Permission.CREATE,), }, + } + assert check_read_permission(owned_perm, access_request) == ["*"] + + +# Test write scenario, check_write_permission + + +def test_write_permission_unauthorized(): + # Tries to write in an unknown resource to the subject + access_request = AccessRequest( + operation=Permission.CREATE, resource=Resource("User"), selector="*" + ) + owned_perm = { + "product": {"*": (Permission.READ,), }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without enough privileges. + access_request = AccessRequest( + operation=Permission.CREATE, resource=Resource("Coupon"), selector="*" + ) + owned_perm = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE,), + "b4a3": (Permission.DELETE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest( + operation=Permission.UPDATE, resource=Resource("User"), selector="ffc0" + ) + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE), + "abc3": (Permission.UPDATE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest( + operation=Permission.DELETE, resource=Resource("User"), selector="c4fd" + ) + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), + "d3fc": (Permission.DELETE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + +def test_write_permission_authorized(): + # Tries to create a new item. + access_request = AccessRequest( + operation=Permission.CREATE, resource=Resource("User"), selector="*" + ) + owned_perm = { + "user": {"*": (Permission.UPDATE, Permission.CREATE,), }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to update a specific value on a wildcard policy. + access_request = AccessRequest( + operation=Permission.UPDATE, resource=Resource("Coupon"), selector="43df" + ) + owned_perm = { + "coupon": {"*": (Permission.READ, Permission.UPDATE, Permission.DELETE), }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to delete a specific value on a specific policy. + access_request = AccessRequest( + operation=Permission.DELETE, resource=Resource("User"), selector="3dc4" + ) + owned_perm = { + "user": {"*": (Permission.CREATE,), "3dc4": (Permission.DELETE,), }, + } + assert check_write_permission(owned_perm, access_request) is True + + +# Test is_allowed() + + +def test_tries_to_create_unauthorized(): + # user have no creation permissions + input = [ + (Permission.READ,), + (Permission.READ, Permission.UPDATE), + (Permission.READ, Permission.UPDATE, Permission.DELETE), + (Permission.UPDATE, Permission.DELETE), + ] + for perm in input: + assert is_allowed(perm, Permission.CREATE) is False + + +def test_tries_to_update_anauthorized(): + # user have no update permission + input = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.DELETE), + ] + for perm in input: + assert is_allowed(perm, Permission.UPDATE) is False + + +def test_tries_to_delete_anauthorized(): + # user have no delete permission + input = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.UPDATE), + (Permission.CREATE, Permission.UPDATE), + ] + for perm in input: + assert is_allowed(perm, Permission.DELETE) is False + + +def test_tries_to_read_without_explicit_read_authorized(): + # user have only write permissions and tries to read + input = [ + (Permission.CREATE,), + (Permission.UPDATE,), + (Permission.DELETE,), + (Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.UPDATE), + (Permission.DELETE, Permission.UPDATE), + (Permission.CREATE, Permission.DELETE, Permission.UPDATE), + ] + for perm in input: + assert is_allowed(perm, Permission.READ) + + +def test_tries_to_write_but_has_only_read(): + # user have only read permissions and tries to do all writes + input = [ + Permission.CREATE, + Permission.UPDATE, + Permission.DELETE, + ] + for perm in input: + assert is_allowed((Permission.READ,), perm) is False diff --git a/kingdom/access/utils.py b/kingdom/access/utils.py new file mode 100644 index 0000000..fefd435 --- /dev/null +++ b/kingdom/access/utils.py @@ -0,0 +1,21 @@ +import jwt +from graphql import GraphQLError + +from src.auth import config +from src.core.exceptions import ServerException + +# TODO This should be inside handlers +class InvalidToken(ServerException): + def __init__(self): + super().__init__("Invalid Token") + + +def parse_token(token: str) -> dict: + try: + return jwt.decode( + jwt=token, + key=config.get_jwt_secret_key(), + algorithms=config.JWT_ALGORITHM, + ) + except jwt.PyJWTError: + raise InvalidToken() diff --git a/src/auth/domain/model.py b/src/auth/domain/model.py index bd93c9d..8c294fe 100644 --- a/src/auth/domain/model.py +++ b/src/auth/domain/model.py @@ -121,6 +121,8 @@ def generate_password_reset_token(self) -> bytes: expiration: datetime = now + timedelta( minutes=config.get_jwt_token_expiration() ) + # Fix this payload according to RFC 7519 + # Sub should hold 'access_key' payload: dict = dict( sub=self.password, exp=expiration, diff --git a/src/core/access/authorization.py b/src/core/access/authorization.py deleted file mode 100644 index d5d97d3..0000000 --- a/src/core/access/authorization.py +++ /dev/null @@ -1,143 +0,0 @@ -# authorization.py -# -# Context: -# When a user logs in, front-end receives a JWT on user's behalf. -# User has roles associated to him/her. -# And each of these roles have policies attached to them. -# -# USE CASE 1: Policy Packing and Unpacking -# Whenever a user logs in, their policies gets packed into a JWT that's -# transferred within system's services boundaries. -# -# USE CASE 2: Policy Enforcement -# Whenever a user tries to perform an operation on a given resource -# i.e. an AccessRequest, it's up to authorization module to enforce -# whether user has enough privileges. - -# USE CASE 1 -- In-depth -# Parse a list of Policy and return a mapping of resource-selector-operation -# This mapping should also *simplify* the policy and remove redundant policies - -from collections import namedtuple -from dataclasses import dataclass -from enum import Enum -from typing import List - -# Operation order: -# Split between READ and WRITE operations. -# READ: READ -# WRITE: CREATE | UPDATE | DELETE -# -# Rules: -# 1. WRITE permission overrides READ permission. -# e.g. If a USER have any of WRITE permission, him/her implicitly -# have READ permission. -# 2. No WRITE permissions override one-another. -# e.g. If a user has DELETE permission and it doesn't have -# UPDATE or CREATE permissions, it is only allowed to delete. - - -class Permission(Enum): - READ = 0b000 - CREATE = 0b001 - UPDATE = 0b010 - DELETE = 0b100 - - def __or__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value | other.value - - def __ror__(self, other): - return self.__or__(other) - - def __and__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value & other.value - - -@dataclass -class Resource: - name: str - - -@dataclass -class Policy(object): - resource: Resource - permissions: List[Permission] - selectors: str - - -def test_tries_to_create_unauthorized(): - # user have no creation permissions - input = [ - (Permission.READ), - (Permission.READ, Permission.UPDATE), - (Permission.READ, Permission.UPDATE, Permission.DELETE), - (Permission.UPDATE, Permission.DELETE) - ] - for perm in input: - assert has_permission(perm, Permission.CREATE) is False - - -def test_tries_to_update_anauthorized(): - # user have no update permission - input = [ - (Permission.READ), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.DELETE) - ] - for perm in input: - assert has_permission(perm, Permission.UPDATE) is False - - -def test_tries_to_delete_anauthorized(): - # user have no delete permission - input = [ - (Permission.READ), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.UPDATE), - (Permission.CREATE, Permission.UPDATE) - ] - for perm in input: - assert has_permission(perm, Permission.DELETE) is False - - -def test_tries_to_read_without_explicit_read_authorized(): - # user have only write permissions and tries to read - input = [ - (Permission.CREATE), - (Permission.UPDATE), - (Permission.DELETE), - (Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.UPDATE), - (Permission.DELETE, Permission.UPDATE), - (Permission.CREATE, Permission.DELETE, Permission.UPDATE), - ] - for perm in input: - assert has_permission(perm, Permission.READ) - - -def test_tries_to_write_but_has_only_read(): - # user have only read permissions and tries to do all writes - input = [ - Permission.CREATE, - Permission.UPDATE, - Permission.DELETE, - ] - for perm in input: - assert has_permission((Permission.READ), perm) is False - - -def has_permission( - owned_permissions: tuple, - requested_operation: Permission -) -> bool: - for permission in owned_permissions: - permission |= permission - - return permission & requested_operation > 0 - - diff --git a/src/core/access/test_permission.py b/src/core/access/test_permission.py deleted file mode 100644 index efb1bac..0000000 --- a/src/core/access/test_permission.py +++ /dev/null @@ -1,602 +0,0 @@ -# authorization.py -""" - Context: - When a user logs in, front-end receives a JWT on user's behalf. - User has roles associated to him/her. - And each of these roles have policies attached to them. -""" - -from dataclasses import dataclass -from enum import Enum -from typing import Dict, List, NamedTuple, Tuple, Union - -TOKEN_ALL = "*" - - -class Permission(Enum): - """Operation-permission""" - READ = 0b000 - CREATE = 0b001 - UPDATE = 0b010 - DELETE = 0b100 - - def __hash__(self) -> int: - return hash(self.value) - - def __or__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value | other - - def __ror__(self, other): - return self.__or__(other) - - def __and__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value & other - - def __rand__(self, other): - return self.__and__(other) - - -@dataclass -class Resource: - """An enterprise-wide resource wrapper""" - name: str - - def __init__(self, name: str): - self.name = name - self.alias = name.lower() - - -@dataclass -class Conditional: - """One and only one conditional clause""" - identifier: str - selector: str - - -@dataclass -class Policy: - """An association of permissions that are allowed to be performed on - on instances of a given resource that match ANY of the conditionals - criteria""" - resource: Resource - permissions: Tuple[Permission] - conditionals: List[Conditional] - - -""" -USE CASE 1: Policy Packing -- Whenever a user logs in, their policies gets packed into a JWT that's -transferred within system's services boundaries. -- We don't need to unpack it, since it's a unidirectional step from -authorization service to JWTs. Incoming logic relies on unpacked data -structure - -In-depth: -Parse a list of Policy and return a mapping of resource-selector-operation -This mapping should also *simplify* the policy and remove redundant policies -""" - - -def test_simple_policy_packing(): - """Given a list of Policies with no overlapping conditional selectors, - we expect a well-formatted DTO dict""" - product_policy = Policy(resource=Resource("Product"), - permissions=(Permission.UPDATE, ), - conditionals=[ - Conditional("resource.id", "ab4f"), - Conditional("resource.id", "13fa"), - ]) - - ya_product_policy = Policy(resource=Resource("Product"), - permissions=(Permission.CREATE, ), - conditionals=[ - Conditional("resource.id", "*"), - ]) - - account_policy = Policy(resource=Resource("Account"), - permissions=(Permission.READ, ), - conditionals=[ - Conditional("resource.id", "*"), - ]) - - ya_account_policy = Policy(resource=Resource("Account"), - permissions=(Permission.UPDATE, ), - conditionals=[ - Conditional("resource.id", "0bf3"), - Conditional("resource.id", "bc0e"), - ]) - - role_policies = [ - product_policy, ya_product_policy, account_policy, ya_account_policy - ] - - owned_perm = { - "product": { - "*": (Permission.CREATE, ), - "ab4f": (Permission.UPDATE, ), - "13fa": (Permission.UPDATE, ), - }, - "account": { - "*": (Permission.READ, ), - "0bf3": (Permission.UPDATE, ), - "bc0e": (Permission.UPDATE, ), - } - } - - assert pack_policies(role_policies) == owned_perm - - -def test_redundant_policy_packing(): - """Given a list of Policies with overlapping conditional selectors, - we expect a well-formatted DTO dict""" - product_policy = Policy(resource=Resource("Product"), - permissions=(Permission.READ, Permission.CREATE), - conditionals=[ - Conditional("resource.id", "*"), - ]) - - ya_product_policy = Policy(resource=Resource("Product"), - permissions=(Permission.READ, - Permission.UPDATE), - conditionals=[ - Conditional("resource.id", "7fb4"), - Conditional("resource.id", "49f3"), - Conditional("resource.id", "abc9"), - ]) - - yao_product_policy = Policy(resource=Resource("Product"), - permissions=( - Permission.READ, - Permission.DELETE, - ), - conditionals=[ - Conditional("resource.id", "aaa"), - Conditional("resource.id", "abc9"), - ]) - - role_policies = [ - product_policy, - ya_product_policy, - yao_product_policy, - ] - - owned_perm = { - "product": { - "*": ( - Permission.READ, - Permission.CREATE, - ), - "7fb4": (Permission.UPDATE, ), - "49f3": (Permission.UPDATE, ), - "abc9": (Permission.UPDATE, Permission.DELETE), - "aaa": (Permission.DELETE, ), - }, - } - - assert pack_policies(role_policies) == owned_perm - - -def pack_policies(policies: List[Policy]) -> Dict: - """ - Pack a list of policies into a known PolicyDTO format. - TODO: Refactor this in order to properly encapsulate output DTO format. - - >>> a_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.UPDATE,), - conditionals=[Conditional("resource.id", "*")] - ) - >>> ya_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "5f34")] - ) - >>> pack_policies([a_policy, ya_policy]) - { - "product": { - "5f34": (Permission.READ,) - }, - "account": { - "*": (Permission.UPDATE,) - } - } - """ - owned: Dict[str, Dict[str, tuple]] = {} - - # iterative straightforward approach: on every iteration we keep on - # building our output dictionary - for policy in policies: - resource = policy.resource.alias - permissions = policy.permissions - if resource not in owned: - owned[resource] = {} - - # iterate on every conditional - for conditional in policy.conditionals: - selector = conditional.selector - # There are two kinds of selectors: specific and generics. - # And a selector already exist for a given resource or it doesn't. - if selector in owned[resource]: - # When a selector already exists, we make sure we keep - # permissions unique - current_perms = set(owned[resource][selector]) - incoming_perms = set(permissions) - # Union - updated_permissions = tuple(incoming_perms | current_perms) - owned[resource][selector] = updated_permissions - else: - # Otherwise, it's a new selector, hence a new entry on - # dict. - owned[resource][selector] = tuple(permissions) - - # Brute-forcing, we now iterate over each resource and remove - # redundant permissions - for resource, selector_dict in owned.items(): - if TOKEN_ALL not in selector_dict: - # nothing to do - continue - - all_token_perms = set(selector_dict[TOKEN_ALL]) - for selector, permissions in selector_dict.items(): - # We now subtract "*"'s permissions from the other permissions - if selector == TOKEN_ALL: - # Not this one. - continue - current_perms = set(permissions) - updated_perms = current_perms - all_token_perms - - if len(updated_perms) == 0: - # Meaning that all of this selector's permissions are already - # contemplated by "*" - del owned[resource][selector] - else: - owned[resource][selector] = tuple(updated_perms) - - return owned - - -""" -USE CASE 2: Incoming AccessRequest - Whenever a subject tries to perform an operation on a given resource - i.e. an AccessRequest, it's up to authorization module to decide whether - that operation is allowed. - - There are two authorization scenarios: - 1. A READ scenario, in which authorization module should pass-along to - the server which instances that subject is allowed to read. - 2. A WRITE scenario, in which authorization module should tell whether - user has permission or not. - - READ SCENARIO: - Given that - 1. AccessRequest.operation is READ - 2. And AccessRequest.resource in OwnedPermission.resource - 3. Return OwnedPermission.resource.selectors keys. - 3.1. If "*" in OwnedPermission.resource.selectors, return only "*". - - PS: Since READ is the least privilege level, every key should hold at least - enough privileges for read. -""" - - -@dataclass -class AccessRequest: - operation: int - resource: str - selector: str - - def __init__(self, operation: Permission, resource: Resource, - selector: str): - self.operation = operation.value - self.resource = resource.alias - self.selector = selector - - -def test_read_permission_filtering_unauthorized(): - access_request = AccessRequest(operation=Permission.READ, - resource=Resource("Product"), - selector="*") - owned_perm = { - "account": { - "*": (Permission.CREATE, ), - }, - } - assert check_read_permission(owned_perm, access_request) == [] - - access_request = AccessRequest(operation=Permission.READ, - resource=Resource("Account"), - selector="*") - owned_perm = { - "product": { - "*": (Permission.READ, ), - }, - } - assert check_read_permission(owned_perm, access_request) == [] - - -def test_read_permission_filtering_authorized_but_specific(): - access_request = AccessRequest(operation=Permission.READ, - resource=Resource("Coupon"), - selector="*") - owned_perm = { - "coupon": { - "ab4c": (Permission.READ, ), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE, ), - "b4a3": (Permission.DELETE, ), - }, - } - assert check_read_permission( - owned_perm, access_request) == ["ab4c", "bc3f", "cc4a", "b4a3"] - - access_request = AccessRequest(operation=Permission.READ, - resource=Resource("Coupon"), - selector="*") - owned_perm = { - "coupon": { - "*": (Permission.CREATE, ), - }, - } - assert check_read_permission(owned_perm, access_request) == ["*"] - - -def check_read_permission(owned_permissions: Dict, - access_request: AccessRequest) -> List: - # Sanity check - assert access_request.operation == Permission.READ.value - - if access_request.resource not in owned_permissions: - # Subject has no permission related to requested resource. - return [] - - # Subject has at least one selector that it can read. - if TOKEN_ALL in owned_permissions[access_request.resource]: - # If it has any binding to "*", then it can read it all. - return [TOKEN_ALL] - - # Subject has specific bindings, we shall return them. - allowed_ids = owned_permissions[access_request.resource].keys() - return list(allowed_ids) - - -""" - WRITE SCENARIO: - Given that - 1. AccessRequest.operation is CREATE|DELETE|UPDATE - 3. AccessRequest.resource in OwnedPermission.resource - 4. And that either - 2.1. "*" in OwnedPermission.resource.selectors Or - 2.2. AccessRequest.resource.selector in - OwnedPermission.resource.selectors - 5. Check that - AccessRequest.resource.selector.operation has permissions against - OwnedPermission.resource.selector.operations - - PS: Note that in each of these steps, if a condition is not satisfied - we instantly revoke. -""" - - -def test_write_permission_unauthorized(): - # Tries to write in an unknown resource to the subject - access_request = AccessRequest(operation=Permission.CREATE, - resource=Resource("User"), - selector="*") - owned_perm = { - "product": { - "*": (Permission.READ, ), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without enough privileges. - access_request = AccessRequest(operation=Permission.CREATE, - resource=Resource("Coupon"), - selector="*") - owned_perm = { - "coupon": { - "ab4c": (Permission.READ, ), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE, ), - "b4a3": (Permission.DELETE, ), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest(operation=Permission.UPDATE, - resource=Resource("User"), - selector="ffc0") - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE), - "abc3": (Permission.UPDATE, ), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest(operation=Permission.DELETE, - resource=Resource("User"), - selector="c4fd") - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), - "d3fc": (Permission.DELETE, ), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - -def test_write_permission_authorized(): - # Tries to create a new item. - access_request = AccessRequest(operation=Permission.CREATE, - resource=Resource("User"), - selector="*") - owned_perm = { - "user": { - "*": ( - Permission.UPDATE, - Permission.CREATE, - ), - }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to update a specific value on a wildcard policy. - access_request = AccessRequest(operation=Permission.UPDATE, - resource=Resource("Coupon"), - selector="43df") - owned_perm = { - "coupon": { - "*": (Permission.READ, Permission.UPDATE, Permission.DELETE), - }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to delete a specific value on a specific policy. - access_request = AccessRequest(operation=Permission.DELETE, - resource=Resource("User"), - selector="3dc4") - owned_perm = { - "user": { - "*": (Permission.CREATE, ), - "3dc4": (Permission.DELETE, ), - }, - } - assert check_write_permission(owned_perm, access_request) is True - - -def check_write_permission(owned_permissions: Dict, - access_request: AccessRequest) -> bool: - # Sanity check. - assert (access_request.operation \ - & (Permission.CREATE | Permission.UPDATE | Permission.DELETE)) - - if access_request.resource not in owned_permissions: - # Resource is unknown to subject. - return False - - owned_resource = owned_permissions[access_request.resource] - # CREATE case is different than UPDATE and DELETE. - # Because it requires a "*" selector. - if access_request.operation == Permission.CREATE.value: - # To CREATE, subject must have at least one '*' policy. - if TOKEN_ALL not in owned_resource: - return False - - # Checking is a simple O(1) step. - return Permission.CREATE in owned_resource[TOKEN_ALL] - - # Deal with UPDATE or DELETE - # Check for generics. - if TOKEN_ALL in owned_resource: - if is_allowed(owned_resource[TOKEN_ALL], access_request.operation): - # Wildcard matches permission. - return True - # if access_request.operation in owned_resource[TOKEN_ALL]: - # return True - - # Then specify. - if access_request.selector not in owned_resource: - # No rule for requested instance. Denied. - return False - - return is_allowed(owned_resource[access_request.selector], - access_request.operation) - # return access_request.operation in owned_resource[access_request.selector] - - -""" -USE CASE 3: Permission check -Given that an incoming AccessRequest is dispatched and a common selector -exist in both OwnedPermission and AccessRequest, it's up to this logic -to return whether those permissions results in an Allow statement. - - Operation order: - Split between READ and WRITE operations. - READ: READ - WRITE: CREATE | UPDATE | DELETE - - Rules: - 1. WRITE permission overrides READ permission. - e.g. If a Subject has any of WRITE permission, him/her implicitly - have READ permission. - 2. No WRITE permissions override one-another. - e.g. If a Subject has DELETE permission and it doesn't have - UPDATE or CREATE permissions, it is only allowed to delete. -""" - - -def test_tries_to_create_unauthorized(): - # user have no creation permissions - input = [(Permission.READ, ), (Permission.READ, Permission.UPDATE), - (Permission.READ, Permission.UPDATE, Permission.DELETE), - (Permission.UPDATE, Permission.DELETE)] - for perm in input: - assert is_allowed(perm, Permission.CREATE) is False - - -def test_tries_to_update_anauthorized(): - # user have no update permission - input = [(Permission.READ, ), (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.DELETE)] - for perm in input: - assert is_allowed(perm, Permission.UPDATE) is False - - -def test_tries_to_delete_anauthorized(): - # user have no delete permission - input = [(Permission.READ, ), (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.UPDATE), - (Permission.CREATE, Permission.UPDATE)] - for perm in input: - assert is_allowed(perm, Permission.DELETE) is False - - -def test_tries_to_read_without_explicit_read_authorized(): - # user have only write permissions and tries to read - input = [ - (Permission.CREATE, ), - (Permission.UPDATE, ), - (Permission.DELETE, ), - (Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.UPDATE), - (Permission.DELETE, Permission.UPDATE), - (Permission.CREATE, Permission.DELETE, Permission.UPDATE), - ] - for perm in input: - assert is_allowed(perm, Permission.READ) - - -def test_tries_to_write_but_has_only_read(): - # user have only read permissions and tries to do all writes - input = [ - Permission.CREATE, - Permission.UPDATE, - Permission.DELETE, - ] - for perm in input: - assert is_allowed((Permission.READ, ), perm) is False - - -def is_allowed(owned_permissions: tuple, - requested_operation: Union[int, Permission]) -> bool: - if isinstance(requested_operation, int): - requested_operation = Permission(requested_operation) - - # corner case is when requested permission is READ: - if requested_operation == Permission.READ and len(owned_permissions) > 0: - # if we have ANY permission, it means that the user is able to read - return True - - permissions = 0 - for owned_permission in owned_permissions: - permissions |= owned_permission - - return (permissions & requested_operation) > 0 From d3f0b973b051bfad2884d44bb75b2ba97f96dcd7 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Mon, 12 Apr 2021 21:57:39 -0300 Subject: [PATCH 07/32] Chore: Splits tests from implementation --- kingdom/access/authorization/dsl.py | 297 +--------------------------- kingdom/access/tests/test_dsl.py | 216 ++++++++++++++++++++ 2 files changed, 223 insertions(+), 290 deletions(-) create mode 100644 kingdom/access/tests/test_dsl.py diff --git a/kingdom/access/authorization/dsl.py b/kingdom/access/authorization/dsl.py index 580b011..9e87af4 100644 --- a/kingdom/access/authorization/dsl.py +++ b/kingdom/access/authorization/dsl.py @@ -21,70 +21,13 @@ There are some conditions to selectors: 1. All selector must **always** be alone. + +OBS.: This is in WIP and should be thoroughly simplified & documented. """ import string from typing import List -valid_conditionals = [ - "resource.id == 128f12334hjg || resource.id == 12839712893791823", - "resource.id==128f12334hjg||resource.id==12839712893791823", - "resource.id==*", - "resource.id ==*", - "resource.id== *", -] - -simplified_conditionals = { - # This meanings that list[0] should be translated to list[1] - "selector conditional": [ - "resource.id==*||resource.id==8123798fcd89||resource.id==8197498127", - "resource.id == *", - ] -} - -invalid_conditionals = [ - "resource.id == 9284234 ||", - "resource.id = 1238912fd || resource.id ==182789123", - "resource.id 12381723", - "resource.id == 1928309182 resource.id = 12930918", - "resource.id == 81927398123 || resource.id = 9128398", - "resource .id == 12893789123", - "resource. id == 89071239", - "resource.id == 182937 12893 827381723 || resource.id == 1892 9283" - "resource.id == 18297389f|resource.id==1827398f", - "resouce.id == 1283971283ff || subject.id == 1891273987123", - "resource.created_at == 2319833012707 || resource.id == faf76bc7", - "resource.id == 8129370192ff || resource.id == 8123091283908", - "resource.id!=291f767d8bc", -] - - -def test_conditionals_split(): - input = [ - "expr || newexpr", - "expr||newexpr", - " expr || newexpr ", - "expr|newexpr", - "expr || newexpr||neewexpr||", - "ex pr || new expr || ", - " expr ||| newexpr ", - " expr|||newexpr ", - ] - - want = [ - ("expr", "newexpr"), - ("expr", "newexpr"), - ("expr", "newexpr"), - False, - False, - False, - False, - False, - ] - - got = [conditionals_split(sequence) for sequence in input] - assert got == want - def conditionals_split(sequence: str): expressions = sequence.split("||") @@ -106,29 +49,6 @@ def conditionals_split(sequence: str): return tuple(parsed_expr) -def test_parse_identifier(): - input = [ - ".id", - "resource.id", - " resourceeee.id", - "r es ource.id", - "resource .id", - " r esource.id", - "resource..id", - ] - want = [ - (False,), - ("resource", ".id"), - ("resourceeee", ".id"), - (False,), - (False,), - (False,), - ("resource", "..id"), - ] - got = [parse_identifier(i) for i in input] - assert got == want - - def parse_identifier(expression): # since it's the beginning, we can strip this, to avoid overly # complicated iterations :-) @@ -143,43 +63,6 @@ def parse_identifier(expression): return (False,) -def test_parse_reference(): - input = [ - ".", - ".id==", - " .id>", - " id==", - ".id === ", - "..id..", - ".nope =", - ".that!=", - "[index]>=", - ".fine0 ==", - ".name@ <=", - ".n a m e ===", - ".name >>", - ] - - want = [ - (False,), - ("id", "=="), - (False,), - (False,), - ("id", "=== "), - (False,), - ("nope", "="), - ("that", "!="), - (False,), - (False,), - (False,), - (False,), - ("name", ">>"), - ] - - got = [parse_reference(ref) for ref in input] - assert got == want - - VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} VALID_OPS_TOKEN = {token for operator in VALID_OPS for token in operator} @@ -222,35 +105,6 @@ def parse_reference(reference_expr): return (False,) -def test_parse_identifier_reference(): - input = [ - "resource.id==", - "resourceeee.id>=", - "r esource.id ==", - "resource .id <=", - " r esource.id =", - "resource.id <<", - "resource.name <=", - "resource.na me !=", - "resource.name ===", - "rsrc!name ==", - ] - want = [ - ("RESOURCE", "ID"), - ("RESOURCEEEE", "ID"), - False, - False, - False, - ("RESOURCE", "ID"), - ("RESOURCE", "NAME"), - False, - ("RESOURCE", "NAME"), - False, - ] - got = [parse_identifier_reference(expr) for expr in input] - assert got == want - - def parse_identifier_reference(expression): identifier, *reference_expr = parse_identifier(expression) if identifier is False: @@ -262,66 +116,21 @@ def parse_identifier_reference(expression): return (identifier.upper(), reference.upper()) -def test_parse_operator(): - input = [ - "== 'd8f7s9d8f7'", # valid - "== '8dfs8d7f9'", # valid - "== ''", # valid - "== \"'21f90912'\" ", # invalid - "== '21f90912' ", # valid - "= '*' ", # invalid - '< "ddx"', # invalid - " < '3030fk30'", # valid - " > ''", # valid - " >> ''", # invalid - "=== '*'", # invalid - ">= '2fd04'", # valid - "!= '*'", # valid - " + '*'", # invalid - "-- 'd'", # invalid - "/ 'xxvc'", # invalid - "=+ '*'", # invalid - "=- '*'", # invalid - ] - want = [ - ("==", "'d8f7s9d8f7'"), # valid - ("==", "'8dfs8d7f9'"), # valid - ("==", "''"), # valid - (False,), # invalid - ("==", "'21f90912'"), # valid - (False,), # invalid - (False,), # invalid - ("<", "'3030fk30'"), # valid - (">", "''"), # valid - (False,), # valid - (False,), # invalid - (">=", "'2fd04'"), # valid - ("!=", "'*'"), # valid - (False,), # invalid - (False,), # invalid - (False,), # invalid - (False,), # invalid - (False,), # invalid - ] - got = [parse_operator(op) for op in input] - assert got == want - - def parse_operator(operator_expr): operator_expr = operator_expr.strip() # just to make sure - VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} - VALID_TOKEN = {token for operator in VALID_OPS for token in operator} operator = "" parsing_idx = -1 + # First we parse a known operator. for idx, token in enumerate(operator_expr): - if token in VALID_TOKEN: + if token in VALID_OPS_TOKEN: # the only valid thing that is being parsed operator += token else: parsing_idx = idx break + # Then we check if the rest is OK. rest = operator_expr[parsing_idx:] for idx, token in enumerate(rest): if token == "'": @@ -338,52 +147,6 @@ def parse_operator(operator_expr): return (False,) -def test_parse_valid_selector(): - input = [ - " '128f12334hjg'", - " '128f12334hjg'", - "'12839712893791823'", - " '12839712893791823'", - " '128f12334hjg'", - "'*'", - " '*'", - " '**'", - " '!'", - "'12837891ff", - "'123123'123123'", - "''", - "'fff\"dddsd'", - "'''", - "';1234234fgds00x;;", - "'f5f65b65c!!'", - "f4'dfadf7'", - ] - - want = [ - ("128f12334hjg",), - ("128f12334hjg",), - ("12839712893791823",), - ("12839712893791823",), - ("128f12334hjg",), - ("*",), - ("*",), - (False,), - (False,), - (False,), - (False,), - (False,), - (False,), - (False,), - (False,), - (False,), - (False,), - ] - - got = [parse_selector(cond) for cond in input] - - assert got == want - - def parse_selector(selector_expr): ALL_TOKEN = "*" selector_expr = selector_expr.strip() @@ -393,6 +156,7 @@ def parse_selector(selector_expr): def isselector(token): return token.isnumeric() or token.isidentifier() or token == ALL_TOKEN + # First we try to find a selector. for idx, token in enumerate(selector_expr): if idx == 0: # first token @@ -409,7 +173,7 @@ def isselector(token): else: return (False,) - # are we dealig with an *? + # Edge case: are we dealig with an *? if ALL_TOKEN in selector and selector != "*": # plain comparison return (False,) @@ -421,53 +185,6 @@ def isselector(token): return (selector,) -def test_parse_expressions(): - valid_input = [ - "resource.id=='d8s7f987sdf'", - "resource.id == 'd8s7f987sdf'", - "resource.id == '*'", - "product.id=='*'", - "subject.salary > '1800'", - "subject.salary <= '1800'", - "some.name == 'ab9f8d0'", - ] - got = [parse_expression(expr) for expr in valid_input] - want = [ - ("resource", "id", "==", "d8s7f987sdf"), - ("resource", "id", "==", "d8s7f987sdf"), - ("resource", "id", "==", "*"), - ("product", "id", "==", "*"), - ("subject", "salary", ">", "1800"), - ("subject", "salary", "<=", "1800"), - ("some", "name", "==", "ab9f8d0"), - ] - assert got == want - - invalid_input = [ - "resource..id == '8d9f7a8f'", - ".id == 'xxx'", - "'resource'.id == '*'", - "reso urce.id == '*'", - "resource.id === '*'", - "resource.id='dgv8bf'", - 'resource.id == "*"', - "subject.salary > 1800", - ] - - got = [parse_expression(expr) for expr in invalid_input] - want = [ - False, - False, - False, - False, - False, - False, - False, - False, - ] - assert got == want - - def parse_expression(expr): identifier, *reference_expr = parse_identifier(expr) if identifier is False: diff --git a/kingdom/access/tests/test_dsl.py b/kingdom/access/tests/test_dsl.py new file mode 100644 index 0000000..085ec40 --- /dev/null +++ b/kingdom/access/tests/test_dsl.py @@ -0,0 +1,216 @@ +from kingdom.access.authorization.dsl import ( + conditionals_split, + parse_expression, + parse_identifier, + parse_identifier_reference, + parse_operator, + parse_reference, + parse_selector, +) + + +def test_conditionals_split(): + input = [ + "expr || newexpr", + "expr||newexpr", + " expr || newexpr ", + "expr|newexpr", + "expr || newexpr||neewexpr||", + "ex pr || new expr || ", + " expr ||| newexpr ", + " expr|||newexpr ", + ] + + want = [ + ("expr", "newexpr"), + ("expr", "newexpr"), + ("expr", "newexpr"), + False, + False, + False, + False, + False, + ] + + got = [conditionals_split(sequence) for sequence in input] + assert got == want + + +def test_parse_identifier(): + input = [ + ".id", + "resource.id", + " resourceeee.id", + "r es ource.id", + "resource .id", + " r esource.id", + "resource..id", + ] + want = [ + (False,), + ("resource", ".id"), + ("resourceeee", ".id"), + (False,), + (False,), + (False,), + ("resource", "..id"), + ] + got = [parse_identifier(i) for i in input] + assert got == want + + +def test_parse_reference(): + input = [ + ".", + ".id==", + " .id>", + " id==", + ".id === ", + "..id..", + ".nope =", + ".that!=", + "[index]>=", + ".fine0 ==", + ".name@ <=", + ".n a m e ===", + ".name >>", + ] + + want = [ + (False,), + ("id", "=="), + (False,), + (False,), + ("id", "=== "), + (False,), + ("nope", "="), + ("that", "!="), + (False,), + (False,), + (False,), + (False,), + ("name", ">>"), + ] + + got = [parse_reference(ref) for ref in input] + assert got == want + + +def test_parse_identifier_reference(): + input = [ + "resource.id==", + "resourceeee.id>=", + "r esource.id ==", + "resource .id <=", + " r esource.id =", + "resource.id <<", + "resource.name <=", + "resource.na me !=", + "resource.name ===", + "rsrc!name ==", + ] + want = [ + ("RESOURCE", "ID"), + ("RESOURCEEEE", "ID"), + False, + False, + False, + ("RESOURCE", "ID"), + ("RESOURCE", "NAME"), + False, + ("RESOURCE", "NAME"), + False, + ] + got = [parse_identifier_reference(expr) for expr in input] + assert got == want + + +def test_parse_operator(): + input = [ + "== 'd8f7s9d8f7'", # valid + "== '8dfs8d7f9'", # valid + "== ''", # valid + "== \"'21f90912'\" ", # invalid + "== '21f90912' ", # valid + "= '*' ", # invalid + '< "ddx"', # invalid + " < '3030fk30'", # valid + " > ''", # valid + " >> ''", # invalid + "=== '*'", # invalid + ">= '2fd04'", # valid + "!= '*'", # valid + " + '*'", # invalid + "-- 'd'", # invalid + "/ 'xxvc'", # invalid + "=+ '*'", # invalid + "=- '*'", # invalid + ] + want = [ + ("==", "'d8f7s9d8f7'"), # valid + ("==", "'8dfs8d7f9'"), # valid + ("==", "''"), # valid + (False,), # invalid + ("==", "'21f90912'"), # valid + (False,), # invalid + (False,), # invalid + ("<", "'3030fk30'"), # valid + (">", "''"), # valid + (False,), # valid + (False,), # invalid + (">=", "'2fd04'"), # valid + ("!=", "'*'"), # valid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid + (False,), # invalid + ] + got = [parse_operator(op) for op in input] + assert got == want + + +def test_parse_valid_selector(): + input = [ + " '128f12334hjg'", + " '128f12334hjg'", + "'12839712893791823'", + " '12839712893791823'", + " '128f12334hjg'", + "'*'", + " '*'", + " '**'", + " '!'", + "'12837891ff", + "'123123'123123'", + "''", + "'fff\"dddsd'", + "'''", + "';1234234fgds00x;;", + "'f5f65b65c!!'", + "f4'dfadf7'", + ] + + want = [ + ("128f12334hjg",), + ("128f12334hjg",), + ("12839712893791823",), + ("12839712893791823",), + ("128f12334hjg",), + ("*",), + ("*",), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + (False,), + ] + + got = [parse_selector(cond) for cond in input] + + assert got == want From b13107160530a51177708bfeb800d2847812c211 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 13 Apr 2021 19:41:56 -0300 Subject: [PATCH 08/32] Chore: Move tests around --- .../access/tests/authorization/__init__.py | 0 .../tests/{ => authorization}/test_dsl.py | 0 .../tests/{ => authorization}/test_encode.py | 72 +++-- .../access/tests/authorization/test_flow.py | 21 ++ .../tests/authorization/test_interface.py | 75 ++++++ .../access/tests/authorization/test_verify.py | 250 ++++++++++++++++++ kingdom/access/tests/test_verify.py | 205 -------------- 7 files changed, 400 insertions(+), 223 deletions(-) create mode 100644 kingdom/access/tests/authorization/__init__.py rename kingdom/access/tests/{ => authorization}/test_dsl.py (100%) rename kingdom/access/tests/{ => authorization}/test_encode.py (59%) create mode 100644 kingdom/access/tests/authorization/test_flow.py create mode 100644 kingdom/access/tests/authorization/test_interface.py create mode 100644 kingdom/access/tests/authorization/test_verify.py delete mode 100644 kingdom/access/tests/test_verify.py diff --git a/kingdom/access/tests/authorization/__init__.py b/kingdom/access/tests/authorization/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kingdom/access/tests/test_dsl.py b/kingdom/access/tests/authorization/test_dsl.py similarity index 100% rename from kingdom/access/tests/test_dsl.py rename to kingdom/access/tests/authorization/test_dsl.py diff --git a/kingdom/access/tests/test_encode.py b/kingdom/access/tests/authorization/test_encode.py similarity index 59% rename from kingdom/access/tests/test_encode.py rename to kingdom/access/tests/authorization/test_encode.py index 22660dc..a4a6f68 100644 --- a/kingdom/access/tests/test_encode.py +++ b/kingdom/access/tests/authorization/test_encode.py @@ -1,5 +1,5 @@ from kingdom.access.authorization.encode import encode -from kingdom.access.authorization.model import ( +from kingdom.access.authorization.types import ( Conditional, Permission, Policy, @@ -50,21 +50,63 @@ def test_simple_policy_packing(): ] owned_perm = { - "product": { - "*": (Permission.CREATE,), - "ab4f": (Permission.UPDATE,), - "13fa": (Permission.UPDATE,), - }, - "account": { - "*": (Permission.READ,), - "0bf3": (Permission.UPDATE,), - "bc0e": (Permission.UPDATE,), - }, + "product": {"*": 1, "ab4f": 2, "13fa": 2, }, + "account": {"*": 0, "0bf3": 2, "bc0e": 2, }, } assert encode(role_policies) == owned_perm +def test_cumulative_policy_packing(): + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "*"), ], + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE,), + conditionals=[Conditional("resource.id", "*"), ], + ) + + account_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "044e"), + Conditional("resource.id", "0e0e"), + Conditional("resource.id", "bc0e"), + ], + ) + + ya_account_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.DELETE,), + conditionals=[Conditional("resource.id", "044e"), ], + ) + + yaa_account_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.DELETE,), + conditionals=[ + Conditional("resource.id", "bc0e"), + Conditional("resource.id", "aac0"), + ], + ) + role_policies = [ + product_policy, + ya_product_policy, + account_policy, + ya_account_policy, + yaa_account_policy, + ] + owned_perm = { + "product": {"*": 1, "044e": 6, "0e0e": 2, "bc0e": 6, "aac0": 4, } + } + assert encode(role_policies) == owned_perm + + def test_redundant_policy_packing(): """Given a list of Policies with overlapping conditional selectors, we expect a well-formatted DTO dict""" @@ -100,13 +142,7 @@ def test_redundant_policy_packing(): ] owned_perm = { - "product": { - "*": (Permission.READ, Permission.CREATE,), - "7fb4": (Permission.UPDATE,), - "49f3": (Permission.UPDATE,), - "abc9": (Permission.UPDATE, Permission.DELETE), - "aaa": (Permission.DELETE,), - }, + "product": {"*": 1, "7fb4": 2, "49f3": 2, "abc9": 6, "aaa": 4, }, } assert encode(role_policies) == owned_perm diff --git a/kingdom/access/tests/authorization/test_flow.py b/kingdom/access/tests/authorization/test_flow.py new file mode 100644 index 0000000..14fb619 --- /dev/null +++ b/kingdom/access/tests/authorization/test_flow.py @@ -0,0 +1,21 @@ +from kingdom.access.authentication.types import encode_jwt +from kingdom.access.authorization.flow import AuthFlow +from kingdom.access.tests.authorization.test_interface import default_user + + +""" +Problem: + Given an incoming JWT token, authentication flow is responsible for: + + 1. Verifying (and decoding) whther a JWT is valid. + 2. Return whether a user can perform a command + > Cancel or not flow. + 3. Return which instances of a resource a subject can see + > A list of resources-scope +""" + + +def create_valid_jwt() -> bytes, User: + user = default_user() + jwt, err = encode_jwt(user) + return jwt diff --git a/kingdom/access/tests/authorization/test_interface.py b/kingdom/access/tests/authorization/test_interface.py new file mode 100644 index 0000000..b478e3d --- /dev/null +++ b/kingdom/access/tests/authorization/test_interface.py @@ -0,0 +1,75 @@ +from kingdom.access.authentication.types import ( + JWT_ALGORITHM, + RANDOM_KEY, + Conditional, + JWTDecodedPayload, + MaybeBytes, + MaybePayload, + Permission, + Policy, + Resource, + Role, + User, + decode_jwt, + encode_jwt, + encode_user_payload, + encode_user_policies, +) + + +""" +Problems: + +1. We have a user and we want to issue a JWT on his behalf. +2. We have a JWT and want to parse a JWT on his behalf +""" + +"""Problem 1: +1. We need to have the mininum set of User-Role-Policy domain defined. +""" + + +def default_user() -> User: + product_read = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "*"), ], + ) + specific_coupon = Policy( + resource=Resource("Coupon"), + permissions=(Permission.READ,), + conditionals=[ + Conditional("resource.id", "7fb4"), + Conditional("resource.id", "49f3"), + ], + ) + role = Role( + name="default consumer", policies=[product_read, specific_coupon] + ) + user = User(access_key="abc4f", roles=[role]) + return user + + +def test_successful_encoding_and_decoding(): + user = default_user() + user_policies = encode_user_policies(user) + + jwt, err_encode = encode_jwt(user) + assert err_encode is None + + decoded_jwt, err_decode = decode_jwt(jwt) + assert err_decode is None + + assert user_policies == decoded_jwt["roles"] + + +def test_unsuccessful_decoding(): + from string import ascii_letters + from random import randint + + def token_part(): + return "".join(ascii_letters[randint(0, 50)] for _ in range(1, 64)) + + token = ".".join(token_part() for _ in range(3)) + _, err_decode = decode_jwt(token.encode()) + assert isinstance(err_decode, Exception) diff --git a/kingdom/access/tests/authorization/test_verify.py b/kingdom/access/tests/authorization/test_verify.py new file mode 100644 index 0000000..6eddedd --- /dev/null +++ b/kingdom/access/tests/authorization/test_verify.py @@ -0,0 +1,250 @@ +from typing import List, Tuple + +from kingdom.access.authorization.types import ( + AccessRequest, + Permission, + Policy, + PolicyContext, + Resource, +) +from kingdom.access.authorization.verify import ( + check_read_permission, + check_write_permission, + is_allowed, +) + + +class TestReadPermission: + "Test permissions on a read scenario" + fn = check_read_permission + + def test_read_permission_filtering_unauthorized(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Product"), + selector="*", + ) + owned_perm: PolicyContext = {} + assert check_read_permission(owned_perm, access_request) == [] + + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Product"), + selector="*", + ) + owned_perm = { + "account": {"*": (Permission.CREATE,), }, + } + assert check_read_permission(owned_perm, access_request) == [] + + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Account"), + selector="*", + ) + owned_perm = { + "product": {"*": (Permission.READ,), }, + } + assert check_read_permission(owned_perm, access_request) == [] + + def test_read_permission_filtering_authorized_but_specific(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Coupon"), + selector="*", + ) + owned_perm: PolicyContext = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE,), + "b4a3": (Permission.DELETE,), + }, + } + assert check_read_permission(owned_perm, access_request) == [ + "ab4c", + "bc3f", + "cc4a", + "b4a3", + ] + + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Coupon"), + selector="*", + ) + owned_perm = { + "coupon": {"*": (Permission.CREATE,), }, + } + assert check_read_permission(owned_perm, access_request) == ["*"] + + +class TestVerifyWritePermissions: + "Test permissions on a write scenario" + fn = check_write_permission + + def test_write_permission_unauthorized(self): + # Subject hasn't permission to do anything + access_request = AccessRequest( + operation=Permission.CREATE, + resource=Resource("Rules"), + selector="*", + ) + owned_perm: PolicyContext = {} + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in an unknown resource to the subject + access_request = AccessRequest( + operation=Permission.CREATE, + resource=Resource("User"), + selector="*", + ) + owned_perm = { + "product": {"*": (Permission.READ,), }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without enough privileges. + access_request = AccessRequest( + operation=Permission.CREATE, + resource=Resource("Coupon"), + selector="*", + ) + owned_perm = { + "coupon": { + "ab4c": (Permission.READ,), + "bc3f": (Permission.READ, Permission.UPDATE), + "cc4a": (Permission.UPDATE,), + "b4a3": (Permission.DELETE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest( + operation=Permission.UPDATE, + resource=Resource("User"), + selector="ffc0", + ) + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE), + "abc3": (Permission.UPDATE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + # Tries to write in a known resource but without specific privileges + access_request = AccessRequest( + operation=Permission.DELETE, + resource=Resource("User"), + selector="c4fd", + ) + owned_perm = { + "user": { + "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), + "d3fc": (Permission.DELETE,), + }, + } + assert check_write_permission(owned_perm, access_request) is False + + def test_write_permission_authorized(self): + # Tries to create a new item. + access_request = AccessRequest( + operation=Permission.CREATE, + resource=Resource("User"), + selector="*", + ) + owned_perm: PolicyContext = { + "user": {"*": (Permission.UPDATE, Permission.CREATE,), }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to update a specific value on a wildcard policy. + access_request = AccessRequest( + operation=Permission.UPDATE, + resource=Resource("Coupon"), + selector="43df", + ) + owned_perm = { + "coupon": { + "*": (Permission.READ, Permission.UPDATE, Permission.DELETE), + }, + } + assert check_write_permission(owned_perm, access_request) is True + + # Tries to delete a specific value on a specific policy. + access_request = AccessRequest( + operation=Permission.DELETE, + resource=Resource("User"), + selector="3dc4", + ) + owned_perm = { + "user": {"*": (Permission.CREATE,), "3dc4": (Permission.DELETE,)}, + } + assert check_write_permission(owned_perm, access_request) is True + + +# Test is_allowed() + + +class TestIsPermissionAllowed: + "Test if authorization calculations are working as expected" + fn = is_allowed + + def test_tries_to_create_unauthorized(self): + # user have no creation permissions + input: List[Tuple[Permission, ...]] = [ + (Permission.READ,), + (Permission.READ, Permission.UPDATE), + (Permission.READ, Permission.UPDATE, Permission.DELETE), + (Permission.UPDATE, Permission.DELETE), + ] + for perm in input: + assert is_allowed(perm, Permission.CREATE) is False + + def test_tries_to_update_anauthorized(self): + # user have no update permission + input: List[Tuple[Permission, ...]] = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.DELETE), + ] + for perm in input: + assert is_allowed(perm, Permission.UPDATE) is False + + def test_tries_to_delete_anauthorized(self): + # user have no delete permission + input: List[Tuple[Permission, ...]] = [ + (Permission.READ,), + (Permission.READ, Permission.CREATE), + (Permission.READ, Permission.CREATE, Permission.UPDATE), + (Permission.CREATE, Permission.UPDATE), + ] + for perm in input: + assert is_allowed(perm, Permission.DELETE) is False + + def test_tries_to_read_without_explicit_read_authorized(self): + # user have only write permissions and tries to read + input: List[Tuple[Permission, ...]] = [ + (Permission.CREATE,), + (Permission.UPDATE,), + (Permission.DELETE,), + (Permission.CREATE, Permission.DELETE), + (Permission.CREATE, Permission.UPDATE), + (Permission.DELETE, Permission.UPDATE), + (Permission.CREATE, Permission.DELETE, Permission.UPDATE), + ] + for perm in input: + assert is_allowed(perm, Permission.READ) + + def test_tries_to_write_but_has_only_read(self): + # user have only read permissions and tries to do all writes + input: List[Permission] = [ + Permission.CREATE, + Permission.UPDATE, + Permission.DELETE, + ] + for perm in input: + assert is_allowed((Permission.READ,), perm) is False diff --git a/kingdom/access/tests/test_verify.py b/kingdom/access/tests/test_verify.py deleted file mode 100644 index 732f398..0000000 --- a/kingdom/access/tests/test_verify.py +++ /dev/null @@ -1,205 +0,0 @@ -from kingdom.access.authorization.model import ( - AccessRequest, - Permission, - Policy, - Resource, -) -from kingdom.access.authorization.verify import ( - check_read_permission, - check_write_permission, - is_allowed, -) - -# Test read scenario, check_read_permission - - -def test_read_permission_filtering_unauthorized(): - access_request = AccessRequest( - operation=Permission.READ, resource=Resource("Product"), selector="*" - ) - owned_perm = { - "account": {"*": (Permission.CREATE,), }, - } - assert check_read_permission(owned_perm, access_request) == [] - - access_request = AccessRequest( - operation=Permission.READ, resource=Resource("Account"), selector="*" - ) - owned_perm = { - "product": {"*": (Permission.READ,), }, - } - assert check_read_permission(owned_perm, access_request) == [] - - -def test_read_permission_filtering_authorized_but_specific(): - access_request = AccessRequest( - operation=Permission.READ, resource=Resource("Coupon"), selector="*" - ) - owned_perm = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE,), - "b4a3": (Permission.DELETE,), - }, - } - assert check_read_permission(owned_perm, access_request) == [ - "ab4c", - "bc3f", - "cc4a", - "b4a3", - ] - - access_request = AccessRequest( - operation=Permission.READ, resource=Resource("Coupon"), selector="*" - ) - owned_perm = { - "coupon": {"*": (Permission.CREATE,), }, - } - assert check_read_permission(owned_perm, access_request) == ["*"] - - -# Test write scenario, check_write_permission - - -def test_write_permission_unauthorized(): - # Tries to write in an unknown resource to the subject - access_request = AccessRequest( - operation=Permission.CREATE, resource=Resource("User"), selector="*" - ) - owned_perm = { - "product": {"*": (Permission.READ,), }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without enough privileges. - access_request = AccessRequest( - operation=Permission.CREATE, resource=Resource("Coupon"), selector="*" - ) - owned_perm = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE,), - "b4a3": (Permission.DELETE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest( - operation=Permission.UPDATE, resource=Resource("User"), selector="ffc0" - ) - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE), - "abc3": (Permission.UPDATE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest( - operation=Permission.DELETE, resource=Resource("User"), selector="c4fd" - ) - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), - "d3fc": (Permission.DELETE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - -def test_write_permission_authorized(): - # Tries to create a new item. - access_request = AccessRequest( - operation=Permission.CREATE, resource=Resource("User"), selector="*" - ) - owned_perm = { - "user": {"*": (Permission.UPDATE, Permission.CREATE,), }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to update a specific value on a wildcard policy. - access_request = AccessRequest( - operation=Permission.UPDATE, resource=Resource("Coupon"), selector="43df" - ) - owned_perm = { - "coupon": {"*": (Permission.READ, Permission.UPDATE, Permission.DELETE), }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to delete a specific value on a specific policy. - access_request = AccessRequest( - operation=Permission.DELETE, resource=Resource("User"), selector="3dc4" - ) - owned_perm = { - "user": {"*": (Permission.CREATE,), "3dc4": (Permission.DELETE,), }, - } - assert check_write_permission(owned_perm, access_request) is True - - -# Test is_allowed() - - -def test_tries_to_create_unauthorized(): - # user have no creation permissions - input = [ - (Permission.READ,), - (Permission.READ, Permission.UPDATE), - (Permission.READ, Permission.UPDATE, Permission.DELETE), - (Permission.UPDATE, Permission.DELETE), - ] - for perm in input: - assert is_allowed(perm, Permission.CREATE) is False - - -def test_tries_to_update_anauthorized(): - # user have no update permission - input = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.DELETE), - ] - for perm in input: - assert is_allowed(perm, Permission.UPDATE) is False - - -def test_tries_to_delete_anauthorized(): - # user have no delete permission - input = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.UPDATE), - (Permission.CREATE, Permission.UPDATE), - ] - for perm in input: - assert is_allowed(perm, Permission.DELETE) is False - - -def test_tries_to_read_without_explicit_read_authorized(): - # user have only write permissions and tries to read - input = [ - (Permission.CREATE,), - (Permission.UPDATE,), - (Permission.DELETE,), - (Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.UPDATE), - (Permission.DELETE, Permission.UPDATE), - (Permission.CREATE, Permission.DELETE, Permission.UPDATE), - ] - for perm in input: - assert is_allowed(perm, Permission.READ) - - -def test_tries_to_write_but_has_only_read(): - # user have only read permissions and tries to do all writes - input = [ - Permission.CREATE, - Permission.UPDATE, - Permission.DELETE, - ] - for perm in input: - assert is_allowed((Permission.READ,), perm) is False From 910888041f1a16deaedbfd170b24bc92ce5c45b8 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 13 Apr 2021 19:42:34 -0300 Subject: [PATCH 09/32] Refactor: Encoding a PolicyContext now uses Permission.value for easier serialization --- kingdom/access/authorization/encode.py | 95 +++++++++++++++++++------- 1 file changed, 72 insertions(+), 23 deletions(-) diff --git a/kingdom/access/authorization/encode.py b/kingdom/access/authorization/encode.py index 59d2865..443398d 100644 --- a/kingdom/access/authorization/encode.py +++ b/kingdom/access/authorization/encode.py @@ -6,27 +6,67 @@ JWT payload. """ -from typing import Dict, List, Tuple +from functools import reduce +from typing import Dict, List, Optional, Tuple, Union -from kingdom.access.authorization.model import ( +from kingdom.access.authorization.types import ( TOKEN_ALL, Conditional, Permission, Policy, PolicyContext, + PolicyContextRaw, Resource, Selector, ) +PermissionTuple = Tuple[Permission, ...] -def unique_permissions( - current_permissions: Tuple[Permission, ...], - incoming_permissions: Tuple[Permission, ...], -) -> Tuple[Permission, ...]: - current = set(current_permissions) - incoming = set(incoming_permissions) - # Union - return tuple(current | incoming) + +def itop(permissions: Union[int, PermissionTuple]) -> PermissionTuple: + """ + Considering that a permission can be both an integer and an instance of + Permission, this function ensures that operations are done with a + Permission type. + It does by translating integer to Permission whenever needed. + + >>> atoi_permission(0) + (Permission.READ,) + >>> atoi_permission((Permission.CREATE, Permission.READ)) + (Permission.CREATE, Permission.READ) + """ + if isinstance(permissions, int): + return (Permission(permissions),) + return permissions + + +def union_permissions( + permissions: Union[PermissionTuple, int], + incoming_permissions: Union[PermissionTuple, int], +) -> int: + """ + Given a set of permissions and a set of incoming permissions that should + be unionized, this method adds them up. + + >>> union_permissions((Permission.READ,), 2) + 3 + >>> union_permissions( + (Permission.DELETE,), (Permission.CREATE, Permission.UPDATE)) + 7 + >>> union_permissions((Permission.CREATE,), (Permission.CREATE,)) + 1 + """ + current: PermissionTuple = itop(permissions) + incoming: PermissionTuple = itop(incoming_permissions) + + updated = set(current) | set(incoming) + perms = reduce(lambda a, b: a | b, updated) + + if isinstance(perms, Permission): + # If updated has only one element, reduce will output the element + # itself. Hence we need to get its value + return perms.value + return perms def encode(policies: List[Policy]) -> PolicyContext: @@ -56,10 +96,21 @@ def encode(policies: List[Policy]) -> PolicyContext: } } + >>> pack_policies([a_policy, ya_policy]) + { + "product": { + "5f34": 1 + }, + "account": { + "*": 4 + } + } + TODO: This is currently a side-effectful implementation, we could def. implement a pure one. """ - owned: PolicyContext = {} + # owned: PolicyContext = {} + owned: PolicyContextRaw = {} # Iterative approach: on every iteration we keep on # building output dictionary @@ -75,14 +126,13 @@ def encode(policies: List[Policy]) -> PolicyContext: # There are two kinds of selectors: specific and generics. # And a selector already exist for a given resource or it doesn't. if selector in owned[resource]: - # When a selector already exists, we make sure we keep - # permissions unique - owned[resource][selector] = unique_permissions( - owned[resource][selector], permissions - ) + existing_permissions = owned[resource][selector] else: - # Otherwise, it's a new selector, hence a new entry - owned[resource][selector] = tuple(permissions) + existing_permissions = tuple() + + owned[resource][selector] = union_permissions( + permissions, existing_permissions + ) # Now remove any redundant permission due to a possible "*" selector # Brute-force. @@ -91,20 +141,19 @@ def encode(policies: List[Policy]) -> PolicyContext: # Well, nothing to do then. continue - all_token_perms = set(selector_perm[TOKEN_ALL]) + # all_token_perms = set(selector_perm[TOKEN_ALL]) for selector, permissions in selector_perm.items(): # Subtract "*"'s permissions from the other permissions if selector == TOKEN_ALL: # Not this one. continue - current_perms = set(permissions) - updated_perms = current_perms - all_token_perms - if not updated_perms: + updated_perms = permissions & ~selector_perm[TOKEN_ALL] + if updated_perms == 0: # All of this selector's permissions are already # contemplated by "*" del owned[resource][selector] else: - owned[resource][selector] = tuple(updated_perms) + owned[resource][selector] = updated_perms return owned From 8ab4d30ea470a3e664eb9cd92b240c081003d2ee Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 13 Apr 2021 19:43:08 -0300 Subject: [PATCH 10/32] Chore: Renames models to types Probably will rename again --- kingdom/access/authorization/{model.py => types.py} | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) rename kingdom/access/authorization/{model.py => types.py} (88%) diff --git a/kingdom/access/authorization/model.py b/kingdom/access/authorization/types.py similarity index 88% rename from kingdom/access/authorization/model.py rename to kingdom/access/authorization/types.py index 2cc596b..1e510b7 100644 --- a/kingdom/access/authorization/model.py +++ b/kingdom/access/authorization/types.py @@ -72,7 +72,9 @@ class AccessRequest: resource: str selector: str - def __init__(self, operation: Permission, resource: Resource, selector: str): + def __init__( + self, operation: Permission, resource: Resource, selector: str + ): self.operation = operation.value self.resource = resource.alias self.selector = selector @@ -82,3 +84,7 @@ def __init__(self, operation: Permission, resource: Resource, selector: str): Selector = str SelectorPermissionMap = Dict[Selector, Tuple[Permission, ...]] PolicyContext = Dict[ResourceAlias, SelectorPermissionMap] + +PermissionRaw = int +PermissionMap = Dict[Selector, PermissionRaw] +PolicyContextRaw = Dict[ResourceAlias, PermissionMap] From 3cb29178568953eddde1b470020a7e7f22b5d941 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 13 Apr 2021 19:44:05 -0300 Subject: [PATCH 11/32] Chore: Updates imports --- kingdom/access/authorization/verify.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/kingdom/access/authorization/verify.py b/kingdom/access/authorization/verify.py index a57fe08..be417d7 100644 --- a/kingdom/access/authorization/verify.py +++ b/kingdom/access/authorization/verify.py @@ -13,7 +13,7 @@ """ from typing import Dict, List, Tuple, Union -from kingdom.access.authorization.model import ( +from kingdom.access.authorization.types import ( TOKEN_ALL, AccessRequest, Permission, @@ -71,7 +71,9 @@ def check_read_permission( def check_write_permission( - owned_policies: PolicyContext, access_request: AccessRequest + owned_policies: PolicyContext, + access_request: AccessRequest, + transform_permission: bool = False, ) -> bool: """ Consider a subject that tries to access a resource. The access attempt is From 1da28e57158479f0d1f7e681cf0fb950c6640705 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 13 Apr 2021 19:44:53 -0300 Subject: [PATCH 12/32] Feat: Initial steps of final module interface --- kingdom/access/authentication/__init__.py | 0 kingdom/access/authentication/types.py | 81 +++++++++++++++++++++++ kingdom/access/authorization/flow.py | 6 ++ 3 files changed, 87 insertions(+) create mode 100644 kingdom/access/authentication/__init__.py create mode 100644 kingdom/access/authentication/types.py create mode 100644 kingdom/access/authorization/flow.py diff --git a/kingdom/access/authentication/__init__.py b/kingdom/access/authentication/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kingdom/access/authentication/types.py b/kingdom/access/authentication/types.py new file mode 100644 index 0000000..ba4c22d --- /dev/null +++ b/kingdom/access/authentication/types.py @@ -0,0 +1,81 @@ +from datetime import datetime, timedelta +from typing import Dict, Generator, List, Optional, Tuple + +import jwt + +from kingdom.access.authorization.encode import encode +from kingdom.access.authorization.types import ( + Conditional, + Permission, + Policy, + PolicyContext, + Resource, + dataclass, +) + +RANDOM_KEY = "abcd00f" +JWT_ALGORITHM = "HS256" + +TOKEN_EXPIRATION_MIN = 30 + + +@dataclass +class Role: + name: str + policies: List[Optional[Policy]] + + def __hash__(self) -> int: + return hash(self.name) + + +@dataclass +class User: + access_key: str + roles: List[Optional[Role]] + + def resolve_policies(self): + # TODO: Improve this typing + for role in self.roles: + for policy in role.policies: + yield policy + + +JWTDecodedPayload = Dict + +MaybeBytes = Tuple[Optional[bytes], Optional[Exception]] +MaybePayload = Tuple[Optional[JWTDecodedPayload], Optional[Exception]] + + +def encode_user_policies(user: User) -> PolicyContext: + user_policies = list(user.resolve_policies()) + return encode(user_policies) + + +def encode_user_payload(user: User) -> JWTDecodedPayload: + expiration = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRATION_MIN) + return dict( + sub=user.access_key, exp=expiration, roles=encode_user_policies(user), + ) + + +def encode_jwt(user: User) -> MaybeBytes: + payload = encode_user_payload(user) + try: + return ( + jwt.encode( + payload=payload, key=RANDOM_KEY, algorithm=JWT_ALGORITHM + ), + None, + ) + except jwt.PyJWTError as exc: + return None, exc + + +def decode_jwt(token: bytes) -> MaybePayload: + try: + return ( + jwt.decode(jwt=token, key=RANDOM_KEY, algorithms=[JWT_ALGORITHM]), + None, + ) + except jwt.PyJWTError as exc: + return None, exc diff --git a/kingdom/access/authorization/flow.py b/kingdom/access/authorization/flow.py new file mode 100644 index 0000000..a56ad21 --- /dev/null +++ b/kingdom/access/authorization/flow.py @@ -0,0 +1,6 @@ +from dataclasses import dataclass + + +@dataclass +class AuthFlow: + JWT: bytes From 2d93ea6b7157cc83a1b082dad20d4b2030c4d979 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 14 Apr 2021 19:19:40 -0300 Subject: [PATCH 13/32] Feat: Implements identity flow --- kingdom/access/authorization/flow.py | 57 ++++++++ .../access/tests/authorization/test_flow.py | 136 +++++++++++++++++- 2 files changed, 187 insertions(+), 6 deletions(-) diff --git a/kingdom/access/authorization/flow.py b/kingdom/access/authorization/flow.py index a56ad21..ea39d1c 100644 --- a/kingdom/access/authorization/flow.py +++ b/kingdom/access/authorization/flow.py @@ -1,6 +1,63 @@ from dataclasses import dataclass +from typing import List + +from kingdom.access.authentication.types import decode_jwt +from kingdom.access.authorization.types import AccessRequest +from kingdom.access.authorization.verify import ( + check_read_permission, + check_write_permission, +) @dataclass class AuthFlow: JWT: bytes + context: AccessRequest + + def authenticate(self) -> None: + payload, err = decode_jwt(self.JWT) + if err: + self._is_authenticated = False + else: + self._payload = payload + self._is_authenticated = True + + @property + def is_authenticated(self) -> bool: + if not hasattr(self, "_is_authenticated"): + self.authenticate() + return self._is_authenticated + + @property + def is_authorized(self) -> bool: + if not hasattr(self, "_is_authorized"): + self.authorize() + return self._is_authorized + + def scope(self) -> List: + # TODO: Scope should be a class + # Scope(operation="READ", resource="product", instances=["ffff"]) + return self._scope + + def __resolve_read_authorization(self) -> None: + self._is_authorized = True + owned_permissions = self._payload["roles"] + self._scope = check_read_permission(owned_permissions, self.context) + + def __resolve_write_authorization(self) -> None: + owned_permissions = self._payload["roles"] + self._is_authorized = check_write_permission( + owned_permissions, self.context + ) + if self._is_authorized: + self._scope = [self.context.selector] + else: + self._scope = [] + + def authorize(self) -> None: + """Can either be read/write""" + if self.context.is_request_read(): + # The restriction comes on scope + self.__resolve_read_authorization() + else: + self.__resolve_write_authorization() diff --git a/kingdom/access/tests/authorization/test_flow.py b/kingdom/access/tests/authorization/test_flow.py index 14fb619..2aaff7f 100644 --- a/kingdom/access/tests/authorization/test_flow.py +++ b/kingdom/access/tests/authorization/test_flow.py @@ -1,7 +1,14 @@ -from kingdom.access.authentication.types import encode_jwt +from kingdom.access.authentication.types import ( + AccessRequest, + Permission, + Resource, + encode_jwt, +) from kingdom.access.authorization.flow import AuthFlow -from kingdom.access.tests.authorization.test_interface import default_user - +from kingdom.access.tests.authorization.test_interface import ( + gen_default_user, + gen_invalid_token, +) """ Problem: @@ -14,8 +21,125 @@ > A list of resources-scope """ +JWT = bytes + -def create_valid_jwt() -> bytes, User: - user = default_user() +def gen_valid_jwt() -> JWT: + user = gen_default_user() jwt, err = encode_jwt(user) - return jwt + return jwt if jwt else b"" + + +class TestHappyPath: + def test_known_user_is_authenticated(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Coupon"), + selector="7fb4", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + def test_known_user_is_authenticated_and_authorized_to_read(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Coupon"), + selector="7fb4", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + flow.authorize() + assert flow.is_authorized + + assert flow.scope() == ["7fb4", "49f3"] + + def test_known_user_is_authenticated_and_authorized_to_write(self): + access_request = AccessRequest( + operation=Permission.UPDATE, + resource=Resource("User"), + selector="ffff", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + flow.authorize() + assert flow.is_authorized + + assert flow.scope() == ["ffff"] + + +class TestUnhappyPath: + def test_unknown_user_is_not_authenticated(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Coupon"), + selector="7fb4", + ) + jwt = gen_invalid_token() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + + assert flow.is_authenticated is False + + def test_subject_has_no_permission_to_read(self): + access_request = AccessRequest( + operation=Permission.READ, + resource=Resource("Wallet"), + selector="*", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + flow.authorize() + assert flow.is_authorized + + # permission to read nothing + assert flow.scope() == [] + + def test_subject_has_no_permission_to_write(self): + access_request = AccessRequest( + operation=Permission.CREATE, + resource=Resource("Wallet"), + selector="*", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + flow.authorize() + assert flow.is_authorized is False + + assert flow.scope() == [] + + def test_subject_has_no_permission_to_write_2(self): + access_request = AccessRequest( + operation=Permission.UPDATE, + resource=Resource("User"), + selector="4ff3", + ) + jwt = gen_valid_jwt() + + flow = AuthFlow(jwt, access_request) + flow.authenticate() + assert flow.is_authenticated + + flow.authorize() + assert flow.is_authorized is False + + assert flow.scope() == [] From 1c1901262833f7a8dedb759f8dea3a8145c41391 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 14 Apr 2021 19:20:32 -0300 Subject: [PATCH 14/32] improve: More knowledge to AccessRequest --- kingdom/access/authorization/types.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kingdom/access/authorization/types.py b/kingdom/access/authorization/types.py index 1e510b7..210f7b2 100644 --- a/kingdom/access/authorization/types.py +++ b/kingdom/access/authorization/types.py @@ -79,6 +79,9 @@ def __init__( self.resource = resource.alias self.selector = selector + def is_request_read(self) -> bool: + return self.operation == 0 + ResourceAlias = str Selector = str From 0ce5419c936dcf81946fdc74075666548af0d890 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 14 Apr 2021 19:20:48 -0300 Subject: [PATCH 15/32] chore: Import here to avoid cyclic dependencies --- kingdom/access/authentication/types.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kingdom/access/authentication/types.py b/kingdom/access/authentication/types.py index ba4c22d..29caf0c 100644 --- a/kingdom/access/authentication/types.py +++ b/kingdom/access/authentication/types.py @@ -5,6 +5,7 @@ from kingdom.access.authorization.encode import encode from kingdom.access.authorization.types import ( + AccessRequest, Conditional, Permission, Policy, From 4228f2c22bed7aae21a9daca539497c4d0c1097a Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 14 Apr 2021 19:22:01 -0300 Subject: [PATCH 16/32] improve: Checking write permission is agnostic to input types --- kingdom/access/authorization/verify.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/kingdom/access/authorization/verify.py b/kingdom/access/authorization/verify.py index be417d7..fa13951 100644 --- a/kingdom/access/authorization/verify.py +++ b/kingdom/access/authorization/verify.py @@ -73,7 +73,6 @@ def check_read_permission( def check_write_permission( owned_policies: PolicyContext, access_request: AccessRequest, - transform_permission: bool = False, ) -> bool: """ Consider a subject that tries to access a resource. The access attempt is @@ -99,6 +98,7 @@ def check_write_permission( More examples on test suite. """ + # TODO: Refactor and improve logic handling, DRY plz # Sanity check. assert access_request.operation & ( Permission.CREATE | Permission.UPDATE | Permission.DELETE @@ -118,7 +118,10 @@ def check_write_permission( return False # Checking is a simple O(1) step. - return Permission.CREATE in owned_policies[resource][TOKEN_ALL] + return is_allowed( + owned_policies[resource][TOKEN_ALL], access_request.operation + ) + # return Permission.CREATE in owned_policies[resource][TOKEN_ALL] # Deal with UPDATE or DELETE # Check for generics. @@ -127,8 +130,6 @@ def check_write_permission( if is_allowed(permissions, access_request.operation): # Wildcard matches permission. return True - # if access_request.operation in owned_resource[TOKEN_ALL]: - # return True # Then specify. if access_request.selector not in owned_policies[resource]: @@ -140,7 +141,7 @@ def check_write_permission( def is_allowed( - owned_permissions: Tuple[Permission, ...], + owned_permissions: Union[Tuple[Permission, ...], int], requested_operation: Union[int, Permission], ) -> bool: """ @@ -161,9 +162,12 @@ def is_allowed( >>> is_allowed(owned_perm, requested_op) False """ + # TODO: Refactor and improve typing if isinstance(requested_operation, int): requested_operation = Permission(requested_operation) + if isinstance(owned_permissions, int): + owned_permissions = (owned_permissions,) # corner case is when requested permission is READ: if requested_operation == Permission.READ and owned_permissions: From f927c7e9cef12dab6cb8d8a61314339c9a33da3e Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Wed, 14 Apr 2021 19:22:20 -0300 Subject: [PATCH 17/32] chore: Modifies some helpers --- .../tests/authorization/test_interface.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/kingdom/access/tests/authorization/test_interface.py b/kingdom/access/tests/authorization/test_interface.py index b478e3d..bb7d1d2 100644 --- a/kingdom/access/tests/authorization/test_interface.py +++ b/kingdom/access/tests/authorization/test_interface.py @@ -16,7 +16,6 @@ encode_user_policies, ) - """ Problems: @@ -28,12 +27,23 @@ 1. We need to have the mininum set of User-Role-Policy domain defined. """ +JWT = bytes + -def default_user() -> User: +def gen_default_user() -> User: product_read = Policy( resource=Resource("Product"), permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "*"), ], + conditionals=[ + Conditional("resource.id", "*"), + ], + ) + self_edit = Policy( + resource=Resource("User"), + permissions=(Permission.UPDATE,), + conditionals=[ + Conditional("resource.id", "ffff"), + ], ) specific_coupon = Policy( resource=Resource("Coupon"), @@ -44,14 +54,26 @@ def default_user() -> User: ], ) role = Role( - name="default consumer", policies=[product_read, specific_coupon] + name="default consumer", + policies=[self_edit, product_read, specific_coupon], ) user = User(access_key="abc4f", roles=[role]) return user +def gen_invalid_token() -> JWT: + from string import ascii_letters + from random import randint + + def token_part(): + return "".join(ascii_letters[randint(0, 50)] for _ in range(1, 64)) + + token_str = ".".join(token_part() for _ in range(3)) + return token_str.encode() + + def test_successful_encoding_and_decoding(): - user = default_user() + user = gen_default_user() user_policies = encode_user_policies(user) jwt, err_encode = encode_jwt(user) @@ -64,12 +86,6 @@ def test_successful_encoding_and_decoding(): def test_unsuccessful_decoding(): - from string import ascii_letters - from random import randint - - def token_part(): - return "".join(ascii_letters[randint(0, 50)] for _ in range(1, 64)) - - token = ".".join(token_part() for _ in range(3)) - _, err_decode = decode_jwt(token.encode()) + invalid_token = gen_invalid_token() + _, err_decode = decode_jwt(invalid_token) assert isinstance(err_decode, Exception) From 1e60aa29790ea04839a3f8cee1f8d799b3be1e93 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:27:33 -0300 Subject: [PATCH 18/32] Feat: Redesign and structuring --- kingdom/access/authentication/__init__.py | 0 .../access/authentication/authenticator.py | 50 --- kingdom/access/authentication/types.py | 82 ---- kingdom/access/authorization/__init__.py | 0 kingdom/access/authorization/encode.py | 159 -------- kingdom/access/authorization/flow.py | 63 ---- kingdom/access/authorization/types.py | 93 ----- kingdom/access/authorization/verify.py | 181 --------- kingdom/access/base.py | 287 ++++++++++++++ kingdom/access/config.py | 4 + kingdom/access/{authorization => }/dsl.py | 0 kingdom/access/flow.py | 193 ++++++++++ kingdom/access/jwt.py | 37 ++ .../access/tests/authorization/__init__.py | 0 .../access/tests/authorization/test_encode.py | 148 -------- .../access/tests/authorization/test_flow.py | 145 -------- .../tests/authorization/test_interface.py | 91 ----- .../access/tests/authorization/test_verify.py | 250 ------------- kingdom/access/tests/test_base.py | 178 +++++++++ .../tests/{authorization => }/test_dsl.py | 2 +- kingdom/access/tests/test_flow.py | 352 ++++++++++++++++++ kingdom/access/types.py | 11 + kingdom/access/utils.py | 21 -- 23 files changed, 1063 insertions(+), 1284 deletions(-) delete mode 100644 kingdom/access/authentication/__init__.py delete mode 100644 kingdom/access/authentication/authenticator.py delete mode 100644 kingdom/access/authentication/types.py delete mode 100644 kingdom/access/authorization/__init__.py delete mode 100644 kingdom/access/authorization/encode.py delete mode 100644 kingdom/access/authorization/flow.py delete mode 100644 kingdom/access/authorization/types.py delete mode 100644 kingdom/access/authorization/verify.py create mode 100644 kingdom/access/base.py create mode 100644 kingdom/access/config.py rename kingdom/access/{authorization => }/dsl.py (100%) create mode 100644 kingdom/access/flow.py create mode 100644 kingdom/access/jwt.py delete mode 100644 kingdom/access/tests/authorization/__init__.py delete mode 100644 kingdom/access/tests/authorization/test_encode.py delete mode 100644 kingdom/access/tests/authorization/test_flow.py delete mode 100644 kingdom/access/tests/authorization/test_interface.py delete mode 100644 kingdom/access/tests/authorization/test_verify.py create mode 100644 kingdom/access/tests/test_base.py rename kingdom/access/tests/{authorization => }/test_dsl.py (99%) create mode 100644 kingdom/access/tests/test_flow.py create mode 100644 kingdom/access/types.py delete mode 100644 kingdom/access/utils.py diff --git a/kingdom/access/authentication/__init__.py b/kingdom/access/authentication/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kingdom/access/authentication/authenticator.py b/kingdom/access/authentication/authenticator.py deleted file mode 100644 index 40e8a02..0000000 --- a/kingdom/access/authentication/authenticator.py +++ /dev/null @@ -1,50 +0,0 @@ -""" -authenticator.py -Handles authentication throughout services -""" -from dataclasses import dataclass -from typing import Dict - -import jwt - -from src.core import config - - -class InvalidToken(Exception): - def __init__(self): - super().__init__("Invalid JWT token.") - - -@dataclass -class JWT: - "Simple JWT Wrapper" - token: str - - def decode(self) -> Dict: - """Decodes a JWT""" - try: - self.payload = jwt.decode( - jwt=self.token, - key=config.get_jwt_secret_key(), - algorithms=config.JWT_ALGORITHM, - ) - return self.payload - except jwt.PyJWTError: - raise InvalidToken() - - -class Authenticator: - token: JWT - payload: Dict - - def __init__(self, token: str): - self.jwt = JWT(token) - - def __call__(self): - self.payload = self.jwt.decode() - - -def test_authenticator(): - token = "abr9f8c09adj!lsd.sdxxf.ldfj" - auth = Authenticator(token) - assert auth() diff --git a/kingdom/access/authentication/types.py b/kingdom/access/authentication/types.py deleted file mode 100644 index 29caf0c..0000000 --- a/kingdom/access/authentication/types.py +++ /dev/null @@ -1,82 +0,0 @@ -from datetime import datetime, timedelta -from typing import Dict, Generator, List, Optional, Tuple - -import jwt - -from kingdom.access.authorization.encode import encode -from kingdom.access.authorization.types import ( - AccessRequest, - Conditional, - Permission, - Policy, - PolicyContext, - Resource, - dataclass, -) - -RANDOM_KEY = "abcd00f" -JWT_ALGORITHM = "HS256" - -TOKEN_EXPIRATION_MIN = 30 - - -@dataclass -class Role: - name: str - policies: List[Optional[Policy]] - - def __hash__(self) -> int: - return hash(self.name) - - -@dataclass -class User: - access_key: str - roles: List[Optional[Role]] - - def resolve_policies(self): - # TODO: Improve this typing - for role in self.roles: - for policy in role.policies: - yield policy - - -JWTDecodedPayload = Dict - -MaybeBytes = Tuple[Optional[bytes], Optional[Exception]] -MaybePayload = Tuple[Optional[JWTDecodedPayload], Optional[Exception]] - - -def encode_user_policies(user: User) -> PolicyContext: - user_policies = list(user.resolve_policies()) - return encode(user_policies) - - -def encode_user_payload(user: User) -> JWTDecodedPayload: - expiration = datetime.utcnow() + timedelta(minutes=TOKEN_EXPIRATION_MIN) - return dict( - sub=user.access_key, exp=expiration, roles=encode_user_policies(user), - ) - - -def encode_jwt(user: User) -> MaybeBytes: - payload = encode_user_payload(user) - try: - return ( - jwt.encode( - payload=payload, key=RANDOM_KEY, algorithm=JWT_ALGORITHM - ), - None, - ) - except jwt.PyJWTError as exc: - return None, exc - - -def decode_jwt(token: bytes) -> MaybePayload: - try: - return ( - jwt.decode(jwt=token, key=RANDOM_KEY, algorithms=[JWT_ALGORITHM]), - None, - ) - except jwt.PyJWTError as exc: - return None, exc diff --git a/kingdom/access/authorization/__init__.py b/kingdom/access/authorization/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kingdom/access/authorization/encode.py b/kingdom/access/authorization/encode.py deleted file mode 100644 index 443398d..0000000 --- a/kingdom/access/authorization/encode.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -encode.py - -Whenever a user is successfully authenticated, application should be able to -properly encode its policies in an associative mapping form to be packed within -JWT payload. -""" - -from functools import reduce -from typing import Dict, List, Optional, Tuple, Union - -from kingdom.access.authorization.types import ( - TOKEN_ALL, - Conditional, - Permission, - Policy, - PolicyContext, - PolicyContextRaw, - Resource, - Selector, -) - -PermissionTuple = Tuple[Permission, ...] - - -def itop(permissions: Union[int, PermissionTuple]) -> PermissionTuple: - """ - Considering that a permission can be both an integer and an instance of - Permission, this function ensures that operations are done with a - Permission type. - It does by translating integer to Permission whenever needed. - - >>> atoi_permission(0) - (Permission.READ,) - >>> atoi_permission((Permission.CREATE, Permission.READ)) - (Permission.CREATE, Permission.READ) - """ - if isinstance(permissions, int): - return (Permission(permissions),) - return permissions - - -def union_permissions( - permissions: Union[PermissionTuple, int], - incoming_permissions: Union[PermissionTuple, int], -) -> int: - """ - Given a set of permissions and a set of incoming permissions that should - be unionized, this method adds them up. - - >>> union_permissions((Permission.READ,), 2) - 3 - >>> union_permissions( - (Permission.DELETE,), (Permission.CREATE, Permission.UPDATE)) - 7 - >>> union_permissions((Permission.CREATE,), (Permission.CREATE,)) - 1 - """ - current: PermissionTuple = itop(permissions) - incoming: PermissionTuple = itop(incoming_permissions) - - updated = set(current) | set(incoming) - perms = reduce(lambda a, b: a | b, updated) - - if isinstance(perms, Permission): - # If updated has only one element, reduce will output the element - # itself. Hence we need to get its value - return perms.value - return perms - - -def encode(policies: List[Policy]) -> PolicyContext: - """ - This functions parses a list of Policy and return a mapping of - resource-selector-perimssion, known as PolicyContext. - This mapping should also contain a simplification of redundant policies - that a subject might have. - - >>> a_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.UPDATE,), - conditionals=[Conditional("resource.id", "*")] - ) - >>> ya_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "5f34")] - ) - >>> pack_policies([a_policy, ya_policy]) - { - "product": { - "5f34": (Permission.READ,) - }, - "account": { - "*": (Permission.UPDATE,) - } - } - - >>> pack_policies([a_policy, ya_policy]) - { - "product": { - "5f34": 1 - }, - "account": { - "*": 4 - } - } - - TODO: This is currently a side-effectful implementation, we could def. - implement a pure one. - """ - # owned: PolicyContext = {} - owned: PolicyContextRaw = {} - - # Iterative approach: on every iteration we keep on - # building output dictionary - for policy in policies: - resource = policy.resource.alias - permissions = policy.permissions - if resource not in owned: - owned[resource] = {} - - # Iterate on every conditional - for conditional in policy.conditionals: - selector = conditional.selector - # There are two kinds of selectors: specific and generics. - # And a selector already exist for a given resource or it doesn't. - if selector in owned[resource]: - existing_permissions = owned[resource][selector] - else: - existing_permissions = tuple() - - owned[resource][selector] = union_permissions( - permissions, existing_permissions - ) - - # Now remove any redundant permission due to a possible "*" selector - # Brute-force. - for resource, selector_perm in owned.items(): - if TOKEN_ALL not in selector_perm: - # Well, nothing to do then. - continue - - # all_token_perms = set(selector_perm[TOKEN_ALL]) - for selector, permissions in selector_perm.items(): - # Subtract "*"'s permissions from the other permissions - if selector == TOKEN_ALL: - # Not this one. - continue - - updated_perms = permissions & ~selector_perm[TOKEN_ALL] - if updated_perms == 0: - # All of this selector's permissions are already - # contemplated by "*" - del owned[resource][selector] - else: - owned[resource][selector] = updated_perms - - return owned diff --git a/kingdom/access/authorization/flow.py b/kingdom/access/authorization/flow.py deleted file mode 100644 index ea39d1c..0000000 --- a/kingdom/access/authorization/flow.py +++ /dev/null @@ -1,63 +0,0 @@ -from dataclasses import dataclass -from typing import List - -from kingdom.access.authentication.types import decode_jwt -from kingdom.access.authorization.types import AccessRequest -from kingdom.access.authorization.verify import ( - check_read_permission, - check_write_permission, -) - - -@dataclass -class AuthFlow: - JWT: bytes - context: AccessRequest - - def authenticate(self) -> None: - payload, err = decode_jwt(self.JWT) - if err: - self._is_authenticated = False - else: - self._payload = payload - self._is_authenticated = True - - @property - def is_authenticated(self) -> bool: - if not hasattr(self, "_is_authenticated"): - self.authenticate() - return self._is_authenticated - - @property - def is_authorized(self) -> bool: - if not hasattr(self, "_is_authorized"): - self.authorize() - return self._is_authorized - - def scope(self) -> List: - # TODO: Scope should be a class - # Scope(operation="READ", resource="product", instances=["ffff"]) - return self._scope - - def __resolve_read_authorization(self) -> None: - self._is_authorized = True - owned_permissions = self._payload["roles"] - self._scope = check_read_permission(owned_permissions, self.context) - - def __resolve_write_authorization(self) -> None: - owned_permissions = self._payload["roles"] - self._is_authorized = check_write_permission( - owned_permissions, self.context - ) - if self._is_authorized: - self._scope = [self.context.selector] - else: - self._scope = [] - - def authorize(self) -> None: - """Can either be read/write""" - if self.context.is_request_read(): - # The restriction comes on scope - self.__resolve_read_authorization() - else: - self.__resolve_write_authorization() diff --git a/kingdom/access/authorization/types.py b/kingdom/access/authorization/types.py deleted file mode 100644 index 210f7b2..0000000 --- a/kingdom/access/authorization/types.py +++ /dev/null @@ -1,93 +0,0 @@ -from dataclasses import dataclass -from enum import Enum -from typing import Dict, List, NamedTuple, Tuple, Union - -TOKEN_ALL = "*" - - -class Permission(Enum): - """Fine-grained mapping of a permission.""" - - READ = 0b000 - CREATE = 0b001 - UPDATE = 0b010 - DELETE = 0b100 - - def __hash__(self) -> int: - return hash(self.value) - - def __or__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value | other - - def __ror__(self, other): - return self.__or__(other) - - def __and__(self, other): - if isinstance(other, Permission): - other = other.value - return self.value & other - - def __rand__(self, other): - return self.__and__(other) - - -@dataclass -class Resource: - """An enterprise-wide resource wrapper""" - - name: str - - def __init__(self, name: str): - self.name = name - self.alias = name.lower() - - -@dataclass -class Conditional: - """One and only one conditional clause""" - - identifier: str - selector: str - - -@dataclass -class Policy: - """ - Aggregate of authorization module. - - An association of permissions that are allowed to be performed on - on instances of a given resource that match ANY of the conditionals - criteria""" - - resource: Resource - permissions: Tuple[Permission] - conditionals: List[Conditional] - - -@dataclass -class AccessRequest: - operation: int - resource: str - selector: str - - def __init__( - self, operation: Permission, resource: Resource, selector: str - ): - self.operation = operation.value - self.resource = resource.alias - self.selector = selector - - def is_request_read(self) -> bool: - return self.operation == 0 - - -ResourceAlias = str -Selector = str -SelectorPermissionMap = Dict[Selector, Tuple[Permission, ...]] -PolicyContext = Dict[ResourceAlias, SelectorPermissionMap] - -PermissionRaw = int -PermissionMap = Dict[Selector, PermissionRaw] -PolicyContextRaw = Dict[ResourceAlias, PermissionMap] diff --git a/kingdom/access/authorization/verify.py b/kingdom/access/authorization/verify.py deleted file mode 100644 index fa13951..0000000 --- a/kingdom/access/authorization/verify.py +++ /dev/null @@ -1,181 +0,0 @@ -""" -verify.py - -Responsible for checking and handling whether a given subject is allowed to do -a given action on a given resource. - -There are two authorization scenarios considered: - 1. A read scenario, in which authorization module should pass-along to - the server which instances of a given resource that subject is allowed - to read. - 2. A write scenario, in which authorization module should tell whether - user has permission or not to perform such action. -""" -from typing import Dict, List, Tuple, Union - -from kingdom.access.authorization.types import ( - TOKEN_ALL, - AccessRequest, - Permission, - Policy, - PolicyContext, - Selector, -) - - -def check_read_permission( - owned_policies: PolicyContext, access_request: AccessRequest -) -> List[Selector]: - """ - Consider a subject that tries to access a resource. The access attempt is - abstracted as AccessRequest and the whole set of policies it owns is - represented as PolicyContext. - - This function returns the list of Selectors of requested resource that - the subject is allowed to read. - - >>> access_request = AccessRequest( - operation=Permission.READ, resource=Resource("Coupon"), selector="*" - ) - >>> owned_perm = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - }, - "users": { - "ccf3": (Permission.READ,), - "abbc": (Permission.UPDATE, Permission.DELETE), - }, - } - >>> check_read_permission(owned_perm, access_request) - ["ab4c", "bc3f"] - - More examples on test suite. - """ - # Sanity check - assert access_request.operation == Permission.READ.value - - resource = access_request.resource - if resource not in owned_policies: - # Subject has no permission related to requested resource. - return [] - - # Subject has at least one selector that it can read. - if TOKEN_ALL in owned_policies[resource]: - # If it has any binding to "*", then it can read it all. - return [TOKEN_ALL] - - # Subject has specific identifiers, we shall return them. - allowed_ids = owned_policies[resource].keys() - return list(allowed_ids) - - -def check_write_permission( - owned_policies: PolicyContext, - access_request: AccessRequest, -) -> bool: - """ - Consider a subject that tries to access a resource. The access attempt is - abstracted as AccessRequest and the whole set of policies it owns is - represented as PolicyContext. - - This function returns whether the user has enough permissions to do - the write operation on AccessRequest. - - >>> access_request = AccessRequest( - operation=Permission.CREATE, resource=Resource("Coupon"), selector="*" - ) - >>> owned_perm = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE,), - "b4a3": (Permission.DELETE,), - }, - } - >>> check_write_permission(owned_perm, access_request) - False - - More examples on test suite. - """ - # TODO: Refactor and improve logic handling, DRY plz - # Sanity check. - assert access_request.operation & ( - Permission.CREATE | Permission.UPDATE | Permission.DELETE - ) - - resource = access_request.resource - if resource not in owned_policies: - # Resource is unknown to subject. - return False - # resource_owned = owned_policies[resource] - - # CREATE case is different than UPDATE and DELETE. - # It requires a "*" selector. - if access_request.operation == Permission.CREATE.value: - # To CREATE, subject must have at least one '*' policy. - if TOKEN_ALL not in owned_policies[resource]: - return False - - # Checking is a simple O(1) step. - return is_allowed( - owned_policies[resource][TOKEN_ALL], access_request.operation - ) - # return Permission.CREATE in owned_policies[resource][TOKEN_ALL] - - # Deal with UPDATE or DELETE - # Check for generics. - if TOKEN_ALL in owned_policies[resource]: - permissions = owned_policies[resource][TOKEN_ALL] - if is_allowed(permissions, access_request.operation): - # Wildcard matches permission. - return True - - # Then specify. - if access_request.selector not in owned_policies[resource]: - # No rule for requested instance. Denied. - return False - - permissions = owned_policies[resource][access_request.selector] - return is_allowed(permissions, access_request.operation) - - -def is_allowed( - owned_permissions: Union[Tuple[Permission, ...], int], - requested_operation: Union[int, Permission], -) -> bool: - """ - When a subject tries to perform a write, this function calculates whether - the requested operation is allowed on a set of owned permissions. - - There are a few caveats: - 1. Write permission overrides read permission. - e.g. If a Subject has any write permission, him/her implicitly - have read permission. - 2. No write permissions override one-another. - e.g. If a Subject has DELETE permission and it doesn't have - UPDATE or CREATE permissions, it is only allowed to delete. - - - >>> owned_perm = (Permission.READ, Permission.UPDATE, Permission.DELETE), - >>> requested_op = Permission.CREATE - >>> is_allowed(owned_perm, requested_op) - False - """ - # TODO: Refactor and improve typing - - if isinstance(requested_operation, int): - requested_operation = Permission(requested_operation) - if isinstance(owned_permissions, int): - owned_permissions = (owned_permissions,) - - # corner case is when requested permission is READ: - if requested_operation == Permission.READ and owned_permissions: - # if we have ANY permission, it means that the user is able to read - return True - - permissions = 0 - for owned_permission in owned_permissions: - permissions |= owned_permission - - return int(permissions & requested_operation) > 0 diff --git a/kingdom/access/base.py b/kingdom/access/base.py new file mode 100644 index 0000000..085c051 --- /dev/null +++ b/kingdom/access/base.py @@ -0,0 +1,287 @@ +from copy import deepcopy +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from functools import reduce +from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union + +from kingdom.access import config +from kingdom.access.types import Payload, PolicyContext + +TOKEN_ALL = "*" + + +class Permission(Enum): + """Fine-grained mapping of a permission operation.""" + + READ = 0b000 + CREATE = 0b001 + UPDATE = 0b010 + DELETE = 0b100 + + def __hash__(self) -> int: + return hash(self.value) + + def __or__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value | other + + def __ror__(self, other): + return self.__or__(other) + + def __and__(self, other): + if isinstance(other, Permission): + other = other.value + return self.value & other + + def __rand__(self, other): + return self.__and__(other) + + +@dataclass +class Resource: + """An enterprise-wide resource wrapper""" + + name: str + + def __init__(self, name: str): + self.name = name + self.alias = name.lower() + + +@dataclass +class Statement: + """One and only one conditional statement""" + + identifier: str + selector: str + + +@dataclass +class Policy: + """ + An association of permissions that are allowed to be performed on + on instances of a given resource that match ANY of the conditionals + criteria""" + + resource: Resource + permissions: Tuple[Permission, ...] + conditionals: List[Statement] + + +@dataclass +class Role: + name: str + policies: List[Policy] + + def __hash__(self) -> int: + return hash(self.name) + + +@dataclass +class User: + access_key: str + roles: List[Optional[Role]] + + @property + def encoded_policies(self) -> PolicyContext: + user_policies = list(self.resolve_policies()) + return encode_policies(user_policies) + + @property + def jwt_payload(self) -> Payload: + expiration = datetime.utcnow() + timedelta( + minutes=config.TOKEN_EXPIRATION_MIN + ) + return dict( + sub=self.access_key, exp=expiration, policies=self.encoded_policies + ) + + def resolve_policies(self): + # TODO: Improve this typing + for role in self.roles: + for policy in role.policies: + yield policy + + +PermissionTuple = Tuple[Permission, ...] +AnyPermission = Union[Permission, int] +PermissionTupleOrInt = Union[PermissionTuple, int] + + +def itop(permissions: PermissionTupleOrInt) -> PermissionTuple: + """ + Considering that a permission can be both an integer and an instance of + Permission, this function ensures that operations are done with a + Permission type. + It does by translating integer to Permission whenever needed. + + >>> atoi_permission(0) + (Permission.READ,) + >>> atoi_permission((Permission.CREATE, Permission.READ)) + (Permission.CREATE, Permission.READ) + """ + if isinstance(permissions, int): + return (Permission(permissions),) + return permissions + + +def union_permissions( + permissions: PermissionTupleOrInt, + incoming_permissions: PermissionTupleOrInt, +) -> int: + """ + Given a set of permissions and a set of incoming permissions that should + be unionized, this method adds them up. + + >>> union_permissions((Permission.READ,), 2) + 3 + >>> union_permissions( + (Permission.DELETE,), (Permission.CREATE, Permission.UPDATE)) + 7 + >>> union_permissions((Permission.CREATE,), (Permission.CREATE,)) + 1 + """ + current: PermissionTuple = itop(permissions) + incoming: PermissionTuple = itop(incoming_permissions) + + updated: Set[Permission] = set(current) | set(incoming) + perms: AnyPermission = reduce(lambda a, b: a | b, updated) + + if isinstance(perms, Permission): + # If updated has only one element, reduce will output the element + # itself. Hence we need to get its value + return int(perms.value) + return perms + + +def build_redundant_context(policies: List[Policy]) -> PolicyContext: + """ + Parses a list of Policy and return a mapping of + resource per selector per permission, known as PolicyContext. + + Warning: This map is redundant i.e. "*" might have overlapping permissions + with some other specific selectors. Take this example: + + >>> a_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE, Permission.CREATE,), + conditionals=[Conditional("resource.id", "*")] + ) + >>> ya_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE), + conditionals=[Conditional("resource.id", "5f34")] + ) + >>> build_redundant_context([a_policy, ya_policy]) + { + "account": { + "*": 3, + "5f34": 2, + } + } + + Note that "5f34" entry is redundant because "*" selector already + contemplates this statement condition. + """ + owned: PolicyContext = {} + + # Iterative approach: on every iteration we keep on + # building output dictionary + for policy in policies: + resource = policy.resource.alias + permissions: PermissionTuple = policy.permissions + if resource not in owned: + owned[resource] = {} + + # Iterate on every conditional + for conditional in policy.conditionals: + selector = conditional.selector + # There are two kinds of selectors: specific and generics. + # And a selector already exist for a given resource or it doesn't. + if selector in owned[resource]: + existing_permissions: int = owned[resource][selector] + else: + existing_permissions: Tuple = tuple() + + owned[resource][selector] = union_permissions( + permissions, existing_permissions + ) + + return owned + + +def remove_context_redundancy(context: PolicyContext) -> PolicyContext: + """ + Given a redundant PolicyContext, remove any redundancy that might exist + + For now, redundancies origins is from having a special "*" token. + """ + # Now remove any redundant permission due to a possible "*" selector + # Brute-force. + simplified: PolicyContext = deepcopy(context) + + for resource, selector_perm in context.items(): + if TOKEN_ALL not in selector_perm: + # Well, nothing to do then. + continue + + # all_token_perms = set(selector_perm[TOKEN_ALL]) + for selector, permissions in selector_perm.items(): + # Subtract "*"'s permissions from the other permissions + if selector == TOKEN_ALL: + # Not this one. + continue + + updated_perms = permissions & ~selector_perm[TOKEN_ALL] + if updated_perms == 0: + # All of this selector's permissions are already + # contemplated by "*" + del simplified[resource][selector] + else: + simplified[resource][selector] = updated_perms + + return simplified + + +def encode_policies(policies: List[Policy]) -> PolicyContext: + """ + This functions parses a list of Policy and return a mapping of + resource-selector-perimssion, known as PolicyContext. + This mapping should also contain a simplification of redundant policies + that a subject might have. + + >>> a_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[Conditional("resource.id", "*")] + ) + >>> ya_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Conditional("resource.id", "5f34")] + ) + >>> pack_policies([a_policy, ya_policy]) + { + "product": { + "5f34": Permission.READ.value, + }, + "account": { + "*": Permission.UPDATE.value, + }, + } + + # Which is the equivalent: + >>> pack_policies([a_policy, ya_policy]) + { + "product": { + "5f34": 0, + }, + "account": { + "*": 4, + }, + } + """ + context = build_redundant_context(policies) + return remove_context_redundancy(context) diff --git a/kingdom/access/config.py b/kingdom/access/config.py new file mode 100644 index 0000000..5b4311b --- /dev/null +++ b/kingdom/access/config.py @@ -0,0 +1,4 @@ +TOKEN_ALL = "*" +RANDOM_KEY = "abcd00f" +JWT_ALGORITHM = "HS256" +TOKEN_EXPIRATION_MIN = 30 diff --git a/kingdom/access/authorization/dsl.py b/kingdom/access/dsl.py similarity index 100% rename from kingdom/access/authorization/dsl.py rename to kingdom/access/dsl.py diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py new file mode 100644 index 0000000..e37f970 --- /dev/null +++ b/kingdom/access/flow.py @@ -0,0 +1,193 @@ +from dataclasses import dataclass +from typing import Dict, List, Tuple, TypeVar + +from kingdom.access import jwt +from kingdom.access.base import Optional, Permission, Resource +from kingdom.access.types import JWT, Payload, PolicyContext, Scope + +TOKEN_ALL = "*" + +UserKey = str + + +class AccessRequest: + operation: int + resource: str + selector: str + + def __init__(self, operation: str, resource: str, selector: str) -> None: + self.resource = resource + self.selector = selector + if not selector: + self.selector = TOKEN_ALL + self.__operation = getattr(Permission, operation) + self.operation = self.__operation.value + + def __repr__(self) -> str: + return ( + f"" + ) + + +class NotEnoughPrivilegesErr(Exception): + def __init__(self, request: AccessRequest): + super().__init__( + f"Unauthorized: User have not enough privileges to do " + f"{request.operation} on {request.selector} of {request.resource}." + ) + + +def authenticate(token: JWT) -> Tuple[PolicyContext, UserKey]: + """ + Raises an Exception if not authenticated + """ + payload = jwt.decode(token) + return payload["policies"], payload["access_key"] + + +def authorize( + policies: PolicyContext, resource: str, operation: str, selector: str = "", +) -> Scope: + """ + Raises an Exception if not authorized + """ + request = AccessRequest( + resource=resource, operation=operation, selector=selector + ) + scope, authorized = check_permission(policies, request) + if not authorized: + raise NotEnoughPrivilegesErr(request) + return scope + + +def check_permission( + owned_policies: PolicyContext, access_request: AccessRequest +) -> Tuple[Scope, bool]: + if access_request.operation == Permission.READ.value: + return get_read_scope(owned_policies, access_request), True + return ( + [access_request.selector], + is_write_allowed(owned_policies, access_request), + ) + + +def get_read_scope( + owned_policies: PolicyContext, access_request: AccessRequest +) -> Scope: + """ + Consider a subject that tries to access a resource. The access attempt is + abstracted as AccessRequest and the whole set of policies it owns is + represented as PolicyContext. + + This function returns which selectors of access_request.resource the + subject is allowed to read from. + + >>> access_request = AccessRequest( + operation="READ", resource="coupon", selector="*" + ) + >>> owned_perm = { + "coupon": { + "ab4c": Permission.READ.value, + "bc3f": (Permission.READ | Permission.UPDATE), + }, + "users": { + "ccf3": Permission.READ.value, + "abbc": (Permission.UPDATE | Permission.DELETE), + }, + } + >>> check_read_permission(owned_perm, access_request) + ["ab4c", "bc3f"] + + More examples on test suite. + """ + # Sanity check + assert access_request.operation == Permission.READ.value + + resource = access_request.resource + if resource not in owned_policies: + # Subject has no permission related to requested resource. + return [] + + # Subject has at least one selector that it can read. + if TOKEN_ALL in owned_policies[resource]: + # If it has any binding to "*", then it can read it all. + return [TOKEN_ALL] + + # Subject has specific selectors, we shall return them. + allowed_ids = owned_policies[resource].keys() + return list(allowed_ids) + + +def is_write_allowed( + owned_policies: PolicyContext, access_request: AccessRequest, +) -> bool: + """ + Consider a subject that tries to access a resource. The access attempt is + abstracted as AccessRequest and the whole set of policies it owns is + represented as PolicyContext. + + This function returns whether the user has enough permissions to do + the write operation on AccessRequest. + + >>> access_request = AccessRequest( + operation=Permission.CREATE, resource=Resource("Coupon"), selector="*" + ) + >>> owned_perm = { + "coupon": { + "ab4c": (Permission.READ.value,), + "bc3f": (Permission.READ | Permission.UPDATE), + "cc4a": (Permission.UPDATE.value,), + "b4a3": (Permission.DELETE.value,), + }, + } + >>> check_write_permission(owned_perm, access_request) + False + + More examples on test suite. + """ + + def mask_pass(owned_permissions: int, requested_operation: int,) -> bool: + """ + When a subject tries to perform a write, this function calculates + whether the requested operation is allowed on a set of owned + permissions. + + >>> owned_perm = ( + Permission.READ | Permission.UPDATE | Permission.DELETE + ), + >>> requested_op = Permission.CREATE.value + >>> is_allowed(owned_perm, requested_op) + False + """ + return int(owned_permissions & requested_operation) > 0 + + TOKEN_ALL = "*" + assert access_request.operation & ( + Permission.CREATE | Permission.UPDATE | Permission.DELETE + ) + + resource = access_request.resource + operation = access_request.operation + selector = access_request.selector + if resource not in owned_policies: + # Resource is unknown to subject. + return False + + if selector == TOKEN_ALL and selector not in owned_policies[resource]: + # For e.g CREATE without CREATE ALL policy + return False + + if TOKEN_ALL in owned_policies[resource]: + # We might be asking for a specific instance but we have a "*" policy + # that contemplates it. + if mask_pass(owned_policies[resource][TOKEN_ALL], operation): + return True + + # Now that we've reached here, it means that subject has no "*" policy + # to allow it for asked resource. Hence it must have a specific policy + if selector not in owned_policies[resource]: + return False + + permissions = owned_policies[resource][selector] + return mask_pass(permissions, operation) diff --git a/kingdom/access/jwt.py b/kingdom/access/jwt.py new file mode 100644 index 0000000..a2ead08 --- /dev/null +++ b/kingdom/access/jwt.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +import jwt +from kingdom.access import config +from kingdom.access.types import JWT, Payload + +MaybeJWT = Tuple[Optional[JWT], Optional[Exception]] +MaybePayload = Tuple[Optional[Payload], Optional[Exception]] + + +class InvalidToken(Exception): + def __init__(self): + super().__init__("Unauthorized: Invalid token.") + + +def encode(payload: Payload) -> JWT: + try: + return jwt.encode( + payload=payload, + key=config.RANDOM_KEY, + algorithm=config.JWT_ALGORITHM, + ) + + except jwt.PyJWTError: + raise + + +def decode(token: JWT) -> Payload: + try: + return jwt.decode( + jwt=token, + key=config.RANDOM_KEY, + algorithms=[config.JWT_ALGORITHM], + ) + except jwt.PyJWTError: + raise InvalidToken() diff --git a/kingdom/access/tests/authorization/__init__.py b/kingdom/access/tests/authorization/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/kingdom/access/tests/authorization/test_encode.py b/kingdom/access/tests/authorization/test_encode.py deleted file mode 100644 index a4a6f68..0000000 --- a/kingdom/access/tests/authorization/test_encode.py +++ /dev/null @@ -1,148 +0,0 @@ -from kingdom.access.authorization.encode import encode -from kingdom.access.authorization.types import ( - Conditional, - Permission, - Policy, - PolicyContext, - Resource, - Selector, -) - - -def test_simple_policy_packing(): - """Given a list of Policies with no overlapping conditional selectors, - we expect a well-formatted DTO dict""" - product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "ab4f"), - Conditional("resource.id", "13fa"), - ], - ) - - ya_product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.CREATE,), - conditionals=[Conditional("resource.id", "*"), ], - ) - - account_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "*"), ], - ) - - ya_account_policy = Policy( - resource=Resource("Account"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "0bf3"), - Conditional("resource.id", "bc0e"), - ], - ) - - role_policies = [ - product_policy, - ya_product_policy, - account_policy, - ya_account_policy, - ] - - owned_perm = { - "product": {"*": 1, "ab4f": 2, "13fa": 2, }, - "account": {"*": 0, "0bf3": 2, "bc0e": 2, }, - } - - assert encode(role_policies) == owned_perm - - -def test_cumulative_policy_packing(): - product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "*"), ], - ) - - ya_product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.CREATE,), - conditionals=[Conditional("resource.id", "*"), ], - ) - - account_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "044e"), - Conditional("resource.id", "0e0e"), - Conditional("resource.id", "bc0e"), - ], - ) - - ya_account_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.DELETE,), - conditionals=[Conditional("resource.id", "044e"), ], - ) - - yaa_account_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.DELETE,), - conditionals=[ - Conditional("resource.id", "bc0e"), - Conditional("resource.id", "aac0"), - ], - ) - role_policies = [ - product_policy, - ya_product_policy, - account_policy, - ya_account_policy, - yaa_account_policy, - ] - owned_perm = { - "product": {"*": 1, "044e": 6, "0e0e": 2, "bc0e": 6, "aac0": 4, } - } - assert encode(role_policies) == owned_perm - - -def test_redundant_policy_packing(): - """Given a list of Policies with overlapping conditional selectors, - we expect a well-formatted DTO dict""" - product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ, Permission.CREATE), - conditionals=[Conditional("resource.id", "*"), ], - ) - - ya_product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ, Permission.UPDATE), - conditionals=[ - Conditional("resource.id", "7fb4"), - Conditional("resource.id", "49f3"), - Conditional("resource.id", "abc9"), - ], - ) - - yao_product_policy = Policy( - resource=Resource("Product"), - permissions=(Permission.READ, Permission.DELETE,), - conditionals=[ - Conditional("resource.id", "aaa"), - Conditional("resource.id", "abc9"), - ], - ) - - role_policies = [ - product_policy, - ya_product_policy, - yao_product_policy, - ] - - owned_perm = { - "product": {"*": 1, "7fb4": 2, "49f3": 2, "abc9": 6, "aaa": 4, }, - } - - assert encode(role_policies) == owned_perm diff --git a/kingdom/access/tests/authorization/test_flow.py b/kingdom/access/tests/authorization/test_flow.py deleted file mode 100644 index 2aaff7f..0000000 --- a/kingdom/access/tests/authorization/test_flow.py +++ /dev/null @@ -1,145 +0,0 @@ -from kingdom.access.authentication.types import ( - AccessRequest, - Permission, - Resource, - encode_jwt, -) -from kingdom.access.authorization.flow import AuthFlow -from kingdom.access.tests.authorization.test_interface import ( - gen_default_user, - gen_invalid_token, -) - -""" -Problem: - Given an incoming JWT token, authentication flow is responsible for: - - 1. Verifying (and decoding) whther a JWT is valid. - 2. Return whether a user can perform a command - > Cancel or not flow. - 3. Return which instances of a resource a subject can see - > A list of resources-scope -""" - -JWT = bytes - - -def gen_valid_jwt() -> JWT: - user = gen_default_user() - jwt, err = encode_jwt(user) - return jwt if jwt else b"" - - -class TestHappyPath: - def test_known_user_is_authenticated(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Coupon"), - selector="7fb4", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - def test_known_user_is_authenticated_and_authorized_to_read(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Coupon"), - selector="7fb4", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - flow.authorize() - assert flow.is_authorized - - assert flow.scope() == ["7fb4", "49f3"] - - def test_known_user_is_authenticated_and_authorized_to_write(self): - access_request = AccessRequest( - operation=Permission.UPDATE, - resource=Resource("User"), - selector="ffff", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - flow.authorize() - assert flow.is_authorized - - assert flow.scope() == ["ffff"] - - -class TestUnhappyPath: - def test_unknown_user_is_not_authenticated(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Coupon"), - selector="7fb4", - ) - jwt = gen_invalid_token() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - - assert flow.is_authenticated is False - - def test_subject_has_no_permission_to_read(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Wallet"), - selector="*", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - flow.authorize() - assert flow.is_authorized - - # permission to read nothing - assert flow.scope() == [] - - def test_subject_has_no_permission_to_write(self): - access_request = AccessRequest( - operation=Permission.CREATE, - resource=Resource("Wallet"), - selector="*", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - flow.authorize() - assert flow.is_authorized is False - - assert flow.scope() == [] - - def test_subject_has_no_permission_to_write_2(self): - access_request = AccessRequest( - operation=Permission.UPDATE, - resource=Resource("User"), - selector="4ff3", - ) - jwt = gen_valid_jwt() - - flow = AuthFlow(jwt, access_request) - flow.authenticate() - assert flow.is_authenticated - - flow.authorize() - assert flow.is_authorized is False - - assert flow.scope() == [] diff --git a/kingdom/access/tests/authorization/test_interface.py b/kingdom/access/tests/authorization/test_interface.py deleted file mode 100644 index bb7d1d2..0000000 --- a/kingdom/access/tests/authorization/test_interface.py +++ /dev/null @@ -1,91 +0,0 @@ -from kingdom.access.authentication.types import ( - JWT_ALGORITHM, - RANDOM_KEY, - Conditional, - JWTDecodedPayload, - MaybeBytes, - MaybePayload, - Permission, - Policy, - Resource, - Role, - User, - decode_jwt, - encode_jwt, - encode_user_payload, - encode_user_policies, -) - -""" -Problems: - -1. We have a user and we want to issue a JWT on his behalf. -2. We have a JWT and want to parse a JWT on his behalf -""" - -"""Problem 1: -1. We need to have the mininum set of User-Role-Policy domain defined. -""" - -JWT = bytes - - -def gen_default_user() -> User: - product_read = Policy( - resource=Resource("Product"), - permissions=(Permission.READ,), - conditionals=[ - Conditional("resource.id", "*"), - ], - ) - self_edit = Policy( - resource=Resource("User"), - permissions=(Permission.UPDATE,), - conditionals=[ - Conditional("resource.id", "ffff"), - ], - ) - specific_coupon = Policy( - resource=Resource("Coupon"), - permissions=(Permission.READ,), - conditionals=[ - Conditional("resource.id", "7fb4"), - Conditional("resource.id", "49f3"), - ], - ) - role = Role( - name="default consumer", - policies=[self_edit, product_read, specific_coupon], - ) - user = User(access_key="abc4f", roles=[role]) - return user - - -def gen_invalid_token() -> JWT: - from string import ascii_letters - from random import randint - - def token_part(): - return "".join(ascii_letters[randint(0, 50)] for _ in range(1, 64)) - - token_str = ".".join(token_part() for _ in range(3)) - return token_str.encode() - - -def test_successful_encoding_and_decoding(): - user = gen_default_user() - user_policies = encode_user_policies(user) - - jwt, err_encode = encode_jwt(user) - assert err_encode is None - - decoded_jwt, err_decode = decode_jwt(jwt) - assert err_decode is None - - assert user_policies == decoded_jwt["roles"] - - -def test_unsuccessful_decoding(): - invalid_token = gen_invalid_token() - _, err_decode = decode_jwt(invalid_token) - assert isinstance(err_decode, Exception) diff --git a/kingdom/access/tests/authorization/test_verify.py b/kingdom/access/tests/authorization/test_verify.py deleted file mode 100644 index 6eddedd..0000000 --- a/kingdom/access/tests/authorization/test_verify.py +++ /dev/null @@ -1,250 +0,0 @@ -from typing import List, Tuple - -from kingdom.access.authorization.types import ( - AccessRequest, - Permission, - Policy, - PolicyContext, - Resource, -) -from kingdom.access.authorization.verify import ( - check_read_permission, - check_write_permission, - is_allowed, -) - - -class TestReadPermission: - "Test permissions on a read scenario" - fn = check_read_permission - - def test_read_permission_filtering_unauthorized(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Product"), - selector="*", - ) - owned_perm: PolicyContext = {} - assert check_read_permission(owned_perm, access_request) == [] - - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Product"), - selector="*", - ) - owned_perm = { - "account": {"*": (Permission.CREATE,), }, - } - assert check_read_permission(owned_perm, access_request) == [] - - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Account"), - selector="*", - ) - owned_perm = { - "product": {"*": (Permission.READ,), }, - } - assert check_read_permission(owned_perm, access_request) == [] - - def test_read_permission_filtering_authorized_but_specific(self): - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Coupon"), - selector="*", - ) - owned_perm: PolicyContext = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE,), - "b4a3": (Permission.DELETE,), - }, - } - assert check_read_permission(owned_perm, access_request) == [ - "ab4c", - "bc3f", - "cc4a", - "b4a3", - ] - - access_request = AccessRequest( - operation=Permission.READ, - resource=Resource("Coupon"), - selector="*", - ) - owned_perm = { - "coupon": {"*": (Permission.CREATE,), }, - } - assert check_read_permission(owned_perm, access_request) == ["*"] - - -class TestVerifyWritePermissions: - "Test permissions on a write scenario" - fn = check_write_permission - - def test_write_permission_unauthorized(self): - # Subject hasn't permission to do anything - access_request = AccessRequest( - operation=Permission.CREATE, - resource=Resource("Rules"), - selector="*", - ) - owned_perm: PolicyContext = {} - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in an unknown resource to the subject - access_request = AccessRequest( - operation=Permission.CREATE, - resource=Resource("User"), - selector="*", - ) - owned_perm = { - "product": {"*": (Permission.READ,), }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without enough privileges. - access_request = AccessRequest( - operation=Permission.CREATE, - resource=Resource("Coupon"), - selector="*", - ) - owned_perm = { - "coupon": { - "ab4c": (Permission.READ,), - "bc3f": (Permission.READ, Permission.UPDATE), - "cc4a": (Permission.UPDATE,), - "b4a3": (Permission.DELETE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest( - operation=Permission.UPDATE, - resource=Resource("User"), - selector="ffc0", - ) - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE), - "abc3": (Permission.UPDATE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - # Tries to write in a known resource but without specific privileges - access_request = AccessRequest( - operation=Permission.DELETE, - resource=Resource("User"), - selector="c4fd", - ) - owned_perm = { - "user": { - "*": (Permission.READ, Permission.CREATE, Permission.UPDATE), - "d3fc": (Permission.DELETE,), - }, - } - assert check_write_permission(owned_perm, access_request) is False - - def test_write_permission_authorized(self): - # Tries to create a new item. - access_request = AccessRequest( - operation=Permission.CREATE, - resource=Resource("User"), - selector="*", - ) - owned_perm: PolicyContext = { - "user": {"*": (Permission.UPDATE, Permission.CREATE,), }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to update a specific value on a wildcard policy. - access_request = AccessRequest( - operation=Permission.UPDATE, - resource=Resource("Coupon"), - selector="43df", - ) - owned_perm = { - "coupon": { - "*": (Permission.READ, Permission.UPDATE, Permission.DELETE), - }, - } - assert check_write_permission(owned_perm, access_request) is True - - # Tries to delete a specific value on a specific policy. - access_request = AccessRequest( - operation=Permission.DELETE, - resource=Resource("User"), - selector="3dc4", - ) - owned_perm = { - "user": {"*": (Permission.CREATE,), "3dc4": (Permission.DELETE,)}, - } - assert check_write_permission(owned_perm, access_request) is True - - -# Test is_allowed() - - -class TestIsPermissionAllowed: - "Test if authorization calculations are working as expected" - fn = is_allowed - - def test_tries_to_create_unauthorized(self): - # user have no creation permissions - input: List[Tuple[Permission, ...]] = [ - (Permission.READ,), - (Permission.READ, Permission.UPDATE), - (Permission.READ, Permission.UPDATE, Permission.DELETE), - (Permission.UPDATE, Permission.DELETE), - ] - for perm in input: - assert is_allowed(perm, Permission.CREATE) is False - - def test_tries_to_update_anauthorized(self): - # user have no update permission - input: List[Tuple[Permission, ...]] = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.DELETE), - ] - for perm in input: - assert is_allowed(perm, Permission.UPDATE) is False - - def test_tries_to_delete_anauthorized(self): - # user have no delete permission - input: List[Tuple[Permission, ...]] = [ - (Permission.READ,), - (Permission.READ, Permission.CREATE), - (Permission.READ, Permission.CREATE, Permission.UPDATE), - (Permission.CREATE, Permission.UPDATE), - ] - for perm in input: - assert is_allowed(perm, Permission.DELETE) is False - - def test_tries_to_read_without_explicit_read_authorized(self): - # user have only write permissions and tries to read - input: List[Tuple[Permission, ...]] = [ - (Permission.CREATE,), - (Permission.UPDATE,), - (Permission.DELETE,), - (Permission.CREATE, Permission.DELETE), - (Permission.CREATE, Permission.UPDATE), - (Permission.DELETE, Permission.UPDATE), - (Permission.CREATE, Permission.DELETE, Permission.UPDATE), - ] - for perm in input: - assert is_allowed(perm, Permission.READ) - - def test_tries_to_write_but_has_only_read(self): - # user have only read permissions and tries to do all writes - input: List[Permission] = [ - Permission.CREATE, - Permission.UPDATE, - Permission.DELETE, - ] - for perm in input: - assert is_allowed((Permission.READ,), perm) is False diff --git a/kingdom/access/tests/test_base.py b/kingdom/access/tests/test_base.py new file mode 100644 index 0000000..43ffe6b --- /dev/null +++ b/kingdom/access/tests/test_base.py @@ -0,0 +1,178 @@ +from kingdom.access.base import ( + Permission, + Policy, + Resource, + Role, + Statement, + User, +) + +CREATE = Permission.CREATE | 0 +READ = Permission.READ | 0 +UPDATE = Permission.UPDATE | 0 +DELETE = Permission.DELETE | 0 + + +def test_simple_policy_packing(): + """Given a list of Policies with no overlapping conditional statements, + we expect a well-formatted map""" + + # Define policies + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[ + Statement("resource.id", "ab4f"), + Statement("resource.id", "13fa"), + ], + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE,), + conditionals=[Statement("resource.id", "*"), ], + ) + + # Define domain roles + store_manager = Role( + "Store management group", policies=[product_policy, ya_product_policy] + ) + + account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.READ,), + conditionals=[Statement("resource.id", "*"), ], + ) + + ya_account_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE,), + conditionals=[ + Statement("resource.id", "0bf3"), + Statement("resource.id", "bc0e"), + ], + ) + + # Define domain roles + site_manager = Role( + name="Site management group", + policies=[account_policy, ya_account_policy, ], + ) + + # A ficticious manager + user = User("abbf", roles=[store_manager, site_manager]) + + owned_perm = { + "product": {"*": 1, "ab4f": 2, "13fa": 2, }, + "account": {"*": 0, "0bf3": 2, "bc0e": 2, }, + } + + assert user.encoded_policies == owned_perm + + +def test_cumulative_policy_packing(): + """Given a list of Policies with cumulative conditional statements, + we expect a well-formatted and cohesive map""" + + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ,), + conditionals=[Statement("resource.id", "*"), ], + ) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE,), + conditionals=[Statement("resource.id", "*"), ], + ) + + # Define domain role + store_manager = Role( + "Store management group", policies=[product_policy, ya_product_policy] + ) + + christmas_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[ + Statement("resource.id", "044e"), + Statement("resource.id", "0e0e"), + Statement("resource.id", "bc0e"), + ], + ) + ya_christmas_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.DELETE,), + conditionals=[Statement("resource.id", "044e"), ], + ) + + yaa_christmas_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.DELETE,), + conditionals=[ + Statement("resource.id", "bc0e"), + Statement("resource.id", "aac0"), + ], + ) + + # Define more fine-grained roles + christmas_ops = Role( + "Christmas task-force operations", + policies=[ + christmas_policy, + ya_christmas_policy, + yaa_christmas_policy, + ], + ) + + # A ficticious supervisor + user = User("abbf", roles=[store_manager, christmas_ops]) + owned_perm = { + "product": {"*": 1, "044e": 6, "0e0e": 2, "bc0e": 6, "aac0": 4, } + } + assert user.encoded_policies == owned_perm + + +def test_redundant_policy_packing(): + """Given a list of Policies with overlapping conditional statements, + we expect a well-formatted without redundant statements map""" + + product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.CREATE, Permission.UPDATE), + conditionals=[Statement("resource.id", "*"), ], + ) + + store_manager = Role("Store management group", policies=[product_policy]) + + ya_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ, Permission.DELETE), + conditionals=[ + Statement("resource.id", "7fb4"), + Statement("resource.id", "49f3"), + Statement("resource.id", "abc9"), + ], + ) + + yao_product_policy = Policy( + resource=Resource("Product"), + permissions=(Permission.READ, Permission.UPDATE,), + conditionals=[ + Statement("resource.id", "aaa"), + Statement("resource.id", "abc9"), + ], + ) + + electronic_manager = Role( + "Electronic sales group", + policies=[ya_product_policy, yao_product_policy], + ) + + sales_coord = User("0bf3", roles=[electronic_manager, store_manager]) + + owned_perm = { + "product": {"*": 3, "7fb4": 4, "49f3": 4, "abc9": 4}, + } + + assert sales_coord.encoded_policies == owned_perm diff --git a/kingdom/access/tests/authorization/test_dsl.py b/kingdom/access/tests/test_dsl.py similarity index 99% rename from kingdom/access/tests/authorization/test_dsl.py rename to kingdom/access/tests/test_dsl.py index 085ec40..0026c38 100644 --- a/kingdom/access/tests/authorization/test_dsl.py +++ b/kingdom/access/tests/test_dsl.py @@ -1,4 +1,4 @@ -from kingdom.access.authorization.dsl import ( +from kingdom.access.dsl import ( conditionals_split, parse_expression, parse_identifier, diff --git a/kingdom/access/tests/test_flow.py b/kingdom/access/tests/test_flow.py new file mode 100644 index 0000000..f486239 --- /dev/null +++ b/kingdom/access/tests/test_flow.py @@ -0,0 +1,352 @@ +from typing import List, Tuple + +from kingdom.access.base import Permission, Policy, Resource +from kingdom.access.flow import ( + AccessRequest, + NotEnoughPrivilegesErr, + authorize, + check_permission, + get_read_scope, + is_write_allowed, +) +from kingdom.access.types import PolicyContext +from pytest import raises + +CREATE = Permission.CREATE | 0 +READ = Permission.READ | 0 +UPDATE = Permission.UPDATE | 0 +DELETE = Permission.DELETE | 0 + + +class TestAuthorize: + fn = authorize + + def test_user_tries_to_read(self): + user_policies: PolicyContext = { + "user": {"00df": READ | UPDATE}, + "product": {"*": READ}, + } + # Purposefully don't pass a selector + scope = authorize(user_policies, resource="coupon", operation="READ") + assert scope == [] + + scope = authorize(user_policies, resource="user", operation="READ") + assert scope == ["00df"] + + scope = authorize(user_policies, resource="product", operation="READ") + assert scope == ["*"] + + def test_user_tries_to_write_on_specific_policies(self): + user_id = "00df" + user_policies: PolicyContext = { + "user": {user_id: READ | UPDATE}, + "product": {"*": READ}, + } + with raises(NotEnoughPrivilegesErr): + _ = authorize(user_policies, resource="user", operation="CREATE") + + with raises(NotEnoughPrivilegesErr): + # This should fail because this attempt is to update all users, + # there is an implicit selector + _ = authorize(user_policies, resource="user", operation="UPDATE") + + with raises(NotEnoughPrivilegesErr): + # Tries to update or delete another user than itself. + _ = authorize( + user_policies, + resource="user", + operation="DELETE", + selector="0f0f", + ) + + scope = authorize( + user_policies, + resource="user", + operation="UPDATE", + selector=user_id, + ) + assert scope == [user_id] + + def test_user_tries_to_write_on_generic_policies(self): + supervisor_id = "3fd0" + # Read operations are redundant but explicit is always better than + # implicit. + sup_policies: PolicyContext = { + "user": {"*": READ}, + "product": {"*": READ | CREATE | UPDATE}, + "group": {"*": READ | CREATE}, + "role": { + "3043": READ | UPDATE, + "34cd": READ | UPDATE, + "0039": READ | UPDATE, + "ccdf": READ | UPDATE, + }, + } + + # This supervisor can only create products and groups. Let's make sure: + with raises(NotEnoughPrivilegesErr): + _ = authorize(sup_policies, resource="user", operation="CREATE") + + with raises(NotEnoughPrivilegesErr): + _ = authorize(sup_policies, resource="role", operation="CREATE") + + scope = authorize(sup_policies, resource="product", operation="CREATE") + assert scope == ["*"] + + # Is not allowed to delete any role as well + with raises(NotEnoughPrivilegesErr): + _ = authorize( + sup_policies, + resource="role", + operation="DELETE", + selector="3043", + ) + + # But finally, it can update its subordinate's roles + scope = authorize( + sup_policies, resource="role", operation="UPDATE", selector="3043" + ) + assert scope == ["3043"] + + +class TestReadPermission: + "Test permissions on a read scenario" + fn = get_read_scope + + def test_read_products_with_zero_policies(self): + "User tries to read products but has no permission to read them" + access_request = AccessRequest( + operation="READ", resource="product", selector="*", + ) + owned_perm: PolicyContext = {} + assert get_read_scope(owned_perm, access_request) == [] + + def test_read_products_with_ticket_create_policy(self): + "User tries to read products but has only permission to create tickets" + access_request = AccessRequest( + operation="READ", resource="product", selector="*", + ) + owned_perm = { + "ticket": {"*": READ | CREATE, }, + } + assert get_read_scope(owned_perm, access_request) == [] + + def test_read_coupons_with_specific_cupons_policy(self): + "User tries to read all coupons but can only see selected ones." + access_request = AccessRequest( + operation="READ", resource="coupon", selector="*", + ) + owned_perm: PolicyContext = { + "coupon": { + "ab4c": READ, + "bc3f": READ | UPDATE, + "cc4a": UPDATE, + "b4a3": DELETE, + }, + } + assert get_read_scope(owned_perm, access_request) == [ + "ab4c", + "bc3f", + "cc4a", + "b4a3", + ] + + def test_read_coupons_with_write_all_coupons_policy(self): + "User tries to read all coupons and user is allowed to manage them" + access_request = AccessRequest( + operation="READ", resource="coupon", selector="*", + ) + owned_perm = { + "coupon": {"*": CREATE | UPDATE | DELETE, }, + } + assert get_read_scope(owned_perm, access_request) == ["*"] + + def test_read_accounts_with_self_edit_policy(self): + "User tries to read all coupons but has permissions to read only one" + access_request = AccessRequest( + operation="READ", resource="account", selector="*", + ) + owned_perm = { + "coupon": {"bcc3": READ, }, + "account": {"ddf3": UPDATE}, + "product": {"*": READ}, + "ticket": {"*": CREATE}, + } + assert get_read_scope(owned_perm, access_request) == [ + "ddf3", + ] + + +class TestVerifyWritePermissions: + "Test permissions on a write scenario" + fn = is_write_allowed + + def test_rules_without_policies(self): + "User tries to write a rule without any policies" + create_rule = AccessRequest( + operation="CREATE", resource="rule", selector="*", + ) + owned_perm: PolicyContext = {} + assert is_write_allowed(owned_perm, create_rule) is False + + def test_create_user_with_product_read_policy(self): + "User tries to create a user but has basic read policies" + create_user = AccessRequest( + operation="CREATE", resource="user", selector="*", + ) + owned_perm = { + "product": {"*": READ, }, + "user": {"*": READ | UPDATE | DELETE, }, + "coupons": {"33f0": READ, "ddf0": READ}, + } + assert is_write_allowed(owned_perm, create_user) is False + + create_product = AccessRequest( + operation="CREATE", resource="product", selector="*", + ) + assert is_write_allowed(owned_perm, create_product) is False + + def test_create_coupon_without_create_all_policy(self): + "User tries to create a coupon but can only manage a few of them" + create_coupon = AccessRequest( + operation="CREATE", resource="coupon", selector="*", + ) + owned_perm = { + "coupon": { + "ab4c": READ, + "bc3f": READ | UPDATE, + "cc4a": READ | UPDATE, + "b4a3": READ | DELETE, + }, + } + assert is_write_allowed(owned_perm, create_coupon) is False + + update_coupon = AccessRequest( + operation="UPDATE", resource="coupon", selector="ab4c" + ) + + assert is_write_allowed(owned_perm, update_coupon) is False + + def test_delete_user_without_enough_policies(self): + "User tries to delete user without specific policy" + delete_user = AccessRequest( + operation="UPDATE", resource="user", selector="ffc0", + ) + owned_perm = { + "coupon": {"*": READ | CREATE | UPDATE | DELETE}, + "user": {"*": READ | CREATE, "abc3": UPDATE, }, + } + assert is_write_allowed(owned_perm, delete_user) is False + + def test_create_user_with_proper_policy(self): + "User tries to create a new user" + create_user = AccessRequest( + operation="CREATE", resource="user", selector="*", + ) + owned_perm: PolicyContext = { + "user": {"*": UPDATE | CREATE, }, + } + assert is_write_allowed(owned_perm, create_user) is True + + # But we can't allow it to delete anything. + delete_user = AccessRequest( + operation="DELETE", resource="user", selector="*", + ) + assert is_write_allowed(owned_perm, delete_user) is False + + def test_update_coupon_with_all_policy(self): + "User tries to update a specific coupon with all policies" + update_coupon = AccessRequest( + operation="UPDATE", resource="coupon", selector="43df", + ) + owned_perm = { + "coupon": {"*": READ | UPDATE | DELETE, }, + } + assert is_write_allowed(owned_perm, update_coupon) is True + + # But we can't allow it to create coupons. + create_coupon = AccessRequest( + operation="CREATE", resource="coupon", selector="*", + ) + assert is_write_allowed(owned_perm, create_coupon) is False + + def test_delete_user_specific_with_enough_privilege(self): + "Tries to delete a specific user with specific policy" + delete_user = AccessRequest( + operation="DELETE", resource="user", selector="3dc4", + ) + owned_perm = { + "user": {"*": CREATE | UPDATE, "3dc4": DELETE}, + } + assert is_write_allowed(owned_perm, delete_user) is True + + update_user = AccessRequest( + operation="UPDATE", resource="user", selector="3dc4", + ) + + assert is_write_allowed(owned_perm, update_user) is True + + +# Test is_allowed() + + +# class TestIsPermissionAllowed: +# "Test if authorization calculations are working as expected" +# fn = is_allowed +# +# def test_tries_to_create_unauthorized(self): +# # user have no creation permissions +# input: List[Tuple[Permission, ...]] = [ +# READ,), +# READ, Permission.UPDATE), +# READ, Permission.UPDATE, Permission.DELETE), +# UPDATE, Permission.DELETE), +# ] +# for perm in input: +# assert is_allowed(perm, Permission.CREATE) is False +# +# def test_tries_to_update_anauthorized(self): +# # user have no update permission +# input: List[Tuple[Permission, ...]] = [ +# READ,), +# READ, Permission.CREATE), +# READ, Permission.CREATE, Permission.DELETE), +# CREATE, Permission.DELETE), +# ] +# for perm in input: +# assert is_allowed(perm, Permission.UPDATE) is False +# +# def test_tries_to_delete_anauthorized(self): +# # user have no delete permission +# input: List[Tuple[Permission, ...]] = [ +# READ,), +# READ, Permission.CREATE), +# READ, Permission.CREATE, Permission.UPDATE), +# CREATE, Permission.UPDATE), +# ] +# for perm in input: +# assert is_allowed(perm, Permission.DELETE) is False +# +# def test_tries_to_read_without_explicit_read_authorized(self): +# # user have only write permissions and tries to read +# input: List[Tuple[Permission, ...]] = [ +# CREATE,), +# UPDATE,), +# DELETE,), +# CREATE, Permission.DELETE), +# CREATE, Permission.UPDATE), +# DELETE, Permission.UPDATE), +# CREATE, Permission.DELETE, Permission.UPDATE), +# ] +# for perm in input: +# assert is_allowed(perm, Permission.READ) +# +# def test_tries_to_write_but_has_only_read(self): +# # user have only read permissions and tries to do all writes +# input: List[Permission] = [ +# Permission.CREATE, +# Permission.UPDATE, +# Permission.DELETE, +# ] +# for perm in input: +# assert is_allowed(READ,), perm) is False diff --git a/kingdom/access/types.py b/kingdom/access/types.py new file mode 100644 index 0000000..42e39c3 --- /dev/null +++ b/kingdom/access/types.py @@ -0,0 +1,11 @@ +from typing import Dict, List, Optional, Tuple + +ResourceAlias = str +Selector = str +PermissionInt = int +SelectorPermissionMap = Dict[Selector, PermissionInt] +PolicyContext = Dict[ResourceAlias, SelectorPermissionMap] + +Scope = List[Selector] +Payload = Dict +JWT = bytes diff --git a/kingdom/access/utils.py b/kingdom/access/utils.py deleted file mode 100644 index fefd435..0000000 --- a/kingdom/access/utils.py +++ /dev/null @@ -1,21 +0,0 @@ -import jwt -from graphql import GraphQLError - -from src.auth import config -from src.core.exceptions import ServerException - -# TODO This should be inside handlers -class InvalidToken(ServerException): - def __init__(self): - super().__init__("Invalid Token") - - -def parse_token(token: str) -> dict: - try: - return jwt.decode( - jwt=token, - key=config.get_jwt_secret_key(), - algorithms=config.JWT_ALGORITHM, - ) - except jwt.PyJWTError: - raise InvalidToken() From 81011290626158de4a8ecb68c6441670ac1b8661 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:38:02 -0300 Subject: [PATCH 19/32] Chore: Improve and fix a few docstrings and namings --- kingdom/access/base.py | 47 ++++++++++++++++++++++++------- kingdom/access/tests/test_base.py | 12 ++++---- 2 files changed, 43 insertions(+), 16 deletions(-) diff --git a/kingdom/access/base.py b/kingdom/access/base.py index 085c051..69cd8fa 100644 --- a/kingdom/access/base.py +++ b/kingdom/access/base.py @@ -85,12 +85,16 @@ class User: roles: List[Optional[Role]] @property - def encoded_policies(self) -> PolicyContext: + def policy_context(self) -> PolicyContext: + "Builds a policy context reading all roles associated to a User" + user_policies = list(self.resolve_policies()) return encode_policies(user_policies) @property def jwt_payload(self) -> Payload: + "Knows how to build a JWT Payload with necessary claims" + expiration = datetime.utcnow() + timedelta( minutes=config.TOKEN_EXPIRATION_MIN ) @@ -167,12 +171,12 @@ def build_redundant_context(policies: List[Policy]) -> PolicyContext: >>> a_policy = Policy( resource=Resource("Account"), permissions=(Permission.UPDATE, Permission.CREATE,), - conditionals=[Conditional("resource.id", "*")] + conditionals=[Statement("resource.id", "*")] ) >>> ya_policy = Policy( resource=Resource("Account"), permissions=(Permission.UPDATE), - conditionals=[Conditional("resource.id", "5f34")] + conditionals=[Statement("resource.id", "5f34")] ) >>> build_redundant_context([a_policy, ya_policy]) { @@ -215,11 +219,34 @@ def build_redundant_context(policies: List[Policy]) -> PolicyContext: def remove_context_redundancy(context: PolicyContext) -> PolicyContext: """ Given a redundant PolicyContext, remove any redundancy that might exist + For now, redundancies origins from having to deal with "*" token. - For now, redundancies origins is from having a special "*" token. + >>> a_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE, Permission.CREATE,), + conditionals=[Statement("resource.id", "*")] + ) + >>> ya_policy = Policy( + resource=Resource("Account"), + permissions=(Permission.UPDATE), + conditionals=[Statement("resource.id", "5f34")] + ) + >>> ctx = build_redundant_context([a_policy, ya_policy]) + >>> ctx + { + "account": { + "*": 3, + "5f34": 2, + } + } + >>> remove_context_redundancy(ctx) + { + "account": { + "*": 3, + } + } """ - # Now remove any redundant permission due to a possible "*" selector - # Brute-force. + # Brute-force. TODO: This could be cleaner (but perhaps less readable). simplified: PolicyContext = deepcopy(context) for resource, selector_perm in context.items(): @@ -234,7 +261,7 @@ def remove_context_redundancy(context: PolicyContext) -> PolicyContext: # Not this one. continue - updated_perms = permissions & ~selector_perm[TOKEN_ALL] + updated_perms: int = permissions & ~selector_perm[TOKEN_ALL] if updated_perms == 0: # All of this selector's permissions are already # contemplated by "*" @@ -255,12 +282,12 @@ def encode_policies(policies: List[Policy]) -> PolicyContext: >>> a_policy = Policy( resource=Resource("Account"), permissions=(Permission.UPDATE,), - conditionals=[Conditional("resource.id", "*")] + conditionals=[Statement("resource.id", "*")] ) >>> ya_policy = Policy( resource=Resource("Product"), permissions=(Permission.READ,), - conditionals=[Conditional("resource.id", "5f34")] + conditionals=[Statement("resource.id", "5f34")] ) >>> pack_policies([a_policy, ya_policy]) { @@ -272,7 +299,7 @@ def encode_policies(policies: List[Policy]) -> PolicyContext: }, } - # Which is the equivalent: + # Which is the equivalent of: >>> pack_policies([a_policy, ya_policy]) { "product": { diff --git a/kingdom/access/tests/test_base.py b/kingdom/access/tests/test_base.py index 43ffe6b..31dbaa5 100644 --- a/kingdom/access/tests/test_base.py +++ b/kingdom/access/tests/test_base.py @@ -62,12 +62,12 @@ def test_simple_policy_packing(): # A ficticious manager user = User("abbf", roles=[store_manager, site_manager]) - owned_perm = { + policy_ctx = { "product": {"*": 1, "ab4f": 2, "13fa": 2, }, "account": {"*": 0, "0bf3": 2, "bc0e": 2, }, } - assert user.encoded_policies == owned_perm + assert user.policy_context == policy_ctx def test_cumulative_policy_packing(): @@ -127,10 +127,10 @@ def test_cumulative_policy_packing(): # A ficticious supervisor user = User("abbf", roles=[store_manager, christmas_ops]) - owned_perm = { + policy_ctx = { "product": {"*": 1, "044e": 6, "0e0e": 2, "bc0e": 6, "aac0": 4, } } - assert user.encoded_policies == owned_perm + assert user.policy_context == policy_ctx def test_redundant_policy_packing(): @@ -171,8 +171,8 @@ def test_redundant_policy_packing(): sales_coord = User("0bf3", roles=[electronic_manager, store_manager]) - owned_perm = { + policy_ctx = { "product": {"*": 3, "7fb4": 4, "49f3": 4, "abc9": 4}, } - assert sales_coord.encoded_policies == owned_perm + assert sales_coord.policy_context == policy_ctx From 3e51b8bded71fb96f1c81906e196e99f61521bcd Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:40:55 -0300 Subject: [PATCH 20/32] Chore: Organizes and improves types cohesion --- kingdom/access/flow.py | 13 +++++++++---- kingdom/access/types.py | 8 ++++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index e37f970..86f9d57 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -3,12 +3,17 @@ from kingdom.access import jwt from kingdom.access.base import Optional, Permission, Resource -from kingdom.access.types import JWT, Payload, PolicyContext, Scope +from kingdom.access.types import ( + JWT, + AuthResponse, + Payload, + PolicyContext, + Scope, + UserKey, +) TOKEN_ALL = "*" -UserKey = str - class AccessRequest: operation: int @@ -38,7 +43,7 @@ def __init__(self, request: AccessRequest): ) -def authenticate(token: JWT) -> Tuple[PolicyContext, UserKey]: +def authenticate(token: JWT) -> AuthResponse: """ Raises an Exception if not authenticated """ diff --git a/kingdom/access/types.py b/kingdom/access/types.py index 42e39c3..e059342 100644 --- a/kingdom/access/types.py +++ b/kingdom/access/types.py @@ -1,11 +1,15 @@ from typing import Dict, List, Optional, Tuple +# Pure. ResourceAlias = str Selector = str PermissionInt = int +UserKey = str +JWT = bytes + +# Derived. SelectorPermissionMap = Dict[Selector, PermissionInt] PolicyContext = Dict[ResourceAlias, SelectorPermissionMap] - Scope = List[Selector] +AuthResponse = Tuple[Scope, UserKey] Payload = Dict -JWT = bytes From 31bcd180d70ed9b0b62980c10f2039f34c4ce29a Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:50:56 -0300 Subject: [PATCH 21/32] Chore: Further improves docstrings on permission checking --- kingdom/access/flow.py | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index 86f9d57..1bd4b5a 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -45,22 +45,33 @@ def __init__(self, request: AccessRequest): def authenticate(token: JWT) -> AuthResponse: """ - Raises an Exception if not authenticated + Checks if a subject, identified by a JWT, is a recognized and trusted + entity. + + Outputs: + AuthResponse, if subject is allowed in. + Exception, if credentials (JWT) are not accepted. """ payload = jwt.decode(token) return payload["policies"], payload["access_key"] def authorize( - policies: PolicyContext, resource: str, operation: str, selector: str = "", + context: PolicyContext, resource: str, operation: str, selector: str = "", ) -> Scope: """ - Raises an Exception if not authorized + Checks if, given a subject `context`, if system allows it to `operate` on + instance `selector` of `resource`. + + Outputs: + Scope, list of resource identifiers that subject is allowed to + perform asked operation. + Exception, if subject is not authorized to perform asked operation. """ request = AccessRequest( resource=resource, operation=operation, selector=selector ) - scope, authorized = check_permission(policies, request) + scope, authorized = check_permission(context, request) if not authorized: raise NotEnoughPrivilegesErr(request) return scope @@ -69,6 +80,19 @@ def authorize( def check_permission( owned_policies: PolicyContext, access_request: AccessRequest ) -> Tuple[Scope, bool]: + """ + When checking permissions, behaviour differs by the nature of requested + operation: + + If it's a + - READ, scope acts as access control, and passes to the system + which instances of given resource the subject is allowed to read. + Obs.: In this case, is_allowed is always True. + + - WRITE, is_allowed tells if user can perform that WRITE operation. Acts + as a circuit breaker, as it is a all-or-nothing operation. + Obs.: In this case, scope is always [access_request.selector] + """ if access_request.operation == Permission.READ.value: return get_read_scope(owned_policies, access_request), True return ( From b53153a1a6702268b54f394ca05b4dba522f4949 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:56:14 -0300 Subject: [PATCH 22/32] Chore: Removes dead code --- kingdom/access/tests/test_flow.py | 65 ------------------------------- 1 file changed, 65 deletions(-) diff --git a/kingdom/access/tests/test_flow.py b/kingdom/access/tests/test_flow.py index f486239..d01775a 100644 --- a/kingdom/access/tests/test_flow.py +++ b/kingdom/access/tests/test_flow.py @@ -285,68 +285,3 @@ def test_delete_user_specific_with_enough_privilege(self): ) assert is_write_allowed(owned_perm, update_user) is True - - -# Test is_allowed() - - -# class TestIsPermissionAllowed: -# "Test if authorization calculations are working as expected" -# fn = is_allowed -# -# def test_tries_to_create_unauthorized(self): -# # user have no creation permissions -# input: List[Tuple[Permission, ...]] = [ -# READ,), -# READ, Permission.UPDATE), -# READ, Permission.UPDATE, Permission.DELETE), -# UPDATE, Permission.DELETE), -# ] -# for perm in input: -# assert is_allowed(perm, Permission.CREATE) is False -# -# def test_tries_to_update_anauthorized(self): -# # user have no update permission -# input: List[Tuple[Permission, ...]] = [ -# READ,), -# READ, Permission.CREATE), -# READ, Permission.CREATE, Permission.DELETE), -# CREATE, Permission.DELETE), -# ] -# for perm in input: -# assert is_allowed(perm, Permission.UPDATE) is False -# -# def test_tries_to_delete_anauthorized(self): -# # user have no delete permission -# input: List[Tuple[Permission, ...]] = [ -# READ,), -# READ, Permission.CREATE), -# READ, Permission.CREATE, Permission.UPDATE), -# CREATE, Permission.UPDATE), -# ] -# for perm in input: -# assert is_allowed(perm, Permission.DELETE) is False -# -# def test_tries_to_read_without_explicit_read_authorized(self): -# # user have only write permissions and tries to read -# input: List[Tuple[Permission, ...]] = [ -# CREATE,), -# UPDATE,), -# DELETE,), -# CREATE, Permission.DELETE), -# CREATE, Permission.UPDATE), -# DELETE, Permission.UPDATE), -# CREATE, Permission.DELETE, Permission.UPDATE), -# ] -# for perm in input: -# assert is_allowed(perm, Permission.READ) -# -# def test_tries_to_write_but_has_only_read(self): -# # user have only read permissions and tries to do all writes -# input: List[Permission] = [ -# Permission.CREATE, -# Permission.UPDATE, -# Permission.DELETE, -# ] -# for perm in input: -# assert is_allowed(READ,), perm) is False From f6fc13235dd68fdc9aa7288b5a7cdc28e55643bd Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Fri, 16 Apr 2021 14:59:16 -0300 Subject: [PATCH 23/32] Chore: Centers TOKEN_ALL in its proper DSL module --- kingdom/access/base.py | 3 +-- kingdom/access/dsl.py | 13 ++++++------- kingdom/access/flow.py | 3 +-- 3 files changed, 8 insertions(+), 11 deletions(-) diff --git a/kingdom/access/base.py b/kingdom/access/base.py index 69cd8fa..7cce86f 100644 --- a/kingdom/access/base.py +++ b/kingdom/access/base.py @@ -6,10 +6,9 @@ from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union from kingdom.access import config +from kingdom.access.dsl import TOKEN_ALL from kingdom.access.types import Payload, PolicyContext -TOKEN_ALL = "*" - class Permission(Enum): """Fine-grained mapping of a permission operation.""" diff --git a/kingdom/access/dsl.py b/kingdom/access/dsl.py index 9e87af4..c04a939 100644 --- a/kingdom/access/dsl.py +++ b/kingdom/access/dsl.py @@ -28,6 +28,10 @@ import string from typing import List +TOKEN_ALL = "*" +VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} +VALID_OPS_TOKEN = {token for operator in VALID_OPS for token in operator} + def conditionals_split(sequence: str): expressions = sequence.split("||") @@ -63,10 +67,6 @@ def parse_identifier(expression): return (False,) -VALID_OPS = {"==", ">", "<", ">=", "<=", "!="} -VALID_OPS_TOKEN = {token for operator in VALID_OPS for token in operator} - - def parse_reference(reference_expr): reference = "" parsing_idx = -1 @@ -148,13 +148,12 @@ def parse_operator(operator_expr): def parse_selector(selector_expr): - ALL_TOKEN = "*" selector_expr = selector_expr.strip() selector = "" parsing_idx = -1 def isselector(token): - return token.isnumeric() or token.isidentifier() or token == ALL_TOKEN + return token.isnumeric() or token.isidentifier() or token == TOKEN_ALL # First we try to find a selector. for idx, token in enumerate(selector_expr): @@ -174,7 +173,7 @@ def isselector(token): return (False,) # Edge case: are we dealig with an *? - if ALL_TOKEN in selector and selector != "*": + if TOKEN_ALL in selector and selector != TOKEN_ALL: # plain comparison return (False,) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index 1bd4b5a..7fed9c1 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -3,6 +3,7 @@ from kingdom.access import jwt from kingdom.access.base import Optional, Permission, Resource +from kingdom.access.dsl import TOKEN_ALL from kingdom.access.types import ( JWT, AuthResponse, @@ -12,8 +13,6 @@ UserKey, ) -TOKEN_ALL = "*" - class AccessRequest: operation: int From cdb2007d404bfd1bc294ce60355c50c04ff44cd2 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 16:53:47 -0300 Subject: [PATCH 24/32] Chore: Simplifies context mapping --- kingdom/access/base.py | 43 ++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/kingdom/access/base.py b/kingdom/access/base.py index 7cce86f..5a2a6fa 100644 --- a/kingdom/access/base.py +++ b/kingdom/access/base.py @@ -1,3 +1,4 @@ +from collections import defaultdict from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timedelta @@ -7,7 +8,7 @@ from kingdom.access import config from kingdom.access.dsl import TOKEN_ALL -from kingdom.access.types import Payload, PolicyContext +from kingdom.access.types import Payload, PolicyContext, SelectorPermissionMap class Permission(Enum): @@ -174,7 +175,7 @@ def build_redundant_context(policies: List[Policy]) -> PolicyContext: ) >>> ya_policy = Policy( resource=Resource("Account"), - permissions=(Permission.UPDATE), + permissions=(Permission.UPDATE,), conditionals=[Statement("resource.id", "5f34")] ) >>> build_redundant_context([a_policy, ya_policy]) @@ -188,31 +189,33 @@ def build_redundant_context(policies: List[Policy]) -> PolicyContext: Note that "5f34" entry is redundant because "*" selector already contemplates this statement condition. """ - owned: PolicyContext = {} - # Iterative approach: on every iteration we keep on # building output dictionary + def pivot_policy( + context: PolicyContext, current_resource: str, policy: Policy + ) -> SelectorPermissionMap: + """Pivots a Policy into a dictionary of Selector: Permission + It updates Permission value with context's permissions""" + return { + conditional.selector: union_permissions( + permissions, + context[current_resource].get(conditional.selector, tuple()), + ) + for conditional in policy.conditionals + } + + context: PolicyContext = {} + for policy in policies: + # Aliasing: resource = policy.resource.alias permissions: PermissionTuple = policy.permissions - if resource not in owned: - owned[resource] = {} - - # Iterate on every conditional - for conditional in policy.conditionals: - selector = conditional.selector - # There are two kinds of selectors: specific and generics. - # And a selector already exist for a given resource or it doesn't. - if selector in owned[resource]: - existing_permissions: int = owned[resource][selector] - else: - existing_permissions: Tuple = tuple() + if resource not in context: + context[resource] = defaultdict(dict) - owned[resource][selector] = union_permissions( - permissions, existing_permissions - ) + context[resource].update(pivot_policy(context, resource, policy)) - return owned + return context def remove_context_redundancy(context: PolicyContext) -> PolicyContext: From 494922ac8d0b4347a5eb267a4c50cf3d266fa481 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 17:22:22 -0300 Subject: [PATCH 25/32] Feat: Adds a test case for unmapped Permission Enum --- kingdom/access/tests/test_base.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/kingdom/access/tests/test_base.py b/kingdom/access/tests/test_base.py index 31dbaa5..a700071 100644 --- a/kingdom/access/tests/test_base.py +++ b/kingdom/access/tests/test_base.py @@ -102,7 +102,7 @@ def test_cumulative_policy_packing(): ) ya_christmas_policy = Policy( resource=Resource("Product"), - permissions=(Permission.DELETE,), + permissions=(Permission.DELETE, Permission.UPDATE), conditionals=[Statement("resource.id", "044e"), ], ) @@ -142,8 +142,15 @@ def test_redundant_policy_packing(): permissions=(Permission.CREATE, Permission.UPDATE), conditionals=[Statement("resource.id", "*"), ], ) + product_maint = Policy( + resource=Resource("Product"), + permissions=(Permission.UPDATE,), + conditionals=[Statement("resource.id", "*")], + ) - store_manager = Role("Store management group", policies=[product_policy]) + store_manager = Role( + "Store management group", policies=[product_policy, product_maint] + ) ya_product_policy = Policy( resource=Resource("Product"), From af79e5dc55629b796e5bfcdb0e83c08c437dab9f Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 17:22:50 -0300 Subject: [PATCH 26/32] Refactor: Permission union now uses raw integers --- kingdom/access/base.py | 52 +++++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/kingdom/access/base.py b/kingdom/access/base.py index 5a2a6fa..9e2bfe5 100644 --- a/kingdom/access/base.py +++ b/kingdom/access/base.py @@ -114,24 +114,33 @@ def resolve_policies(self): PermissionTupleOrInt = Union[PermissionTuple, int] -def itop(permissions: PermissionTupleOrInt) -> PermissionTuple: +def ptoi(permissions: PermissionTupleOrInt) -> int: """ Considering that a permission can be both an integer and an instance of Permission, this function ensures that operations are done with a Permission type. It does by translating integer to Permission whenever needed. - >>> atoi_permission(0) - (Permission.READ,) - >>> atoi_permission((Permission.CREATE, Permission.READ)) - (Permission.CREATE, Permission.READ) + >>> ptoi(0) + 0 + >>> ptoi((Permission.CREATE, Permission.READ, Permission.UPDATE)) + 3 + >>> ptoi((,)) + 0 """ if isinstance(permissions, int): - return (Permission(permissions),) - return permissions + return permissions + + return ( + reduce(lambda a, b: a | b, permissions) + if len(permissions) > 1 + else permissions[0].value + if len(permissions) == 1 + else 0 + ) -def union_permissions( +def union( permissions: PermissionTupleOrInt, incoming_permissions: PermissionTupleOrInt, ) -> int: @@ -147,17 +156,7 @@ def union_permissions( >>> union_permissions((Permission.CREATE,), (Permission.CREATE,)) 1 """ - current: PermissionTuple = itop(permissions) - incoming: PermissionTuple = itop(incoming_permissions) - - updated: Set[Permission] = set(current) | set(incoming) - perms: AnyPermission = reduce(lambda a, b: a | b, updated) - - if isinstance(perms, Permission): - # If updated has only one element, reduce will output the element - # itself. Hence we need to get its value - return int(perms.value) - return perms + return ptoi(permissions) | ptoi(incoming_permissions) def build_redundant_context(policies: List[Policy]) -> PolicyContext: @@ -189,17 +188,16 @@ def build_redundant_context(policies: List[Policy]) -> PolicyContext: Note that "5f34" entry is redundant because "*" selector already contemplates this statement condition. """ - # Iterative approach: on every iteration we keep on - # building output dictionary + def pivot_policy( - context: PolicyContext, current_resource: str, policy: Policy + current_mapping: SelectorPermissionMap, policy: Policy ) -> SelectorPermissionMap: - """Pivots a Policy into a dictionary of Selector: Permission + """Pivots a Policy into a map of Selector: PermissionInt It updates Permission value with context's permissions""" return { - conditional.selector: union_permissions( + conditional.selector: union( permissions, - context[current_resource].get(conditional.selector, tuple()), + current_mapping.get(conditional.selector, tuple()), ) for conditional in policy.conditionals } @@ -213,7 +211,7 @@ def pivot_policy( if resource not in context: context[resource] = defaultdict(dict) - context[resource].update(pivot_policy(context, resource, policy)) + context[resource].update(pivot_policy(context[resource], policy)) return context @@ -248,7 +246,6 @@ def remove_context_redundancy(context: PolicyContext) -> PolicyContext: } } """ - # Brute-force. TODO: This could be cleaner (but perhaps less readable). simplified: PolicyContext = deepcopy(context) for resource, selector_perm in context.items(): @@ -256,7 +253,6 @@ def remove_context_redundancy(context: PolicyContext) -> PolicyContext: # Well, nothing to do then. continue - # all_token_perms = set(selector_perm[TOKEN_ALL]) for selector, permissions in selector_perm.items(): # Subtract "*"'s permissions from the other permissions if selector == TOKEN_ALL: From 8ffb99954765e68ee6f6432cdaabad402aeb21da Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 17:24:17 -0300 Subject: [PATCH 27/32] Fix: Semantic typo --- kingdom/access/tests/test_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kingdom/access/tests/test_flow.py b/kingdom/access/tests/test_flow.py index d01775a..43f7043 100644 --- a/kingdom/access/tests/test_flow.py +++ b/kingdom/access/tests/test_flow.py @@ -230,7 +230,7 @@ def test_create_coupon_without_create_all_policy(self): def test_delete_user_without_enough_policies(self): "User tries to delete user without specific policy" delete_user = AccessRequest( - operation="UPDATE", resource="user", selector="ffc0", + operation="DELETE", resource="user", selector="ffc0", ) owned_perm = { "coupon": {"*": READ | CREATE | UPDATE | DELETE}, From f3b1ea5241aadaa524662ff52893f40e1348281e Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 17:24:59 -0300 Subject: [PATCH 28/32] Refactor: Inline statement using OR --- kingdom/access/flow.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index 7fed9c1..55ef029 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -21,9 +21,7 @@ class AccessRequest: def __init__(self, operation: str, resource: str, selector: str) -> None: self.resource = resource - self.selector = selector - if not selector: - self.selector = TOKEN_ALL + self.selector = selector or TOKEN_ALL self.__operation = getattr(Permission, operation) self.operation = self.__operation.value From 28718e357656b7196a338b5a550a221107325b89 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 17:33:25 -0300 Subject: [PATCH 29/32] Refactor: Greatly simplifies write checking conditions --- kingdom/access/flow.py | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index 55ef029..3003273 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -175,8 +175,7 @@ def is_write_allowed( def mask_pass(owned_permissions: int, requested_operation: int,) -> bool: """ - When a subject tries to perform a write, this function calculates - whether the requested operation is allowed on a set of owned + Calculates whether the requested operation is allowed on a set of owned permissions. >>> owned_perm = ( @@ -186,23 +185,19 @@ def mask_pass(owned_permissions: int, requested_operation: int,) -> bool: >>> is_allowed(owned_perm, requested_op) False """ - return int(owned_permissions & requested_operation) > 0 + return bool(owned_permissions & requested_operation) - TOKEN_ALL = "*" assert access_request.operation & ( Permission.CREATE | Permission.UPDATE | Permission.DELETE ) resource = access_request.resource operation = access_request.operation - selector = access_request.selector if resource not in owned_policies: # Resource is unknown to subject. return False - if selector == TOKEN_ALL and selector not in owned_policies[resource]: - # For e.g CREATE without CREATE ALL policy - return False + permissions = owned_policies[resource].get(access_request.selector, 0) if TOKEN_ALL in owned_policies[resource]: # We might be asking for a specific instance but we have a "*" policy @@ -210,10 +205,4 @@ def mask_pass(owned_permissions: int, requested_operation: int,) -> bool: if mask_pass(owned_policies[resource][TOKEN_ALL], operation): return True - # Now that we've reached here, it means that subject has no "*" policy - # to allow it for asked resource. Hence it must have a specific policy - if selector not in owned_policies[resource]: - return False - - permissions = owned_policies[resource][selector] return mask_pass(permissions, operation) From 0ffe1c024142c6a610de8bf7f3e0bb734b9e6579 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 18:01:18 -0300 Subject: [PATCH 30/32] Refactor: Tests whether an empty scope raises UnauthorizedException --- kingdom/access/tests/test_flow.py | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/kingdom/access/tests/test_flow.py b/kingdom/access/tests/test_flow.py index 43f7043..adfb40a 100644 --- a/kingdom/access/tests/test_flow.py +++ b/kingdom/access/tests/test_flow.py @@ -21,14 +21,15 @@ class TestAuthorize: fn = authorize - def test_user_tries_to_read(self): + def test_user_tries_to_read_everything(self): user_policies: PolicyContext = { "user": {"00df": READ | UPDATE}, "product": {"*": READ}, } - # Purposefully don't pass a selector - scope = authorize(user_policies, resource="coupon", operation="READ") - assert scope == [] + # Purposefully don't pass a selector, meaning it's trying to read + # everything. But user can't see anything from coupons. + with raises(NotEnoughPrivilegesErr): + _ = authorize(user_policies, resource="coupon", operation="READ") scope = authorize(user_policies, resource="user", operation="READ") assert scope == ["00df"] @@ -36,6 +37,24 @@ def test_user_tries_to_read(self): scope = authorize(user_policies, resource="product", operation="READ") assert scope == ["*"] + def test_user_tries_to_read_specifics(self): + user_policies: PolicyContext = { + "coupon": {"00df": READ, "ddf9": READ, "ddfc": READ, "fcc3": READ}, + } + + with raises(NotEnoughPrivilegesErr): + scope = authorize( + user_policies, + resource="coupon", + operation="READ", + selector="d3f4", + ) + + scope = authorize( + user_policies, resource="coupon", operation="READ", selector="fcc3" + ) + assert scope == ["fcc3"] + def test_user_tries_to_write_on_specific_policies(self): user_id = "00df" user_policies: PolicyContext = { From 5b60a12883e0e6ad1177c33c42f56156a4d6dbbd Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 18:01:47 -0300 Subject: [PATCH 31/32] Refactor: An empty selector now raises UnauthorizedException --- kingdom/access/flow.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/kingdom/access/flow.py b/kingdom/access/flow.py index 3003273..2a38a3a 100644 --- a/kingdom/access/flow.py +++ b/kingdom/access/flow.py @@ -10,6 +10,7 @@ Payload, PolicyContext, Scope, + SelectorPermissionMap, UserKey, ) @@ -91,7 +92,8 @@ def check_permission( Obs.: In this case, scope is always [access_request.selector] """ if access_request.operation == Permission.READ.value: - return get_read_scope(owned_policies, access_request), True + scope = get_read_scope(owned_policies, access_request) + return scope, len(scope) > 0 return ( [access_request.selector], is_write_allowed(owned_policies, access_request), @@ -130,19 +132,22 @@ def get_read_scope( # Sanity check assert access_request.operation == Permission.READ.value + selector = access_request.selector resource = access_request.resource if resource not in owned_policies: # Subject has no permission related to requested resource. return [] - # Subject has at least one selector that it can read. - if TOKEN_ALL in owned_policies[resource]: - # If it has any binding to "*", then it can read it all. - return [TOKEN_ALL] + owned_selectors: SelectorPermissionMap = owned_policies[resource] + if selector == TOKEN_ALL: + # If it has an entry, it is allowed to read it. + return ( + [TOKEN_ALL] + if TOKEN_ALL in owned_selectors + else list(owned_selectors.keys()) + ) - # Subject has specific selectors, we shall return them. - allowed_ids = owned_policies[resource].keys() - return list(allowed_ids) + return list(filter(lambda s: s == selector, owned_selectors)) def is_write_allowed( From 4c979ffb8799072be05a311d5809d02a88638c53 Mon Sep 17 00:00:00 2001 From: Rui Conti Date: Tue, 20 Apr 2021 18:12:55 -0300 Subject: [PATCH 32/32] Fix: Adds a missing corner case for conditional splitting --- kingdom/access/dsl.py | 2 +- kingdom/access/tests/test_dsl.py | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/kingdom/access/dsl.py b/kingdom/access/dsl.py index c04a939..2fbc35d 100644 --- a/kingdom/access/dsl.py +++ b/kingdom/access/dsl.py @@ -35,7 +35,7 @@ def conditionals_split(sequence: str): expressions = sequence.split("||") - if "" in expressions: + if "" in expressions or " " in expressions: # meaning that we had a loose OR return False diff --git a/kingdom/access/tests/test_dsl.py b/kingdom/access/tests/test_dsl.py index 0026c38..c06a218 100644 --- a/kingdom/access/tests/test_dsl.py +++ b/kingdom/access/tests/test_dsl.py @@ -11,14 +11,17 @@ def test_conditionals_split(): input = [ - "expr || newexpr", - "expr||newexpr", - " expr || newexpr ", - "expr|newexpr", - "expr || newexpr||neewexpr||", - "ex pr || new expr || ", - " expr ||| newexpr ", - " expr|||newexpr ", + "expr || newexpr", # valid + "expr||newexpr", # valid + " expr || newexpr ", # valid + " expr || ", # invalid + " || expr ", # invalid + " || expr || ", # invalid + "expr|newexpr", # invalid + "expr || newexpr||neewexpr||", # invalid + "ex pr || new expr || ", # invalid + " expr ||| newexpr ", # invalid + " expr|||newexpr ", # invalid ] want = [ @@ -30,6 +33,9 @@ def test_conditionals_split(): False, False, False, + False, + False, + False, ] got = [conditionals_split(sequence) for sequence in input]