Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 162 additions & 6 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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/
38 changes: 34 additions & 4 deletions src/handlers/handler_topic.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,12 +168,18 @@ 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]:
return build_error_response(403, "auth", "User not authorized for topic")
authorized_user = self._resolve_authorized_user(topic_name, user)
if authorized_user is None:
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, 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")
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])
Expand All @@ -189,18 +195,42 @@ 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"},
"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,
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/handlers/test_handler_topic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}):
Expand Down
Loading