store-rbac-bootstrap-py 0.1.0__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.
- store_rbac_bootstrap_py-0.1.0/PKG-INFO +10 -0
- store_rbac_bootstrap_py-0.1.0/pyproject.toml +21 -0
- store_rbac_bootstrap_py-0.1.0/setup.cfg +4 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap/__init__.py +219 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap_py.egg-info/PKG-INFO +10 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap_py.egg-info/SOURCES.txt +10 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap_py.egg-info/dependency_links.txt +1 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap_py.egg-info/requires.txt +4 -0
- store_rbac_bootstrap_py-0.1.0/src/store_rbac_bootstrap_py.egg-info/top_level.txt +1 -0
- store_rbac_bootstrap_py-0.1.0/tests/test_conformance.py +119 -0
- store_rbac_bootstrap_py-0.1.0/tests/test_identity.py +134 -0
- store_rbac_bootstrap_py-0.1.0/tests/test_policy.py +130 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-rbac-bootstrap-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,rbac,bootstrap,context,jwt,skin
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: PyJWT>=2.8
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "store-rbac-bootstrap-py"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql"
|
|
5
|
+
requires-python = ">=3.10"
|
|
6
|
+
license = { text = "MIT" }
|
|
7
|
+
keywords = ["py-store", "rbac", "bootstrap", "context", "jwt", "skin"]
|
|
8
|
+
dependencies = ["PyJWT>=2.8"]
|
|
9
|
+
|
|
10
|
+
[project.optional-dependencies]
|
|
11
|
+
dev = ["pytest>=8"]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["setuptools>=68"]
|
|
15
|
+
build-backend = "setuptools.build_meta"
|
|
16
|
+
|
|
17
|
+
[tool.setuptools.packages.find]
|
|
18
|
+
where = ["src"]
|
|
19
|
+
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""store-rbac-bootstrap-py — RBAC 皮肤层插件包(第一期消费面)。
|
|
2
|
+
|
|
3
|
+
唯一事实源:spec/00-protocol.md。零判决、零语义发明、零宿主依赖。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import urllib.request
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from types import SimpleNamespace
|
|
12
|
+
|
|
13
|
+
import jwt as _jwt # PyJWT
|
|
14
|
+
|
|
15
|
+
_ALLOWED_KEYS = ("policy", "exemptRoles", "denyWriteRoles", "unconfiguredPolicy")
|
|
16
|
+
_FACADES = ("set_rbac", "set_exempt_roles", "set_deny_write_roles", "set_unconfigured_policy")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _call(fn, arg):
|
|
20
|
+
"""门面调用:sync 直接调;返回 awaitable(宿主支持 async 门面时)则 await。"""
|
|
21
|
+
result = fn(arg)
|
|
22
|
+
if inspect.isawaitable(result):
|
|
23
|
+
result = await result
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def file(path): # 策略源 ①:本地文件
|
|
28
|
+
async def source():
|
|
29
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
30
|
+
return source
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def url(u, headers=None): # 策略源 ②:远端策略分发服务
|
|
34
|
+
async def source():
|
|
35
|
+
req = urllib.request.Request(u, headers=headers or {})
|
|
36
|
+
with urllib.request.urlopen(req) as resp:
|
|
37
|
+
if not (200 <= resp.status < 300):
|
|
38
|
+
raise ValueError(f"策略源请求失败: {resp.status} {u}")
|
|
39
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
40
|
+
return source
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def db(loader): # 策略源 ③:DB / 配置中心
|
|
44
|
+
async def source():
|
|
45
|
+
result = loader()
|
|
46
|
+
return await result if inspect.isawaitable(result) else result
|
|
47
|
+
return source
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _validate(config):
|
|
51
|
+
"""校验先于应用;任一不符即抛 ValueError(此时零门面调用)。"""
|
|
52
|
+
if not isinstance(config, dict):
|
|
53
|
+
raise ValueError("引导配置必须是 JSON 对象")
|
|
54
|
+
unknown = [k for k in config if k not in _ALLOWED_KEYS]
|
|
55
|
+
if unknown:
|
|
56
|
+
raise ValueError(f"引导配置含未知键: {', '.join(unknown)}")
|
|
57
|
+
for key in ("exemptRoles", "denyWriteRoles"):
|
|
58
|
+
v = config.get(key)
|
|
59
|
+
if v is not None and (not isinstance(v, list) or any(not isinstance(r, str) for r in v)):
|
|
60
|
+
raise ValueError(f"引导配置 {key} 必须是字符串数组")
|
|
61
|
+
p = config.get("unconfiguredPolicy")
|
|
62
|
+
if p is not None and p not in ("open", "closed"):
|
|
63
|
+
raise ValueError('引导配置 unconfiguredPolicy 只能是 "open" 或 "closed"')
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class _Handle:
|
|
67
|
+
def __init__(self, store, source):
|
|
68
|
+
self._store = store
|
|
69
|
+
self._source = source
|
|
70
|
+
|
|
71
|
+
async def start(self):
|
|
72
|
+
config = await self._source() # 读取失败原样上抛(不吞错)
|
|
73
|
+
_validate(config) # 校验先于应用
|
|
74
|
+
await _call(self._store.set_exempt_roles, config.get("exemptRoles", []))
|
|
75
|
+
await _call(self._store.set_deny_write_roles, config.get("denyWriteRoles", []))
|
|
76
|
+
await _call(self._store.set_unconfigured_policy, config.get("unconfiguredPolicy", "open"))
|
|
77
|
+
await _call(self._store.set_rbac, config.get("policy", None)) # 策略最后生效
|
|
78
|
+
|
|
79
|
+
async def reload(self):
|
|
80
|
+
await _call(self._store.set_rbac, None) # 先清除,再重注入
|
|
81
|
+
await self.start()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def create(store, source):
|
|
85
|
+
if store is None:
|
|
86
|
+
raise ValueError("policy.create 需要 store 实例")
|
|
87
|
+
for name in _FACADES:
|
|
88
|
+
if not callable(getattr(store, name, None)):
|
|
89
|
+
raise ValueError(f"store 缺少门面方法: {name}")
|
|
90
|
+
if not callable(source):
|
|
91
|
+
raise ValueError("policy.create 需要 source(策略源函数)")
|
|
92
|
+
return _Handle(store, source)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
policy = SimpleNamespace(file=file, url=url, db=db, create=create)
|
|
96
|
+
|
|
97
|
+
# ------------------------------------------------------------------ #
|
|
98
|
+
# identity:身份源适配(凭证 → {userId, roles} → 各皮 context_provider 接缝)
|
|
99
|
+
# ------------------------------------------------------------------ #
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _headers_to_bag(headers):
|
|
103
|
+
"""普通 Mapping → {lower: 首个字符串值};非字符串丢弃。"""
|
|
104
|
+
bag = {}
|
|
105
|
+
if not headers:
|
|
106
|
+
return bag
|
|
107
|
+
for k, v in dict(headers).items():
|
|
108
|
+
if v is None:
|
|
109
|
+
continue
|
|
110
|
+
bag.setdefault(str(k).lower(), v if isinstance(v, str) else str(v))
|
|
111
|
+
return bag
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _metadata_to_bag(invocation_metadata):
|
|
115
|
+
"""gRPC invocation_metadata((key, value) 序列 或 支持 .get 的对象)→ 同形 bag。"""
|
|
116
|
+
bag = {}
|
|
117
|
+
if not invocation_metadata:
|
|
118
|
+
return bag
|
|
119
|
+
if hasattr(invocation_metadata, "get") and not isinstance(invocation_metadata, (list, tuple)):
|
|
120
|
+
# 对象形态:已知键按需取(authorization / x-api-key / cookie)
|
|
121
|
+
for name in ("authorization", "x-api-key", "cookie"):
|
|
122
|
+
v = invocation_metadata.get(name)
|
|
123
|
+
if v:
|
|
124
|
+
bag[name] = v if isinstance(v, str) else str(v)
|
|
125
|
+
return bag
|
|
126
|
+
for item in invocation_metadata: # (key, value) 序列形态
|
|
127
|
+
k, v = item[0], item[1]
|
|
128
|
+
bag.setdefault(str(k).lower(), v if isinstance(v, str) else str(v))
|
|
129
|
+
return bag
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _parse_cookie(cookie_header, name):
|
|
133
|
+
for part in (cookie_header or "").split(";"):
|
|
134
|
+
if "=" in part:
|
|
135
|
+
k, _, v = part.partition("=")
|
|
136
|
+
if k.strip() == name:
|
|
137
|
+
return v.strip()
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def jwt(secret, algorithm="HS256", header="authorization",
|
|
142
|
+
user_id_claim="sub", roles_claim="roles"):
|
|
143
|
+
if not isinstance(secret, str) or not secret:
|
|
144
|
+
raise ValueError("identity.jwt 需要非空 secret")
|
|
145
|
+
if algorithm not in ("HS256", "HS384", "HS512"):
|
|
146
|
+
raise ValueError(f"identity.jwt 首期仅支持 HS 系算法(收到 {algorithm})")
|
|
147
|
+
|
|
148
|
+
async def decode(bag):
|
|
149
|
+
raw = bag.get(header.lower())
|
|
150
|
+
if not raw:
|
|
151
|
+
return None # 无凭证 → None(显式清除)
|
|
152
|
+
token = raw[7:] if raw.startswith("Bearer ") else raw
|
|
153
|
+
try:
|
|
154
|
+
payload = _jwt.decode(token, secret, algorithms=[algorithm])
|
|
155
|
+
except Exception as exc: # 验签失败 / 过期 → 普通错误
|
|
156
|
+
raise ValueError(f"无效的 JWT 凭证: {exc}") from None
|
|
157
|
+
user_id = payload.get(user_id_claim)
|
|
158
|
+
if not isinstance(user_id, str) or not user_id:
|
|
159
|
+
raise ValueError("JWT 缺非空 userId 声明")
|
|
160
|
+
roles = payload.get(roles_claim)
|
|
161
|
+
return {
|
|
162
|
+
"userId": user_id,
|
|
163
|
+
"roles": [r for r in roles if isinstance(r, str)] if isinstance(roles, list) else [],
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return decode
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def api_key(header="x-api-key", lookup=None):
|
|
170
|
+
if not callable(lookup):
|
|
171
|
+
raise ValueError("identity.api_key 需要 lookup 回调")
|
|
172
|
+
|
|
173
|
+
async def decode(bag):
|
|
174
|
+
key = bag.get(header.lower())
|
|
175
|
+
if not key:
|
|
176
|
+
return None
|
|
177
|
+
ctx = lookup(key)
|
|
178
|
+
if inspect.isawaitable(ctx):
|
|
179
|
+
ctx = await ctx
|
|
180
|
+
return None if ctx is None else ctx
|
|
181
|
+
|
|
182
|
+
return decode
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def session(cookie="sid", lookup=None):
|
|
186
|
+
if not callable(lookup):
|
|
187
|
+
raise ValueError("identity.session 需要 lookup 回调")
|
|
188
|
+
|
|
189
|
+
async def decode(bag):
|
|
190
|
+
sid = _parse_cookie(bag.get("cookie") or "", cookie)
|
|
191
|
+
if not sid:
|
|
192
|
+
return None
|
|
193
|
+
ctx = lookup(sid)
|
|
194
|
+
if inspect.isawaitable(ctx):
|
|
195
|
+
ctx = await ctx
|
|
196
|
+
return None if ctx is None else ctx
|
|
197
|
+
|
|
198
|
+
return decode
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def adapt(skin, decode):
|
|
202
|
+
if not callable(decode):
|
|
203
|
+
raise ValueError("identity.adapt 需要解码器函数")
|
|
204
|
+
if skin == "store-api":
|
|
205
|
+
async def provider(request):
|
|
206
|
+
return await decode(_headers_to_bag(getattr(request, "headers", None)))
|
|
207
|
+
return provider
|
|
208
|
+
if skin == "store-grpc":
|
|
209
|
+
async def provider(invocation_metadata):
|
|
210
|
+
return await decode(_metadata_to_bag(invocation_metadata))
|
|
211
|
+
return provider
|
|
212
|
+
if skin == "store-graphql":
|
|
213
|
+
async def provider(request):
|
|
214
|
+
return await decode(_headers_to_bag(getattr(request, "headers", None)))
|
|
215
|
+
return provider
|
|
216
|
+
raise ValueError(f"identity.adapt 未知皮名: {skin}")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
identity = SimpleNamespace(jwt=jwt, api_key=api_key, session=session, adapt=adapt)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: store-rbac-bootstrap-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Optional skin-layer bootstrap for common-store RBAC: policy bootstrap (set_rbac + role-rule configs) and credential-to-context factories (JWT / API-Key / Session) for the context_provider seams of store-api / store-grpc / store-graphql
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: py-store,rbac,bootstrap,context,jwt,skin
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Requires-Dist: PyJWT>=2.8
|
|
9
|
+
Provides-Extra: dev
|
|
10
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/store_rbac_bootstrap/__init__.py
|
|
3
|
+
src/store_rbac_bootstrap_py.egg-info/PKG-INFO
|
|
4
|
+
src/store_rbac_bootstrap_py.egg-info/SOURCES.txt
|
|
5
|
+
src/store_rbac_bootstrap_py.egg-info/dependency_links.txt
|
|
6
|
+
src/store_rbac_bootstrap_py.egg-info/requires.txt
|
|
7
|
+
src/store_rbac_bootstrap_py.egg-info/top_level.txt
|
|
8
|
+
tests/test_conformance.py
|
|
9
|
+
tests/test_identity.py
|
|
10
|
+
tests/test_policy.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
store_rbac_bootstrap
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""conformance 用例(加载与 node 同一份 conformance/cases.json;期望值不复制)。"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from types import SimpleNamespace
|
|
6
|
+
|
|
7
|
+
import jwt as pyjwt
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
from store_rbac_bootstrap import policy, identity
|
|
11
|
+
from mock_store import MockStore
|
|
12
|
+
|
|
13
|
+
CASES = json.loads((Path(__file__).parents[2] / "conformance" / "cases.json").read_text(encoding="utf-8"))
|
|
14
|
+
SECRET = "conformance-secret"
|
|
15
|
+
|
|
16
|
+
# 双端门面名映射(camelCase → snake_case),供调用序列断言取 py 分支
|
|
17
|
+
_FACADE_MAP = dict(zip(CASES["facades"]["node"], CASES["facades"]["py"]))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def run(coro):
|
|
21
|
+
return asyncio.run(coro)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def source_of(cfg):
|
|
25
|
+
async def source():
|
|
26
|
+
return cfg
|
|
27
|
+
return source
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def translate(expected_calls):
|
|
31
|
+
return [(_FACADE_MAP[name], value) for name, value in expected_calls]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_conformance_config_full():
|
|
35
|
+
store = MockStore()
|
|
36
|
+
run(policy.create(store, source_of(CASES["config"]["full"]["input"])).start())
|
|
37
|
+
assert store.calls == translate(CASES["config"]["full"]["expected_calls"])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_conformance_config_defaults():
|
|
41
|
+
store = MockStore()
|
|
42
|
+
run(policy.create(store, source_of(CASES["config"]["defaults"]["input"])).start())
|
|
43
|
+
assert store.calls == translate(CASES["config"]["defaults"]["expected_calls"])
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_conformance_invalid_configs_raise_with_zero_calls():
|
|
47
|
+
entries = [
|
|
48
|
+
("unknown_key", CASES["config"]["unknown_key"]),
|
|
49
|
+
("bad_unconfigured_policy", CASES["config"]["bad_unconfigured_policy"]),
|
|
50
|
+
("bad_exempt_roles", CASES["config"]["bad_exempt_roles"]),
|
|
51
|
+
]
|
|
52
|
+
for name, c in entries:
|
|
53
|
+
store = MockStore()
|
|
54
|
+
handle = policy.create(store, source_of(c["input"]))
|
|
55
|
+
with pytest.raises(ValueError) as ei:
|
|
56
|
+
run(handle.start())
|
|
57
|
+
if c.get("error_contains"):
|
|
58
|
+
assert c["error_contains"] in str(ei.value), f"{name} 消息应含 {c['error_contains']}"
|
|
59
|
+
assert len(store.calls) == c["expected_call_count"], f"{name} 应零门面调用"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_conformance_reload_clear_first():
|
|
63
|
+
store = MockStore()
|
|
64
|
+
handle = policy.create(store, source_of({}))
|
|
65
|
+
run(handle.start())
|
|
66
|
+
before = len(store.calls)
|
|
67
|
+
run(handle.reload())
|
|
68
|
+
assert store.calls[before] == tuple(CASES["reload"]["expected_first_call"]["py"])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_conformance_null_clear():
|
|
72
|
+
async def null_lookup(_):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
decoders = {
|
|
76
|
+
"jwt": identity.jwt(secret=SECRET),
|
|
77
|
+
"api_key": identity.api_key(lookup=null_lookup),
|
|
78
|
+
"session": identity.session(lookup=null_lookup),
|
|
79
|
+
}
|
|
80
|
+
for entry in CASES["identity"]["null_clear"]:
|
|
81
|
+
assert run(decoders[entry["kind"]](entry["bag"])) is None, f"{entry['kind']} 应严格 None"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_conformance_jwt_payload_and_roles_default():
|
|
85
|
+
decode = identity.jwt(secret=SECRET)
|
|
86
|
+
p = CASES["identity"]["jwt_payload"]
|
|
87
|
+
t1 = pyjwt.encode(p["claims"], SECRET, algorithm="HS256")
|
|
88
|
+
assert run(decode({"authorization": f"Bearer {t1}"})) == p["expected"]
|
|
89
|
+
r = CASES["identity"]["jwt_roles_default"]
|
|
90
|
+
t2 = pyjwt.encode(r["claims"], SECRET, algorithm="HS256")
|
|
91
|
+
assert run(decode({"authorization": t2})) == r["expected"]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def test_conformance_invalid_is_plain_error_not_permission():
|
|
95
|
+
decode = identity.jwt(secret=SECRET)
|
|
96
|
+
bad = pyjwt.encode({"sub": "u"}, "other-secret", algorithm="HS256")
|
|
97
|
+
with pytest.raises(ValueError) as ei:
|
|
98
|
+
run(decode({"authorization": bad}))
|
|
99
|
+
assert type(ei.value).__name__ == CASES["identity"]["invalid"]["error_class"]["py"]
|
|
100
|
+
assert not isinstance(ei.value, PermissionError)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_conformance_bag_lowercase():
|
|
104
|
+
headers = CASES["identity"]["bag_lowercase"]["headers"]
|
|
105
|
+
expected_key = CASES["identity"]["bag_lowercase"]["expected_bag_key"]
|
|
106
|
+
captured = {}
|
|
107
|
+
|
|
108
|
+
async def spy(bag):
|
|
109
|
+
captured.update(bag)
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
provider = identity.adapt("store-api", spy)
|
|
113
|
+
run(provider(SimpleNamespace(headers=headers)))
|
|
114
|
+
assert expected_key in captured
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_conformance_unknown_skin_raises():
|
|
118
|
+
with pytest.raises(ValueError):
|
|
119
|
+
identity.adapt(CASES["identity"]["unknown_skin"]["skin"], lambda bag: None)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""identity 用例(与 02 node/test/identity.test.js 的 T11–T21 逐场景对拍)。"""
|
|
2
|
+
import asyncio
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
|
|
5
|
+
import jwt as pyjwt
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from store_rbac_bootstrap import identity
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(coro):
|
|
12
|
+
return asyncio.run(coro)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_T11_jwt_no_credential_returns_none():
|
|
16
|
+
decode = identity.jwt(secret="s")
|
|
17
|
+
assert run(decode({})) is None
|
|
18
|
+
assert run(decode({"cookie": "x=1"})) is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_T12_jwt_valid_payload_and_roles_default():
|
|
22
|
+
decode = identity.jwt(secret="s")
|
|
23
|
+
token = pyjwt.encode({"sub": "u1", "roles": ["editor"]}, "s", algorithm="HS256")
|
|
24
|
+
assert run(decode({"authorization": f"Bearer {token}"})) == {"userId": "u1", "roles": ["editor"]}
|
|
25
|
+
no_roles = pyjwt.encode({"sub": "u2"}, "s", algorithm="HS256")
|
|
26
|
+
assert run(decode({"authorization": no_roles})) == {"userId": "u2", "roles": []}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_T13_jwt_invalid_raises_plain_error():
|
|
30
|
+
decode = identity.jwt(secret="s")
|
|
31
|
+
wrong = pyjwt.encode({"sub": "u"}, "other", algorithm="HS256")
|
|
32
|
+
with pytest.raises(ValueError) as ei:
|
|
33
|
+
run(decode({"authorization": wrong}))
|
|
34
|
+
assert not isinstance(ei.value, PermissionError)
|
|
35
|
+
expired = pyjwt.encode({"sub": "u", "exp": 1}, "s", algorithm="HS256")
|
|
36
|
+
with pytest.raises(ValueError) as ei2:
|
|
37
|
+
run(decode({"authorization": expired}))
|
|
38
|
+
assert not isinstance(ei2.value, PermissionError)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_T14_jwt_construction_errors():
|
|
42
|
+
with pytest.raises(ValueError, match="secret"):
|
|
43
|
+
identity.jwt(secret="")
|
|
44
|
+
with pytest.raises(ValueError, match="HS"):
|
|
45
|
+
identity.jwt(secret="s", algorithm="RS256")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_T15_api_key_three_states_and_passthrough():
|
|
49
|
+
async def lookup(k):
|
|
50
|
+
return {"userId": "u", "roles": []} if k == "good" else None
|
|
51
|
+
|
|
52
|
+
decode = identity.api_key(lookup=lookup)
|
|
53
|
+
assert run(decode({})) is None
|
|
54
|
+
assert run(decode({"x-api-key": "good"})) == {"userId": "u", "roles": []}
|
|
55
|
+
assert run(decode({"x-api-key": "bad"})) is None
|
|
56
|
+
|
|
57
|
+
class PermissionError(Exception):
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
def boom(k):
|
|
61
|
+
raise PermissionError("no")
|
|
62
|
+
|
|
63
|
+
decode2 = identity.api_key(lookup=boom)
|
|
64
|
+
with pytest.raises(PermissionError):
|
|
65
|
+
run(decode2({"x-api-key": "k"}))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_T16_session_cookie_parsing():
|
|
69
|
+
async def lookup(sid):
|
|
70
|
+
return {"userId": "u", "roles": []} if sid == "s1" else None
|
|
71
|
+
|
|
72
|
+
decode = identity.session(lookup=lookup)
|
|
73
|
+
assert run(decode({})) is None
|
|
74
|
+
assert run(decode({"cookie": "sid=s1"})) == {"userId": "u", "roles": []}
|
|
75
|
+
assert run(decode({"cookie": "other=1; sid=s1; z=2"})) == {"userId": "u", "roles": []}
|
|
76
|
+
assert run(decode({"cookie": "sid=nope"})) is None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_T17_adapt_store_api_lowercase_bag():
|
|
80
|
+
captured = {}
|
|
81
|
+
|
|
82
|
+
async def decode(bag):
|
|
83
|
+
captured.update(bag)
|
|
84
|
+
return {"userId": "u", "roles": []}
|
|
85
|
+
|
|
86
|
+
provider = identity.adapt("store-api", decode)
|
|
87
|
+
ctx = run(provider(SimpleNamespace(headers={"Authorization": "Bearer t"})))
|
|
88
|
+
assert captured == {"authorization": "Bearer t"}
|
|
89
|
+
assert ctx == {"userId": "u", "roles": []}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_T18_adapt_store_grpc_two_shapes():
|
|
93
|
+
captured = {}
|
|
94
|
+
|
|
95
|
+
async def decode(bag):
|
|
96
|
+
captured.clear()
|
|
97
|
+
captured.update(bag)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
provider = identity.adapt("store-grpc", decode)
|
|
101
|
+
run(provider([("authorization", "Bearer t")])) # (key, value) 序列形态
|
|
102
|
+
assert captured == {"authorization": "Bearer t"}
|
|
103
|
+
|
|
104
|
+
class MD: # .get 对象形态
|
|
105
|
+
def get(self, name):
|
|
106
|
+
return {"authorization": "Bearer z"}.get(name)
|
|
107
|
+
|
|
108
|
+
run(provider(MD()))
|
|
109
|
+
assert captured == {"authorization": "Bearer z"}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_T19_adapt_store_graphql_headers():
|
|
113
|
+
captured = {}
|
|
114
|
+
|
|
115
|
+
async def decode(bag):
|
|
116
|
+
captured.update(bag)
|
|
117
|
+
return None
|
|
118
|
+
|
|
119
|
+
provider = identity.adapt("store-graphql", decode)
|
|
120
|
+
run(provider(SimpleNamespace(headers={"Authorization": "Bearer t"})))
|
|
121
|
+
assert captured == {"authorization": "Bearer t"}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_T20_adapt_unknown_skin_raises():
|
|
125
|
+
with pytest.raises(ValueError, match="未知皮名"):
|
|
126
|
+
identity.adapt("unknown", lambda b: None)
|
|
127
|
+
with pytest.raises(ValueError, match="解码器"):
|
|
128
|
+
identity.adapt("store-api", None)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def test_T21_adapt_null_clear_semantics():
|
|
132
|
+
provider = identity.adapt("store-api", identity.jwt(secret="s"))
|
|
133
|
+
assert run(provider(SimpleNamespace(headers={}))) is None
|
|
134
|
+
assert run(provider(SimpleNamespace())) is None
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""policy 用例(与 02 node/test/policy.test.js 的 T1–T10 逐场景对拍)。"""
|
|
2
|
+
import asyncio
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from store_rbac_bootstrap import policy
|
|
8
|
+
from mock_store import MockStore
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def run(coro):
|
|
12
|
+
return asyncio.run(coro)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def make_source(cfg):
|
|
16
|
+
async def source():
|
|
17
|
+
return cfg
|
|
18
|
+
return source
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_T1_full_config_strict_order():
|
|
22
|
+
store = MockStore()
|
|
23
|
+
cfg = {
|
|
24
|
+
"policy": {"mode": "overlay", "roles": {}, "grants": []},
|
|
25
|
+
"exemptRoles": ["a"],
|
|
26
|
+
"denyWriteRoles": ["g"],
|
|
27
|
+
"unconfiguredPolicy": "closed",
|
|
28
|
+
}
|
|
29
|
+
handle = policy.create(store, make_source(cfg))
|
|
30
|
+
run(handle.start())
|
|
31
|
+
assert store.calls == [
|
|
32
|
+
("set_exempt_roles", ["a"]),
|
|
33
|
+
("set_deny_write_roles", ["g"]),
|
|
34
|
+
("set_unconfigured_policy", "closed"),
|
|
35
|
+
("set_rbac", cfg["policy"]),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_T2_empty_config_defaults():
|
|
40
|
+
store = MockStore()
|
|
41
|
+
handle = policy.create(store, make_source({}))
|
|
42
|
+
run(handle.start())
|
|
43
|
+
assert store.calls == [
|
|
44
|
+
("set_exempt_roles", []),
|
|
45
|
+
("set_deny_write_roles", []),
|
|
46
|
+
("set_unconfigured_policy", "open"),
|
|
47
|
+
("set_rbac", None),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_T3_unknown_top_key_no_facade_call():
|
|
52
|
+
store = MockStore()
|
|
53
|
+
handle = policy.create(store, make_source({"foo": 1}))
|
|
54
|
+
with pytest.raises(ValueError, match="foo"):
|
|
55
|
+
run(handle.start())
|
|
56
|
+
assert store.calls == []
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_T4_bad_unconfigured_policy_no_facade_call():
|
|
60
|
+
store = MockStore()
|
|
61
|
+
handle = policy.create(store, make_source({"unconfiguredPolicy": "x"}))
|
|
62
|
+
with pytest.raises(ValueError, match="unconfiguredPolicy"):
|
|
63
|
+
run(handle.start())
|
|
64
|
+
assert store.calls == []
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_T5_bad_exempt_roles_no_facade_call():
|
|
68
|
+
store = MockStore()
|
|
69
|
+
handle = policy.create(store, make_source({"exemptRoles": "a"}))
|
|
70
|
+
with pytest.raises(ValueError, match="exemptRoles"):
|
|
71
|
+
run(handle.start())
|
|
72
|
+
assert store.calls == []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_T6_source_error_propagates_no_facade_call():
|
|
76
|
+
store = MockStore()
|
|
77
|
+
|
|
78
|
+
async def boom():
|
|
79
|
+
raise RuntimeError("boom")
|
|
80
|
+
|
|
81
|
+
handle = policy.create(store, boom)
|
|
82
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
83
|
+
run(handle.start())
|
|
84
|
+
assert store.calls == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_T7_policy_file(tmp_path):
|
|
88
|
+
p = tmp_path / "cfg.json"
|
|
89
|
+
p.write_text(json.dumps({"exemptRoles": ["x"]}), encoding="utf-8")
|
|
90
|
+
store = MockStore()
|
|
91
|
+
handle = policy.create(store, policy.file(str(p)))
|
|
92
|
+
run(handle.start())
|
|
93
|
+
assert store.calls == [
|
|
94
|
+
("set_exempt_roles", ["x"]),
|
|
95
|
+
("set_deny_write_roles", []),
|
|
96
|
+
("set_unconfigured_policy", "open"),
|
|
97
|
+
("set_rbac", None),
|
|
98
|
+
]
|
|
99
|
+
with pytest.raises(Exception):
|
|
100
|
+
run(policy.file(str(p) + ".nope")())
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def test_T8_reload_clear_first():
|
|
104
|
+
store = MockStore()
|
|
105
|
+
handle = policy.create(store, make_source({"exemptRoles": ["a"]}))
|
|
106
|
+
run(handle.start())
|
|
107
|
+
before = len(store.calls) # 4
|
|
108
|
+
run(handle.reload())
|
|
109
|
+
assert store.calls[before] == ("set_rbac", None) # 清除前置
|
|
110
|
+
assert len(store.calls) == before + 1 + 4
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_T9_missing_facade_raises():
|
|
114
|
+
class S: # 缺 set_deny_write_roles
|
|
115
|
+
def set_rbac(self, v):
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def set_exempt_roles(self, v):
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def set_unconfigured_policy(self, v):
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
with pytest.raises(ValueError, match="set_deny_write_roles"):
|
|
125
|
+
policy.create(S(), make_source({}))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_T10_source_not_callable_raises():
|
|
129
|
+
with pytest.raises(ValueError, match="source"):
|
|
130
|
+
policy.create(MockStore(), 123)
|