485 lines
27 KiB
Python
485 lines
27 KiB
Python
"""Real FastAPI + isolated public Python copies + synthetic SQLite/key only."""
|
|
from __future__ import annotations
|
|
|
|
import atexit
|
|
import faulthandler
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import shutil
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
|
|
MIRROR = Path(__file__).resolve().parent
|
|
SOURCE = Path(os.environ.get("IAM_KEFU_SOURCE_ROOT", "C:/kefu")) / "wechat_rpa"
|
|
TEST_RUNTIME = MIRROR.parents[2] / ".test-runtime"
|
|
TEST_RUNTIME.mkdir(parents=True, exist_ok=True)
|
|
RUNTIME = tempfile.TemporaryDirectory(prefix="iam-kefu-authorization-", dir=TEST_RUNTIME)
|
|
ISOLATED = Path(RUNTIME.name)
|
|
|
|
|
|
def cleanup_runtime():
|
|
os.chdir(TEST_RUNTIME)
|
|
RUNTIME.cleanup()
|
|
|
|
|
|
atexit.register(cleanup_runtime)
|
|
# Copy no settings, .env, database, production key, or assets from the real app.
|
|
for source in SOURCE.glob("*.py"):
|
|
shutil.copyfile(source, ISOLATED / source.name)
|
|
for name in ("admin_api.py", "admin_backend.py", "rbac.py", "group_sso.py", "group_access.py", "group_authorization.py"):
|
|
shutil.copyfile(MIRROR / name, ISOLATED / name)
|
|
os.chdir(ISOLATED)
|
|
RUNTIME_ENV = {key: value for key, value in os.environ.items()
|
|
if key.upper() in {"SYSTEMROOT", "WINDIR", "TEMP", "TMP"}}
|
|
os.environ.clear()
|
|
os.environ.update(RUNTIME_ENV)
|
|
sys.path.insert(0, str(ISOLATED))
|
|
|
|
from fastapi.testclient import TestClient
|
|
import admin_api
|
|
import admin_backend as backend
|
|
import group_access
|
|
import group_authorization as authorization
|
|
import rbac
|
|
|
|
|
|
KEY = bytes.fromhex("34" * 32)
|
|
NOW = 1_800_000_000
|
|
GID = "gid_" + "a" * 32
|
|
OTHER_GID = "gid_" + "b" * 32
|
|
PASSWORD = "Fixture-Authorization-Password-2026"
|
|
|
|
|
|
class GroupAuthorizationTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.directory = tempfile.TemporaryDirectory(dir=ISOLATED)
|
|
self.addCleanup(self.directory.cleanup)
|
|
self.root = Path(self.directory.name)
|
|
self.key_file = self.root / "synthetic.key"
|
|
self.key_file.write_text(KEY.hex(), encoding="ascii")
|
|
self.env = mock.patch.dict(os.environ, {"ZYT_GROUP_METADATA_KEY_FILE": str(self.key_file)}, clear=True)
|
|
self.env.start()
|
|
self.addCleanup(self.env.stop)
|
|
self.db = backend.Database(self.root / "synthetic.sqlite")
|
|
self.clock = mock.Mock(return_value=NOW)
|
|
with mock.patch.object(backend, "load_initial_config", return_value=backend.CONFIG_DEFAULTS.copy()):
|
|
self.db.initialize(PASSWORD)
|
|
self.service = authorization.GroupAuthorization(self.db, clock=self.clock)
|
|
with mock.patch.object(authorization, "GroupAuthorization", return_value=self.service):
|
|
self.client = TestClient(admin_api.create_app(self.db.path))
|
|
self.addCleanup(self.client.close)
|
|
|
|
def identity(self, gid=GID, source_id=7):
|
|
return {"client_id": "kefu", "gid": gid, "source_admin_id": source_id, "company_id": 2}
|
|
|
|
def enroll(self, gid=GID, source_id=7):
|
|
return self.db.group_sso_user({**self.identity(gid, source_id),
|
|
"account": "same-account", "name": "合成集团用户"})
|
|
|
|
def raw(self, value):
|
|
return json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
|
|
def headers(self, raw, path, *, nonce=None, timestamp=None, key=KEY):
|
|
nonce = nonce or secrets.token_hex(16)
|
|
timestamp = str(NOW if timestamp is None else timestamp)
|
|
message = f"POST\n{path}\n{timestamp}\n{nonce}\n{hashlib.sha256(raw).hexdigest()}"
|
|
return {"Content-Type": "application/json", "X-Group-Timestamp": timestamp,
|
|
"X-Group-Nonce": nonce,
|
|
"X-Group-Signature": hmac.new(key, message.encode("ascii"), hashlib.sha256).hexdigest()}
|
|
|
|
def fetch(self, body, path=authorization.OPTIONS_PATH, *, raw=None, query="", **signing):
|
|
raw = self.raw(body) if raw is None else raw
|
|
headers = self.headers(raw, path, **signing)
|
|
response = self.client.post("/api/v2" + path + query, content=raw, headers=headers)
|
|
self.assertEqual(response.headers.get("Cache-Control"), "no-store")
|
|
if response.status_code not in {401, 413} and "X-Group-Response-Signature" in response.headers:
|
|
self.assertEqual(response.headers["X-Group-Response-Signature"],
|
|
group_access.response_signature(KEY, response.content, headers["X-Group-Nonce"]))
|
|
if response.status_code != 200 and response.status_code not in {401, 413}:
|
|
self.assertEqual(response.json(), {"version": 1, "client_id": "kefu", "generated_at": int(self.clock()),
|
|
"error": {"code": response.status_code, "message": response.json()["error"]["message"]}})
|
|
self.assertIn("X-Group-Response-Signature", response.headers)
|
|
return response
|
|
|
|
def options(self, identity=None):
|
|
result = self.fetch(identity or self.identity())
|
|
self.assertEqual(result.status_code, 200, result.text)
|
|
return result.json()
|
|
|
|
def mutation(self, revision=None, role="operator", permissions=None, identity=None, **changes):
|
|
return {**(identity or self.identity()), "actor_source_admin_id": 9,
|
|
"request_id": secrets.token_hex(16), "expected_revision": revision or self.options(identity)["revision"],
|
|
"role_code": role, "permissions": permissions, **changes}
|
|
|
|
def update(self, body=None, **changes):
|
|
return self.fetch(body or self.mutation(**changes), authorization.UPDATE_PATH)
|
|
|
|
def fingerprint(self):
|
|
tables = ("users", "roles", "role_permissions", "auth_tokens", "permissions", "group_sso_bindings",
|
|
"audit_log", "group_authorization_audit", "group_authorization_private_roles", "group_authorization_requests")
|
|
with self.db.connect() as db:
|
|
return {table: [tuple(row) for row in db.execute(f"SELECT * FROM {table} ORDER BY rowid")]
|
|
for table in tables}
|
|
|
|
def token_headers(self, user_id):
|
|
token, _ = self.db.create_token(user_id, "api", "existing-synthetic-session", 3600)
|
|
return {"Authorization": "Bearer " + token}
|
|
|
|
def test_options_exact_native_projection_is_readonly(self):
|
|
user = self.enroll()
|
|
self.db.update_user(user["id"], "operator", True, "", 1, "fixture")
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE permissions SET enabled=0 WHERE code='config:read'")
|
|
db.commit()
|
|
before = self.fingerprint()
|
|
data = self.options()
|
|
self.assertEqual(set(data), {"version", "client_id", "generated_at", "subject", "revision", "roles", "catalog"})
|
|
self.assertRegex(data["revision"], r"^[0-9a-f]{64}$")
|
|
subject = data["subject"]
|
|
self.assertEqual(set(subject), {"gid", "found", "source_admin_id", "company_id", "user"})
|
|
self.assertEqual(subject["gid"], GID)
|
|
self.assertEqual(subject["user"]["permissions"], sorted(self.db.permissions_for_user(user["id"])))
|
|
self.assertIn("config:read", subject["user"]["permissions"])
|
|
for role in data["roles"]:
|
|
self.assertEqual(set(role), {"code", "name", "permissions"})
|
|
for item in data["catalog"]["permissions"]:
|
|
self.assertEqual(set(item), {"code", "name", "group", "page", "kind", "source", "enabled", "sort"})
|
|
text = self.raw(data).decode("utf-8")
|
|
for forbidden in ("password_salt", "password_digest", "link_url", "user_count", str(self.key_file), KEY.hex()):
|
|
self.assertNotIn(forbidden, text)
|
|
self.assertEqual(before, self.fingerprint())
|
|
|
|
def test_role_assignment_existing_token_gains_and_loses_without_login(self):
|
|
user = self.enroll()
|
|
other = self.enroll(OTHER_GID, 8)
|
|
self.db.update_user(other["id"], "viewer", True, "", 1, "fixture")
|
|
headers = self.token_headers(user["id"])
|
|
other_headers = self.token_headers(other["id"])
|
|
initial = self.fingerprint()
|
|
other_before = self.client.get("/api/v2/me", headers=other_headers).json()
|
|
self.assertEqual(self.client.get("/api/v2/stats/model-calls", headers=headers).status_code, 403)
|
|
self.assertEqual(self.client.get("/api/v2/config", headers=headers).status_code, 403)
|
|
first = self.update()
|
|
self.assertEqual(first.status_code, 200, first.text)
|
|
self.assertEqual(first.json()["subject"]["user"]["role"]["code"], "operator")
|
|
self.assertEqual(self.client.get("/api/v2/stats/model-calls", headers=headers).status_code, 200)
|
|
self.assertEqual(self.client.get("/api/v2/config", headers=headers).status_code, 200)
|
|
self.assertEqual(self.client.get("/api/v2/me", headers=headers).json()["role"], "operator")
|
|
second = self.update(role="zyt_basic", revision=first.json()["revision"])
|
|
self.assertEqual(second.status_code, 200, second.text)
|
|
self.assertEqual(self.client.get("/api/v2/stats/model-calls", headers=headers).status_code, 403)
|
|
self.assertEqual(self.client.get("/api/v2/config", headers=headers).status_code, 403)
|
|
self.assertEqual(self.client.get("/api/v2/me", headers=headers).json()["permissions"], [])
|
|
self.assertEqual(self.client.get("/api/v2/me", headers=other_headers).json(), other_before)
|
|
final = self.fingerprint()
|
|
for table in ("roles", "role_permissions", "permissions", "group_sso_bindings"):
|
|
self.assertEqual(initial[table], final[table])
|
|
# session() updates last_used_at; identity, token/CSRF digests, expiry,
|
|
# credentials, status, password policy and all session rows are retained.
|
|
self.assertEqual([row[:-1] for row in initial["auth_tokens"]], [row[:-1] for row in final["auth_tokens"]])
|
|
for before, after in zip(initial["users"], final["users"]):
|
|
self.assertEqual(before[:-1], after[:-1]) # updated_at only may move
|
|
|
|
def test_explicit_permissions_are_private_empty_is_valid_and_other_users_unchanged(self):
|
|
user = self.enroll()
|
|
other = self.enroll(OTHER_GID, 8)
|
|
shared = self.fingerprint()["role_permissions"]
|
|
granted = ["config:read", "stats:read"]
|
|
result = self.update(permissions=granted)
|
|
self.assertEqual(result.status_code, 200, result.text)
|
|
first_role = result.json()["subject"]["user"]["role"]["code"]
|
|
self.assertNotEqual(first_role, "operator")
|
|
self.assertEqual(self.db.permissions_for_user(user["id"]), set(granted))
|
|
self.assertEqual(self.db.permissions_for_user(other["id"]), set())
|
|
with self.db.connect() as db:
|
|
unchanged = [tuple(row) for row in db.execute("SELECT * FROM role_permissions WHERE role_code!=? ORDER BY rowid", (first_role,))]
|
|
self.assertEqual(shared, unchanged)
|
|
second = self.update(role="operator", permissions=granted, identity=self.identity(OTHER_GID, 8))
|
|
self.assertEqual(second.status_code, 200, second.text)
|
|
self.assertNotEqual(first_role, second.json()["subject"]["user"]["role"]["code"])
|
|
self.assertNotIn(first_role, {role["code"] for role in second.json()["roles"]})
|
|
attempt = self.update(role=first_role, identity=self.identity(OTHER_GID, 8))
|
|
self.assertEqual(attempt.status_code, 422)
|
|
empty = self.update(permissions=[])
|
|
self.assertEqual(empty.status_code, 200, empty.text)
|
|
self.assertEqual(empty.json()["subject"]["user"]["permissions"], [])
|
|
self.assertEqual(self.db.permissions_for_user(other["id"]), set(granted))
|
|
|
|
def test_reuses_matching_private_role_but_forks_when_natively_shared_or_edited(self):
|
|
user = self.enroll()
|
|
other = self.enroll(OTHER_GID, 8)
|
|
first = self.update(permissions=["config:read"]).json()
|
|
role = first["subject"]["user"]["role"]["code"]
|
|
again = self.update(permissions=["config:read"]).json()
|
|
self.assertEqual(again["subject"]["user"]["role"]["code"], role)
|
|
self.db.update_user(other["id"], role, True, "", 1, "fixture")
|
|
shared_before = self.db.permissions_for_user(other["id"])
|
|
shared_fork = self.update(permissions=["config:read"]).json()["subject"]["user"]["role"]["code"]
|
|
self.assertNotEqual(shared_fork, role)
|
|
self.assertEqual(self.db.permissions_for_user(other["id"]), shared_before)
|
|
self.db.save_role(shared_fork, "KF 原生改名", ["stats:read"], 1, "fixture")
|
|
edited_fork = self.update(permissions=["config:read"]).json()["subject"]["user"]["role"]["code"]
|
|
self.assertNotIn(edited_fork, {role, shared_fork})
|
|
with self.db.connect() as db:
|
|
self.assertEqual(rbac.permissions_of(db, shared_fork), {"stats:read"})
|
|
|
|
def test_inactive_user_stays_inactive_and_revision_binds_assigned_permissions(self):
|
|
user = self.enroll()
|
|
self.db.update_user(user["id"], "viewer", False, "", 1, "fixture")
|
|
before = self.options()
|
|
result = self.update(revision=before["revision"], permissions=["config:read"])
|
|
self.assertEqual(result.status_code, 200, result.text)
|
|
self.assertFalse(result.json()["subject"]["user"]["active"])
|
|
self.assertEqual(result.json()["subject"]["user"]["permissions"], [])
|
|
role = result.json()["subject"]["user"]["role"]["code"]
|
|
self.assertEqual(next(item["permissions"] for item in result.json()["roles"] if item["code"] == role), ["config:read"])
|
|
self.assertNotEqual(before["revision"], result.json()["revision"])
|
|
|
|
def test_last_active_native_admin_is_preserved(self):
|
|
user = self.enroll()
|
|
self.db.update_user(user["id"], "admin", True, "", 1, "fixture")
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE users SET active=0 WHERE id=1")
|
|
db.commit()
|
|
before = self.fingerprint()
|
|
for permissions, role in ((None, "viewer"), ([], "admin"), (list(self.db.permissions_for_user(user["id"])), "admin")):
|
|
self.assertEqual(self.update(role=role, permissions=permissions).status_code, 409)
|
|
self.assertEqual(before, self.fingerprint())
|
|
self.assertEqual(self.update(role="admin").status_code, 200)
|
|
|
|
def test_missing_native_same_name_or_mismatched_binding_never_enrolls(self):
|
|
self.db.create_user("same-account", PASSWORD, "admin", 1, "fixture")
|
|
before = self.fingerprint()
|
|
with mock.patch.object(backend.Database, "group_sso_user", side_effect=AssertionError("No enrollment")):
|
|
self.assertEqual(self.fetch(self.identity()).status_code, 404)
|
|
self.assertEqual(self.update(self.mutation(revision="0" * 64)).status_code, 404)
|
|
self.assertEqual(before, self.fingerprint())
|
|
self.enroll()
|
|
before = self.fingerprint()
|
|
for changed in ({"company_id": 3}, {"source_admin_id": 8}):
|
|
identity = {**self.identity(), **changed}
|
|
self.assertEqual(self.fetch(identity).status_code, 409)
|
|
self.assertEqual(self.update(self.mutation(revision="0" * 64, identity=identity)).status_code, 409)
|
|
self.assertEqual(before, self.fingerprint())
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE group_sso_bindings SET provider='unrelated' WHERE gid=?", (GID,))
|
|
db.commit()
|
|
self.assertEqual(self.fetch(self.identity()).status_code, 404)
|
|
|
|
def test_unknown_role_permission_and_stale_revision_rollback(self):
|
|
self.enroll()
|
|
for changes, expected in (({"role": "missing-role"}, 422), ({"permissions": ["missing:permission"]}, 422),
|
|
({"revision": "0" * 64}, 409)):
|
|
body = self.mutation(**changes)
|
|
before = self.fingerprint()
|
|
self.assertEqual(self.update(body).status_code, expected)
|
|
self.assertEqual(before, self.fingerprint())
|
|
|
|
def test_revision_detects_native_role_permission_active_and_identity_changes(self):
|
|
user = self.enroll()
|
|
self.db.update_user(user["id"], "viewer", True, "", 1, "fixture")
|
|
sql_changes = [("UPDATE role_permissions SET permission_code='config:write' WHERE role_code='viewer' AND permission_code='config:read'", ()),
|
|
("UPDATE users SET active=0 WHERE id=?", (user["id"],)),
|
|
("UPDATE users SET role='operator' WHERE id=?", (user["id"],)),
|
|
("UPDATE group_sso_bindings SET source_name='更新姓名' WHERE gid=?", (GID,))]
|
|
for sql, parameters in sql_changes:
|
|
body = self.mutation()
|
|
with self.db.connect() as db:
|
|
db.execute(sql, parameters)
|
|
db.commit()
|
|
before = self.fingerprint()
|
|
self.assertEqual(self.update(body).status_code, 409)
|
|
self.assertEqual(before, self.fingerprint())
|
|
|
|
def test_idempotency_is_durable_exact_body_and_revalidates_binding(self):
|
|
self.enroll()
|
|
body = self.mutation(permissions=["stats:read"])
|
|
first = self.update(body)
|
|
self.assertEqual(first.status_code, 200, first.text)
|
|
fingerprint = self.fingerprint()
|
|
# Simulate another worker/restart using the same SQLite file.
|
|
restarted = authorization.GroupAuthorization(self.db, clock=self.clock)
|
|
self.service._reserve_nonce = restarted._reserve_nonce
|
|
second = self.update(body)
|
|
self.assertEqual(second.status_code, 200, second.text)
|
|
self.assertEqual(first.content, second.content)
|
|
self.assertNotEqual(first.headers["X-Group-Response-Signature"], second.headers["X-Group-Response-Signature"])
|
|
self.assertEqual(fingerprint, self.fingerprint())
|
|
for changed in ({"role_code": "viewer"}, {"actor_source_admin_id": 10}, {"permissions": []}):
|
|
self.assertEqual(self.update({**body, **changed}).status_code, 409)
|
|
self.assertEqual(fingerprint, self.fingerprint())
|
|
with self.db.connect() as db:
|
|
db.execute("UPDATE group_sso_bindings SET company_id=3 WHERE gid=?", (GID,))
|
|
db.commit()
|
|
self.assertEqual(self.update(body).status_code, 409)
|
|
|
|
def test_idempotent_success_never_crosses_a_reassigned_binding(self):
|
|
self.enroll()
|
|
replacement = self.enroll(OTHER_GID, 8)
|
|
body = self.mutation(permissions=["stats:read"])
|
|
self.assertEqual(self.update(body).status_code, 200)
|
|
with self.db.connect() as db:
|
|
db.execute("DELETE FROM group_sso_bindings WHERE gid=?", (GID,))
|
|
db.execute("UPDATE group_sso_bindings SET gid=?,source_admin_id=7 WHERE gid=?", (GID, OTHER_GID))
|
|
db.commit()
|
|
before = self.fingerprint()
|
|
self.assertEqual(self.update(body).status_code, 409)
|
|
self.assertEqual(self.db.permissions_for_user(replacement["id"]), set())
|
|
self.assertEqual(before, self.fingerprint())
|
|
|
|
def test_atomic_compare_and_swap_allows_only_one_concurrent_writer(self):
|
|
self.enroll()
|
|
revision = self.options()["revision"]
|
|
bodies = [self.mutation(revision=revision, role="operator"), self.mutation(revision=revision, role="viewer")]
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
results = list(pool.map(self.update, bodies))
|
|
self.assertEqual(sorted(result.status_code for result in results), [200, 409])
|
|
with self.db.connect() as db:
|
|
self.assertEqual(db.execute("SELECT COUNT(*) FROM group_authorization_requests").fetchone()[0], 1)
|
|
self.assertEqual(db.execute("SELECT COUNT(*) FROM group_authorization_audit").fetchone()[0], 1)
|
|
|
|
def test_both_audits_are_transactional_and_preserve_asserted_actor(self):
|
|
user = self.enroll()
|
|
body = self.mutation(permissions=["stats:read"])
|
|
result = self.update(body)
|
|
self.assertEqual(result.status_code, 200, result.text)
|
|
with self.db.connect() as db:
|
|
audit = db.execute("SELECT * FROM group_authorization_audit").fetchone()
|
|
detail = json.loads(audit["detail_json"])
|
|
native = db.execute("SELECT user_id,detail FROM audit_log WHERE action='group.authorization.update'").fetchone()
|
|
self.assertEqual(audit["actor_source_admin_id"], 9)
|
|
self.assertEqual(audit["target_user_id"], user["id"])
|
|
self.assertEqual(detail["request_id"], body["request_id"])
|
|
self.assertEqual(detail["before_permissions"], [])
|
|
self.assertEqual(detail["after_permissions"], ["stats:read"])
|
|
self.assertEqual(detail["after_revision"], result.json()["revision"])
|
|
self.assertIsNone(native["user_id"])
|
|
self.assertEqual(json.loads(native["detail"])["actor_source_admin_id"], 9)
|
|
|
|
def test_audit_failure_rolls_back_role_grants_assignment_and_idempotency(self):
|
|
self.enroll()
|
|
for table in ("audit_log", "group_authorization_audit", "group_authorization_requests"):
|
|
body = self.mutation(permissions=["stats:read"])
|
|
with self.db.connect() as db:
|
|
db.execute(f"CREATE TRIGGER synthetic_audit_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT,'synthetic-secret-error'); END")
|
|
db.commit()
|
|
before = self.fingerprint()
|
|
response = self.update(body)
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertNotIn("synthetic-secret-error", response.text)
|
|
self.assertEqual(before, self.fingerprint())
|
|
with self.db.connect() as db:
|
|
db.execute("DROP TRIGGER synthetic_audit_failure")
|
|
db.commit()
|
|
|
|
def test_response_bound_failure_rolls_back_mutation(self):
|
|
self.enroll()
|
|
body = self.mutation(permissions=["stats:read"])
|
|
before = self.fingerprint()
|
|
with mock.patch.object(authorization, "MAX_RESPONSE_BYTES", 1024):
|
|
response = self.update(body)
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertEqual(before, self.fingerprint())
|
|
|
|
def test_bearer_is_never_machine_auth_and_wrong_path_or_signature_fail_before_parse(self):
|
|
self.enroll()
|
|
token, _ = self.db.create_token(1, "api", "fixture", 3600)
|
|
for path in (authorization.OPTIONS_PATH, authorization.UPDATE_PATH):
|
|
response = self.client.post("/api/v2" + path, json=self.identity(), headers={"Authorization": "Bearer " + token})
|
|
self.assertEqual(response.status_code, 401)
|
|
raw = self.raw(self.identity())
|
|
for key, signed_path, changed in ((b"x" * 32, path, raw), (KEY, "/api/v2" + path, raw),
|
|
(KEY, authorization.UPDATE_PATH if path == authorization.OPTIONS_PATH else authorization.OPTIONS_PATH, raw),
|
|
(KEY, path, b"invalid-json")):
|
|
headers = self.headers(raw, signed_path, key=key)
|
|
with mock.patch.object(authorization, "parse_request", side_effect=AssertionError("Before auth")):
|
|
response = self.client.post("/api/v2" + path, content=changed, headers=headers)
|
|
self.assertEqual(response.status_code, 401)
|
|
|
|
def test_nonce_replay_persists_across_instances_failures_and_concurrent_workers(self):
|
|
self.enroll()
|
|
nonce = "d" * 32
|
|
self.assertEqual(self.fetch(self.identity(), nonce=nonce).status_code, 200)
|
|
restarted = authorization.GroupAuthorization(self.db, clock=self.clock)
|
|
self.service._reserve_nonce = restarted._reserve_nonce
|
|
self.assertEqual(self.fetch(self.identity(), nonce=nonce).status_code, 401)
|
|
invalid = {**self.identity(), "extra": 1}
|
|
self.assertEqual(self.fetch(invalid, nonce="e" * 32).status_code, 422)
|
|
self.assertEqual(self.fetch(invalid, nonce="e" * 32).status_code, 401)
|
|
raw = self.raw(self.identity())
|
|
headers = self.headers(raw, authorization.OPTIONS_PATH, nonce="f" * 32)
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
statuses = list(pool.map(lambda _: self.client.post("/api/v2" + authorization.OPTIONS_PATH,
|
|
content=raw, headers=headers).status_code, range(8)))
|
|
self.assertEqual(statuses.count(200), 1)
|
|
self.assertEqual(statuses.count(401), 7)
|
|
|
|
def test_clock_bounds_and_bounded_nonce_store(self):
|
|
self.enroll()
|
|
for offset in (-91, 91):
|
|
self.assertEqual(self.fetch(self.identity(), timestamp=NOW + offset).status_code, 401)
|
|
for offset in (-90, 90):
|
|
self.assertEqual(self.fetch(self.identity(), timestamp=NOW + offset).status_code, 200)
|
|
with self.db.connect() as db:
|
|
count = db.execute("SELECT COUNT(*) FROM group_authorization_nonces").fetchone()[0]
|
|
self.service.max_nonces = count
|
|
self.assertEqual(self.fetch(self.identity()).status_code, 503)
|
|
self.clock.return_value = NOW + 181
|
|
self.assertEqual(self.fetch(self.identity(), timestamp=NOW + 181).status_code, 200)
|
|
|
|
def test_strict_json_fields_and_types_signed_safe_errors(self):
|
|
self.enroll()
|
|
mutation = self.mutation()
|
|
invalid_options = [[], {}, {**self.identity(), "role_code": "admin"},
|
|
{**self.identity(), "client_id": "hospital"}, {**self.identity(), "source_admin_id": True},
|
|
{**self.identity(), "company_id": 0}, {**self.identity(), "gid": "gid_" + "A" * 32}]
|
|
for body in invalid_options:
|
|
self.assertEqual(self.fetch(body).status_code, 422)
|
|
invalid_updates = [{**mutation, "actor_source_admin_id": True}, {**mutation, "request_id": "A" * 32},
|
|
{**mutation, "expected_revision": "A" * 64}, {**mutation, "role_code": ""},
|
|
{**mutation, "permissions": "stats:read"}, {**mutation, "permissions": [True]},
|
|
{**mutation, "permissions": ["stats:read", "stats:read"]},
|
|
{**mutation, "permissions": [f"fixture:{i}" for i in range(2001)]},
|
|
{**mutation, "actor_id": 1}]
|
|
for body in invalid_updates:
|
|
self.assertEqual(self.update(body).status_code, 422)
|
|
for raw in (b' {"client_id":"kefu","client_id":"kefu","gid":"x","source_admin_id":7,"company_id":2}',
|
|
b'{"client_id":NaN}', b"\xff", b"not-json"):
|
|
self.assertEqual(self.fetch(None, raw=raw).status_code, 422)
|
|
|
|
def test_duplicate_security_headers_query_and_oversize_requests(self):
|
|
self.enroll()
|
|
raw = self.raw(self.identity())
|
|
for name in ("X-Group-Nonce", "X-Group-Timestamp", "X-Group-Signature"):
|
|
headers = list(self.headers(raw, authorization.OPTIONS_PATH).items())
|
|
headers.append((name, "f" * 32))
|
|
self.assertEqual(self.client.post("/api/v2" + authorization.OPTIONS_PATH, content=raw, headers=headers).status_code, 401)
|
|
self.assertEqual(self.fetch(self.identity(), query="?identity=extra").status_code, 422)
|
|
self.assertEqual(self.fetch(None, raw=b" " * (authorization.MAX_REQUEST_BYTES + 1)).status_code, 413)
|
|
|
|
def test_invalid_key_fails_closed_without_paths_or_key_material(self):
|
|
for configured in ("", "relative.key", str(self.root / "missing.key")):
|
|
with mock.patch.dict(os.environ, {"ZYT_GROUP_METADATA_KEY_FILE": configured}):
|
|
raw = self.raw(self.identity())
|
|
response = self.client.post("/api/v2" + authorization.OPTIONS_PATH,
|
|
content=raw, headers=self.headers(raw, authorization.OPTIONS_PATH))
|
|
self.assertEqual(response.status_code, 503)
|
|
self.assertNotIn(str(self.root), response.text)
|
|
self.assertNotIn(KEY.hex(), response.text)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
faulthandler.dump_traceback_later(90, exit=True)
|
|
try:
|
|
unittest.main(verbosity=2)
|
|
finally:
|
|
faulthandler.cancel_dump_traceback_later()
|