From 405597ce350e4f3e0940ae63709366b0e52d9d60 Mon Sep 17 00:00:00 2001 From: Ladislav Sulak Date: Fri, 10 Jul 2026 17:27:55 +0200 Subject: [PATCH 1/3] #194: improving auth user match checking --- src/handlers/handler_topic.py | 22 ++++++++++++++++++++-- tests/unit/handlers/test_handler_topic.py | 18 ++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/handlers/handler_topic.py b/src/handlers/handler_topic.py index 34b0107..5d639ca 100644 --- a/src/handlers/handler_topic.py +++ b/src/handlers/handler_topic.py @@ -168,10 +168,11 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to return build_error_response(404, "topic", f"Topic '{topic_name}' not found") user = token.get("sub") - if topic_name not in self.access_config or user not in self.access_config[topic_name]: + authorized_user = self._resolve_authorized_user(topic_name, user) + if authorized_user is None: return build_error_response(403, "auth", "User not authorized for topic") - allowed, perm_error = self._validate_user_permissions(topic_name, user, topic_message) + allowed, perm_error = self._validate_user_permissions(topic_name, authorized_user, topic_message) if not allowed: return build_error_response(403, "permission", perm_error or "Permission denied") @@ -201,6 +202,23 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to "body": json.dumps({"success": True, "statusCode": 202}), } + def _resolve_authorized_user(self, topic_name: str, user: str | None) -> str | None: + """Match a token user to a configured user for a topic, ignoring case. + Args: + topic_name: Target topic name. + user: User identifier from the token `sub` claim. + Returns: + The configured username (original casing) when authorized, otherwise `None`. + """ + if user is None or topic_name not in self.access_config: + return None + + for configured_user in self.access_config[topic_name]: + if configured_user.casefold() == user.casefold(): + return configured_user + + return None + def _validate_user_permissions( self, topic_name: str, diff --git a/tests/unit/handlers/test_handler_topic.py b/tests/unit/handlers/test_handler_topic.py index a8123a8..f82035f 100644 --- a/tests/unit/handlers/test_handler_topic.py +++ b/tests/unit/handlers/test_handler_topic.py @@ -257,6 +257,24 @@ def test_post_success_all_writers(event_gate_module, make_event, valid_payload): assert 202 == body["statusCode"] +def test_post_authorized_user_case_insensitive(event_gate_module, make_event, valid_payload): + with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "testuser"}): + for writer in event_gate_module.handler_topic.writers.values(): + writer.write = MagicMock(return_value=None) + + event = make_event( + "/topics/{topic_name}", + method="POST", + topic="public.cps.za.test", + body=valid_payload, + headers={"Authorization": "Bearer token"}, + ) + resp = event_gate_module.lambda_handler(event) + assert 202 == resp["statusCode"] + body = json.loads(resp["body"]) + assert body["success"] + + def test_post_passes_topic_key_to_writers(event_gate_module, make_event, valid_payload): """Configured topic key field is extracted and passed to writer.write.""" with patch.object(event_gate_module.handler_token, "decode_jwt", return_value={"sub": "TestUser"}): From 50b7589cf33bb111d9f3cea24abb9d1fbd16cc93 Mon Sep 17 00:00:00 2001 From: Ladislav Sulak Date: Fri, 10 Jul 2026 17:29:50 +0200 Subject: [PATCH 2/3] adding fully mature pythonic gitignore (source: CAL repo) --- .gitignore | 168 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 162 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 4e0419b..53dba4e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,164 @@ -.ipynb_checkpoints +# =========================== +# OS files +# =========================== +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini + +# =========================== +# IDE and editors +# =========================== +.vscode/ +.idea/ +*.swp +*.swo +*~ +.spyderproject +.spyproject +.ropeproject + +# =========================== +# Environment and secrets +# =========================== +.env +.env.local +.env.*.local + +# =========================== +# Python – byte-compiled / optimized +# =========================== +__pycache__/ +*.py[cod] +*$py.class +*.so +cython_debug/ + +# =========================== +# Python – packaging and distribution +# =========================== +.Python +build/ +dist/ +develop-eggs/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# =========================== +# Python – virtual environments +# =========================== .venv -__pycache__ -/.idea/ -/dependencies -/lambda_function.zip +venv/ +env/ +ENV/ +env.bak/ +venv.bak/ +__pypackages__/ + +# =========================== +# Python – tools and type checkers +# =========================== +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +.pdm.toml +.pdm-python +.pdm-build/ +.ruff_cache/ + +# =========================== +# Python – tests and coverage +# =========================== +.coverage +.coverage.* +htmlcov/ +.tox/ +.nox/ +.cache +.pytest_cache/ +.hypothesis/ +nosetests.xml +coverage.xml +*.cover +*.py,cover +cover/ + +# =========================== +# Python – installer logs +# =========================== +pip-log.txt +pip-delete-this-directory.txt +*.manifest +*.spec + +# =========================== +# Python – misc frameworks +# =========================== +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +instance/ +.webassets-cache +.scrapy +celerybeat-schedule +celerybeat.pid +*.sage.py +*.mo +*.pot + +# =========================== +# Documentation +# =========================== +docs/_build/ +/site +.ipynb_checkpoints +profile_default/ +ipython_config.py + +# =========================== +# Terraform and Terragrunt +# =========================== +.terraform/ +*.tfstate +*.tfstate.* +crash.log +crash.*.log +override.tf +override.tf.json +*_override.tf +*_override.tf.json +.terraformrc +terraform.rc +.terraform.lock.hcl +.terragrunt-cache/ + +# =========================== +# Corporate CA certificates +# Actual cert files must never be committed -- provide via CI/CD secrets. +# The trusted_certs/ directory is kept (README only) to document the convention. +# =========================== +trusted_certs/*.pem +trusted_certs/*.crt -*.sarif +# =========================== +# Temporary files and logs +# =========================== +tmp/ +temp/ +*.tmp +logs/ From c4117b1ec273f83eecd923e9fa83a7d24d7788d7 Mon Sep 17 00:00:00 2001 From: Ladislav Sulak Date: Fri, 10 Jul 2026 17:47:33 +0200 Subject: [PATCH 3/3] improving logging of area impacted by the PR --- src/handlers/handler_topic.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/handlers/handler_topic.py b/src/handlers/handler_topic.py index 5d639ca..7c1bc44 100644 --- a/src/handlers/handler_topic.py +++ b/src/handlers/handler_topic.py @@ -170,11 +170,16 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to user = token.get("sub") authorized_user = self._resolve_authorized_user(topic_name, user) if authorized_user is None: - return build_error_response(403, "auth", "User not authorized for topic") + return build_error_response(403, "auth", f"User '{user}' is not authorized for topic '{topic_name}'") allowed, perm_error = self._validate_user_permissions(topic_name, authorized_user, topic_message) if not allowed: - return build_error_response(403, "permission", perm_error or "Permission denied") + return build_error_response( + 403, "permission", + perm_error or f"Permission denied for user '{authorized_user}' for POST to topic '{topic_name}'" + ) + + logger.debug("Authorized user '%s' for topic '%s'.", authorized_user, topic_name) try: validate(instance=topic_message, schema=self.topics[topic_name]) @@ -190,12 +195,19 @@ def _post_topic_message(self, topic_name: str, topic_message: dict[str, Any], to errors.append({"type": writer_name, "message": str(exc)}) if errors: + logger.error( + "POST to topic '%s' failed: %d of %d writer(s) reported errors.", + topic_name, + len(errors), + len(self.writers), + ) return { "statusCode": 500, "headers": {"Content-Type": "application/json"}, "body": json.dumps({"success": False, "statusCode": 500, "errors": errors}), } + logger.info("Message accepted for topic '%s' from user '%s'.", topic_name, authorized_user) return { "statusCode": 202, "headers": {"Content-Type": "application/json"},