kdex-entitlements 0.1.19__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kdex_entitlements-0.1.19/Makefile +21 -0
- kdex_entitlements-0.1.19/PKG-INFO +8 -0
- kdex_entitlements-0.1.19/pyproject.toml +26 -0
- kdex_entitlements-0.1.19/setup.cfg +4 -0
- kdex_entitlements-0.1.19/src/entitlements/__init__.py +115 -0
- kdex_entitlements-0.1.19/src/kdex_entitlements.egg-info/PKG-INFO +8 -0
- kdex_entitlements-0.1.19/src/kdex_entitlements.egg-info/SOURCES.txt +9 -0
- kdex_entitlements-0.1.19/src/kdex_entitlements.egg-info/dependency_links.txt +1 -0
- kdex_entitlements-0.1.19/src/kdex_entitlements.egg-info/requires.txt +4 -0
- kdex_entitlements-0.1.19/src/kdex_entitlements.egg-info/top_level.txt +1 -0
- kdex_entitlements-0.1.19/tests/test_entitlements.py +67 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
VENV = .venv
|
|
2
|
+
VENV_BIN = $(VENV)/bin
|
|
3
|
+
|
|
4
|
+
.PHONY: all
|
|
5
|
+
all: test
|
|
6
|
+
|
|
7
|
+
.PHONY: test
|
|
8
|
+
test: $(VENV) ## Run Python tests.
|
|
9
|
+
$(VENV_BIN)/pytest
|
|
10
|
+
|
|
11
|
+
.PHONY: coverage
|
|
12
|
+
coverage: $(VENV) ## Run Python tests with coverage.
|
|
13
|
+
$(VENV_BIN)/pytest --cov=entitlements --cov-report=term-missing
|
|
14
|
+
|
|
15
|
+
.PHONY: lint
|
|
16
|
+
lint: $(VENV) ## Run Python linter (ruff).
|
|
17
|
+
$(VENV_BIN)/ruff check .
|
|
18
|
+
|
|
19
|
+
$(VENV):
|
|
20
|
+
python3 -m venv $(VENV)
|
|
21
|
+
$(VENV_BIN)/pip install pytest pytest-cov
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "kdex-entitlements"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "A simple entitlements checking library"
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "KDEX Tech"}
|
|
7
|
+
]
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
[build-system]
|
|
11
|
+
requires = ["setuptools>=61.0", "setuptools_scm[toml]>=6.2"]
|
|
12
|
+
build-backend = "setuptools.build_meta"
|
|
13
|
+
|
|
14
|
+
[tool.setuptools_scm]
|
|
15
|
+
fallback_version = "0.0.0"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
test = [
|
|
20
|
+
"pytest>=8.0.0",
|
|
21
|
+
"pytest-cov>=4.1.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.pytest.ini_options]
|
|
25
|
+
testpaths = ["tests"]
|
|
26
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from typing import Dict, List, Optional, Union
|
|
2
|
+
import dataclasses
|
|
3
|
+
|
|
4
|
+
# Types
|
|
5
|
+
SecurityScheme = str
|
|
6
|
+
Entitlements = Dict[SecurityScheme, List[str]]
|
|
7
|
+
RequirementSet = Dict[SecurityScheme, List[str]]
|
|
8
|
+
Requirements = List[RequirementSet]
|
|
9
|
+
|
|
10
|
+
@dataclasses.dataclass(frozen=True)
|
|
11
|
+
class Pattern:
|
|
12
|
+
"""Represents a parsed entitlement or requirement pattern."""
|
|
13
|
+
resource: Optional[str] = None
|
|
14
|
+
name: Optional[str] = None
|
|
15
|
+
verb: Optional[str] = None
|
|
16
|
+
opaque: Optional[str] = None
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def parse(cls, s: str) -> "Pattern":
|
|
20
|
+
parts = s.split(":")
|
|
21
|
+
if len(parts) == 3:
|
|
22
|
+
return cls(resource=parts[0], name=parts[1], verb=parts[2])
|
|
23
|
+
elif len(parts) == 2:
|
|
24
|
+
return cls(resource=parts[0], name="*", verb=parts[1])
|
|
25
|
+
else:
|
|
26
|
+
return cls(opaque=s)
|
|
27
|
+
|
|
28
|
+
def satisfies(self, required: "Pattern") -> bool:
|
|
29
|
+
# Both opaque: must match exactly
|
|
30
|
+
if self.opaque is not None and required.opaque is not None:
|
|
31
|
+
return self.opaque == required.opaque
|
|
32
|
+
|
|
33
|
+
# Mixed: no match unless strings are identical (unlikely given parse)
|
|
34
|
+
if (self.opaque is not None) != (required.opaque is not None):
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
# Structured:
|
|
38
|
+
# Resource must match
|
|
39
|
+
if self.resource != required.resource:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
# Verb must match exactly or entitlement is "all"
|
|
43
|
+
if self.verb != required.verb and self.verb != "all":
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
# Name must match exactly, or either is a wildcard
|
|
47
|
+
if self.name != required.name and self.name not in ("*", "") and required.name not in ("*", ""):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
return True
|
|
51
|
+
|
|
52
|
+
class EntitlementsChecker:
|
|
53
|
+
def __init__(self, anonymous_entitlements: Optional[List[str]] = None, default_scheme: str = "bearer"):
|
|
54
|
+
self.anonymous_entitlements = anonymous_entitlements or []
|
|
55
|
+
self.default_scheme = default_scheme
|
|
56
|
+
|
|
57
|
+
def verify(self, user_entitlements: Entitlements, requirements: Requirements) -> bool:
|
|
58
|
+
if not requirements:
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
# Merge with anonymous
|
|
62
|
+
merged = {k: list(v) for k, v in user_entitlements.items()}
|
|
63
|
+
if self.anonymous_entitlements:
|
|
64
|
+
scheme_list = merged.setdefault(self.default_scheme, [])
|
|
65
|
+
for anon in self.anonymous_entitlements:
|
|
66
|
+
if anon not in scheme_list:
|
|
67
|
+
scheme_list.append(anon)
|
|
68
|
+
|
|
69
|
+
# Pre-parse entitlements
|
|
70
|
+
parsed_entitlements: Dict[SecurityScheme, List[Pattern]] = {
|
|
71
|
+
scheme: [Pattern.parse(e) for e in entries]
|
|
72
|
+
for scheme, entries in merged.items()
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# OR logic for requirements list
|
|
76
|
+
for req_set in requirements:
|
|
77
|
+
if self._verify_set(parsed_entitlements, req_set):
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
def _verify_set(self, user_patterns: Dict[SecurityScheme, List[Pattern]], req_set: RequirementSet) -> bool:
|
|
83
|
+
# AND logic for a requirement set
|
|
84
|
+
for scheme, required_patterns in req_set.items():
|
|
85
|
+
user_list = user_patterns.get(scheme)
|
|
86
|
+
if user_list is None:
|
|
87
|
+
return False
|
|
88
|
+
|
|
89
|
+
for req_str in required_patterns:
|
|
90
|
+
req_p = Pattern.parse(req_str)
|
|
91
|
+
if not any(user_p.satisfies(req_p) for user_p in user_list):
|
|
92
|
+
return False
|
|
93
|
+
return True
|
|
94
|
+
|
|
95
|
+
def verify_resource(
|
|
96
|
+
self,
|
|
97
|
+
user_entitlements: Entitlements,
|
|
98
|
+
resource: str,
|
|
99
|
+
name: str,
|
|
100
|
+
verb: str,
|
|
101
|
+
additional_requirements: Optional[Requirements] = None
|
|
102
|
+
) -> bool:
|
|
103
|
+
identity_req = f"{resource}:{name}:{verb}"
|
|
104
|
+
|
|
105
|
+
if not additional_requirements:
|
|
106
|
+
return self.verify(user_entitlements, [{self.default_scheme: [identity_req]}])
|
|
107
|
+
|
|
108
|
+
# Identity AND (additional requirements OR...)
|
|
109
|
+
combined: Requirements = []
|
|
110
|
+
for req_set in additional_requirements:
|
|
111
|
+
new_set = dict(req_set)
|
|
112
|
+
new_set.setdefault(self.default_scheme, []).append(identity_req)
|
|
113
|
+
combined.append(new_set)
|
|
114
|
+
|
|
115
|
+
return self.verify(user_entitlements, combined)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Makefile
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/entitlements/__init__.py
|
|
4
|
+
src/kdex_entitlements.egg-info/PKG-INFO
|
|
5
|
+
src/kdex_entitlements.egg-info/SOURCES.txt
|
|
6
|
+
src/kdex_entitlements.egg-info/dependency_links.txt
|
|
7
|
+
src/kdex_entitlements.egg-info/requires.txt
|
|
8
|
+
src/kdex_entitlements.egg-info/top_level.txt
|
|
9
|
+
tests/test_entitlements.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
entitlements
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from entitlements import EntitlementsChecker, Pattern
|
|
3
|
+
|
|
4
|
+
def test_pattern_parse():
|
|
5
|
+
assert Pattern.parse("pages:/foo:read") == Pattern(resource="pages", name="/foo", verb="read")
|
|
6
|
+
assert Pattern.parse("pages:read") == Pattern(resource="pages", name="*", verb="read")
|
|
7
|
+
assert Pattern.parse("admin") == Pattern(opaque="admin")
|
|
8
|
+
|
|
9
|
+
def test_pattern_satisfies():
|
|
10
|
+
ent = Pattern.parse("pages:*:read")
|
|
11
|
+
req = Pattern.parse("pages:/foo:read")
|
|
12
|
+
assert ent.satisfies(req)
|
|
13
|
+
|
|
14
|
+
ent = Pattern.parse("pages:all")
|
|
15
|
+
req = Pattern.parse("pages:/foo:read")
|
|
16
|
+
assert ent.satisfies(req)
|
|
17
|
+
|
|
18
|
+
ent = Pattern.parse("pages:/bar:read")
|
|
19
|
+
req = Pattern.parse("pages:/foo:read")
|
|
20
|
+
assert not ent.satisfies(req)
|
|
21
|
+
|
|
22
|
+
ent = Pattern.parse("admin")
|
|
23
|
+
req = Pattern.parse("admin")
|
|
24
|
+
assert ent.satisfies(req)
|
|
25
|
+
|
|
26
|
+
def test_verify():
|
|
27
|
+
checker = EntitlementsChecker(anonymous_entitlements=["anonymous:read"], default_scheme="bearer")
|
|
28
|
+
|
|
29
|
+
user_entitlements = {"bearer": ["pages:foo:read"]}
|
|
30
|
+
|
|
31
|
+
# Simple match
|
|
32
|
+
requirements = [{"bearer": ["pages:foo:read"]}]
|
|
33
|
+
assert checker.verify(user_entitlements, requirements)
|
|
34
|
+
|
|
35
|
+
# Wildcard match
|
|
36
|
+
requirements = [{"bearer": ["pages:*:read"]}]
|
|
37
|
+
assert checker.verify(user_entitlements, requirements)
|
|
38
|
+
|
|
39
|
+
# Anonymous match
|
|
40
|
+
requirements = [{"bearer": ["anonymous:*:read"]}]
|
|
41
|
+
assert checker.verify(user_entitlements, requirements)
|
|
42
|
+
|
|
43
|
+
# Failing match
|
|
44
|
+
requirements = [{"bearer": ["pages:foo:write"]}]
|
|
45
|
+
assert not checker.verify(user_entitlements, requirements)
|
|
46
|
+
|
|
47
|
+
# OR match
|
|
48
|
+
requirements = [
|
|
49
|
+
{"bearer": ["pages:foo:write"]},
|
|
50
|
+
{"bearer": ["pages:foo:read"]}
|
|
51
|
+
]
|
|
52
|
+
assert checker.verify(user_entitlements, requirements)
|
|
53
|
+
|
|
54
|
+
def test_verify_resource():
|
|
55
|
+
checker = EntitlementsChecker(default_scheme="bearer")
|
|
56
|
+
user_entitlements = {"bearer": ["pages:foo:read", "admin"]}
|
|
57
|
+
|
|
58
|
+
# Identity check
|
|
59
|
+
assert checker.verify_resource(user_entitlements, "pages", "foo", "read")
|
|
60
|
+
assert not checker.verify_resource(user_entitlements, "pages", "bar", "read")
|
|
61
|
+
|
|
62
|
+
# Identity + additional requirements
|
|
63
|
+
additional = [{"bearer": ["admin"]}]
|
|
64
|
+
assert checker.verify_resource(user_entitlements, "pages", "foo", "read", additional)
|
|
65
|
+
|
|
66
|
+
additional = [{"bearer": ["superadmin"]}]
|
|
67
|
+
assert not checker.verify_resource(user_entitlements, "pages", "foo", "read", additional)
|