zecmf 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.
- zecmf-0.1.0/MANIFEST.in +1 -0
- zecmf-0.1.0/PKG-INFO +43 -0
- zecmf-0.1.0/README.md +18 -0
- zecmf-0.1.0/pyproject.toml +161 -0
- zecmf-0.1.0/setup.cfg +4 -0
- zecmf-0.1.0/src/zecmf/__init__.py +7 -0
- zecmf-0.1.0/src/zecmf/api/__init__.py +49 -0
- zecmf-0.1.0/src/zecmf/app.py +79 -0
- zecmf-0.1.0/src/zecmf/auth/__init__.py +19 -0
- zecmf-0.1.0/src/zecmf/auth/decorators.py +79 -0
- zecmf-0.1.0/src/zecmf/auth/jwt.py +86 -0
- zecmf-0.1.0/src/zecmf/cli/__init__.py +5 -0
- zecmf-0.1.0/src/zecmf/cli/commands.py +178 -0
- zecmf-0.1.0/src/zecmf/config/__init__.py +60 -0
- zecmf-0.1.0/src/zecmf/config/base.py +184 -0
- zecmf-0.1.0/src/zecmf/constants.py +39 -0
- zecmf-0.1.0/src/zecmf/extensions/__init__.py +1 -0
- zecmf-0.1.0/src/zecmf/extensions/database.py +22 -0
- zecmf-0.1.0/src/zecmf/extensions/queue.py +37 -0
- zecmf-0.1.0/src/zecmf/migrations/README.template +3 -0
- zecmf-0.1.0/src/zecmf/migrations/__init__.py +74 -0
- zecmf-0.1.0/src/zecmf/migrations/alembic.ini.template +74 -0
- zecmf-0.1.0/src/zecmf/migrations/env.py.template +97 -0
- zecmf-0.1.0/src/zecmf/migrations/script.py.mako +24 -0
- zecmf-0.1.0/src/zecmf/testing/__init__.py +3 -0
- zecmf-0.1.0/src/zecmf/testing/config.py +9 -0
- zecmf-0.1.0/src/zecmf/testing/constants.py +16 -0
- zecmf-0.1.0/src/zecmf.egg-info/PKG-INFO +43 -0
- zecmf-0.1.0/src/zecmf.egg-info/SOURCES.txt +37 -0
- zecmf-0.1.0/src/zecmf.egg-info/dependency_links.txt +1 -0
- zecmf-0.1.0/src/zecmf.egg-info/requires.txt +17 -0
- zecmf-0.1.0/src/zecmf.egg-info/top_level.txt +1 -0
- zecmf-0.1.0/tests/test_api.py +33 -0
- zecmf-0.1.0/tests/test_app.py +25 -0
- zecmf-0.1.0/tests/test_cli.py +71 -0
- zecmf-0.1.0/tests/test_decorators.py +166 -0
- zecmf-0.1.0/tests/test_hs256_auth.py +238 -0
- zecmf-0.1.0/tests/test_jwt.py +94 -0
- zecmf-0.1.0/tests/test_migrations.py +53 -0
zecmf-0.1.0/MANIFEST.in
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
recursive-include src/zecmf/migrations *.template *.mako
|
zecmf-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: zecmf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A framework for building microservices in Python
|
|
5
|
+
Author-email: Hendrik Buchwald <hb@zecure.org>
|
|
6
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
7
|
+
Requires-Python: >=3.12
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: flask==3.1.1
|
|
10
|
+
Requires-Dist: flask-restx==1.3.0
|
|
11
|
+
Requires-Dist: jsonschema==4.17.3
|
|
12
|
+
Requires-Dist: flask-sqlalchemy==3.1.1
|
|
13
|
+
Requires-Dist: flask-migrate==4.1.0
|
|
14
|
+
Requires-Dist: flask-jwt-extended==4.7.1
|
|
15
|
+
Requires-Dist: werkzeug==3.1.3
|
|
16
|
+
Requires-Dist: sqlalchemy==2.0.41
|
|
17
|
+
Requires-Dist: click==8.2.0
|
|
18
|
+
Requires-Dist: celery==5.5.2
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest==8.3.5; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov==6.1.1; extra == "dev"
|
|
22
|
+
Requires-Dist: ruff==0.11.10; extra == "dev"
|
|
23
|
+
Requires-Dist: mypy==1.15.0; extra == "dev"
|
|
24
|
+
Requires-Dist: types-Flask-Migrate==4.1.0.20250112; extra == "dev"
|
|
25
|
+
|
|
26
|
+
# Zecure Microservices Framework (ZecMF)
|
|
27
|
+
|
|
28
|
+
A lightweight framework for building microservices in Python with Flask.
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- **Application Factory**: Streamlined Flask application initialization
|
|
33
|
+
- **JWT Authentication**: Built-in JWT authentication with both RS256 and HS256 support
|
|
34
|
+
- **API Setup**: Simplified REST API initialization with Flask-RESTX
|
|
35
|
+
- **Database**: SQLAlchemy and Alembic integration
|
|
36
|
+
- **CLI Commands**: Common CLI commands for microservice management
|
|
37
|
+
- **Configuration**: Hierarchical configuration system with framework defaults and app-specific overrides
|
|
38
|
+
|
|
39
|
+
## Installation
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install zecmf
|
|
43
|
+
```
|
zecmf-0.1.0/README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Zecure Microservices Framework (ZecMF)
|
|
2
|
+
|
|
3
|
+
A lightweight framework for building microservices in Python with Flask.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Application Factory**: Streamlined Flask application initialization
|
|
8
|
+
- **JWT Authentication**: Built-in JWT authentication with both RS256 and HS256 support
|
|
9
|
+
- **API Setup**: Simplified REST API initialization with Flask-RESTX
|
|
10
|
+
- **Database**: SQLAlchemy and Alembic integration
|
|
11
|
+
- **CLI Commands**: Common CLI commands for microservice management
|
|
12
|
+
- **Configuration**: Hierarchical configuration system with framework defaults and app-specific overrides
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install zecmf
|
|
18
|
+
```
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=80.8", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "zecmf"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A framework for building microservices in Python"
|
|
9
|
+
authors = [
|
|
10
|
+
{name = "Hendrik Buchwald", email = "hb@zecure.org"},
|
|
11
|
+
]
|
|
12
|
+
readme = "README.md"
|
|
13
|
+
requires-python = ">=3.12"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
]
|
|
17
|
+
dependencies = [
|
|
18
|
+
"flask==3.1.1",
|
|
19
|
+
"flask-restx==1.3.0",
|
|
20
|
+
"jsonschema==4.17.3", # https://github.com/python-restx/flask-restx/issues/553#issuecomment-1639837981
|
|
21
|
+
"flask-sqlalchemy==3.1.1",
|
|
22
|
+
"flask-migrate==4.1.0",
|
|
23
|
+
"flask-jwt-extended==4.7.1",
|
|
24
|
+
"werkzeug==3.1.3",
|
|
25
|
+
"sqlalchemy==2.0.41",
|
|
26
|
+
"click==8.2.0",
|
|
27
|
+
"celery==5.5.2",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest==8.3.5",
|
|
33
|
+
"pytest-cov==6.1.1",
|
|
34
|
+
"ruff==0.11.10",
|
|
35
|
+
"mypy==1.15.0",
|
|
36
|
+
"types-Flask-Migrate==4.1.0.20250112",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools]
|
|
40
|
+
package-dir = {"" = "src"}
|
|
41
|
+
include-package-data = true
|
|
42
|
+
|
|
43
|
+
[tool.setuptools.packages.find]
|
|
44
|
+
where = ["src"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 88
|
|
48
|
+
indent-width = 4
|
|
49
|
+
|
|
50
|
+
[tool.ruff.lint]
|
|
51
|
+
preview = true
|
|
52
|
+
unfixable = []
|
|
53
|
+
select = [
|
|
54
|
+
"E", # pycodestyle errors
|
|
55
|
+
"F", # pyflakes
|
|
56
|
+
"I", # isort
|
|
57
|
+
"UP", # pyupgrade
|
|
58
|
+
"C", # flake8-comprehensions
|
|
59
|
+
"B", # flake8-bugbear
|
|
60
|
+
"W", # pycodestyle warnings
|
|
61
|
+
"D", # pydocstyle
|
|
62
|
+
"N", # flake8-naming
|
|
63
|
+
"COM", # flake8-commas
|
|
64
|
+
"S", # flake8-bandit
|
|
65
|
+
"BLE", # flake8-blind-except
|
|
66
|
+
"A", # flake8-builtins
|
|
67
|
+
"PT", # flake8-pytest-style
|
|
68
|
+
"SIM", # flake8-simplify
|
|
69
|
+
"TD", # flake8-todos
|
|
70
|
+
"PD", # pandas-vet
|
|
71
|
+
"PL", # pylint
|
|
72
|
+
"NPY", # numpy
|
|
73
|
+
"RUF", # ruff-specific rules
|
|
74
|
+
"ANN", # flake8-annotations
|
|
75
|
+
"TCH", # flake8-type-checking
|
|
76
|
+
"TRY", # flake8-try-except-raise
|
|
77
|
+
]
|
|
78
|
+
ignore = [
|
|
79
|
+
"COM812", # Missing trailing comma
|
|
80
|
+
"D203", # One blank line before class docstring
|
|
81
|
+
"D211", # No blank lines before class docstring
|
|
82
|
+
"D213", # Multi-line docstring summary should start at the second line
|
|
83
|
+
"S105", # Possible hardcoded password
|
|
84
|
+
"S106", # Possible hardcoded password
|
|
85
|
+
"PLR6301", # Method could be a function - common in API endpoints
|
|
86
|
+
"E501", # Line too long
|
|
87
|
+
"TRY003", # Avoid specifying long messages outside exception class
|
|
88
|
+
"PLR0913", # Too many arguments in function definition
|
|
89
|
+
"PLR0917", # Too many positional arguments
|
|
90
|
+
"S110", # try-except-pass detected, consider logging the exception
|
|
91
|
+
"BLE001", # Do not catch blind exception
|
|
92
|
+
"PLR0904", # Too many public methods
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
[tool.ruff.lint.per-file-ignores]
|
|
96
|
+
"tests/*.py" = [
|
|
97
|
+
"ANN001", # Missing type annotation for function argument
|
|
98
|
+
"ANN101", # Missing type annotation for self in method
|
|
99
|
+
"ANN201", # Missing return type annotation for public function
|
|
100
|
+
"ANN204", # Missing return type annotation for special method
|
|
101
|
+
"S101", # Use of assert detected
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
[tool.ruff.lint.isort]
|
|
105
|
+
known-first-party = ["zecmf"]
|
|
106
|
+
section-order = [
|
|
107
|
+
"future",
|
|
108
|
+
"standard-library",
|
|
109
|
+
"third-party",
|
|
110
|
+
"first-party",
|
|
111
|
+
"local-folder"
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
[tool.ruff.format]
|
|
115
|
+
quote-style = "double"
|
|
116
|
+
indent-style = "space"
|
|
117
|
+
skip-magic-trailing-comma = false
|
|
118
|
+
line-ending = "auto"
|
|
119
|
+
|
|
120
|
+
[tool.mypy]
|
|
121
|
+
exclude = "build/"
|
|
122
|
+
|
|
123
|
+
# Module resolution settings
|
|
124
|
+
namespace_packages = true
|
|
125
|
+
implicit_reexport = true
|
|
126
|
+
follow_imports = "silent"
|
|
127
|
+
|
|
128
|
+
# Type checking strictness
|
|
129
|
+
disallow_untyped_defs = true
|
|
130
|
+
disallow_incomplete_defs = true
|
|
131
|
+
disallow_untyped_calls = false
|
|
132
|
+
check_untyped_defs = true
|
|
133
|
+
disallow_untyped_decorators = false
|
|
134
|
+
disallow_subclassing_any = false
|
|
135
|
+
disallow_any_unimported = false
|
|
136
|
+
disallow_any_generics = false
|
|
137
|
+
|
|
138
|
+
# Error reporting settings
|
|
139
|
+
no_implicit_optional = true
|
|
140
|
+
strict_optional = true
|
|
141
|
+
warn_redundant_casts = true
|
|
142
|
+
warn_no_return = true
|
|
143
|
+
warn_return_any = false # Don't warn about returning Any
|
|
144
|
+
warn_unused_ignores = false # Allow necessary type ignores
|
|
145
|
+
|
|
146
|
+
[[tool.mypy.overrides]]
|
|
147
|
+
module = "celery.*"
|
|
148
|
+
ignore_missing_imports = true
|
|
149
|
+
|
|
150
|
+
[[tool.mypy.overrides]]
|
|
151
|
+
module = "flask_restx.*"
|
|
152
|
+
ignore_missing_imports = true
|
|
153
|
+
|
|
154
|
+
[tool.pytest.ini_options]
|
|
155
|
+
testpaths = ["tests"]
|
|
156
|
+
python_files = "test_*.py"
|
|
157
|
+
python_classes = ["Test*"]
|
|
158
|
+
markers = [
|
|
159
|
+
"no_test: marks classes that should not be collected as tests"
|
|
160
|
+
]
|
|
161
|
+
addopts = "--import-mode=importlib"
|
zecmf-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""API module for Flask-RESTX integration."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from flask import Blueprint, Flask
|
|
6
|
+
from flask_restx import Api
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def init_api(
|
|
10
|
+
app: Flask,
|
|
11
|
+
) -> Api:
|
|
12
|
+
"""Initialize the API on the Flask app.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
app: The Flask application
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
The configured API instance
|
|
19
|
+
|
|
20
|
+
"""
|
|
21
|
+
# Get API configuration from app.config with defaults
|
|
22
|
+
prefix = app.config.get("API_PREFIX", "/api")
|
|
23
|
+
title = app.config.get("API_TITLE", "API")
|
|
24
|
+
version = app.config.get("API_VERSION", "1.0.0")
|
|
25
|
+
description = app.config.get("API_DESCRIPTION", "...")
|
|
26
|
+
|
|
27
|
+
# Create API blueprint with prefix
|
|
28
|
+
blueprint = Blueprint("api", __name__, url_prefix=prefix)
|
|
29
|
+
|
|
30
|
+
# Configure API with provided settings
|
|
31
|
+
api_config: dict[str, Any] = {
|
|
32
|
+
"doc": False,
|
|
33
|
+
"authorizations": {
|
|
34
|
+
"Bearer": {"type": "apiKey", "in": "header", "name": "Authorization"}
|
|
35
|
+
},
|
|
36
|
+
"security": "Bearer",
|
|
37
|
+
"title": title,
|
|
38
|
+
"version": version,
|
|
39
|
+
"description": description,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Create and register API
|
|
43
|
+
api = Api(blueprint, **api_config)
|
|
44
|
+
app.register_blueprint(blueprint)
|
|
45
|
+
|
|
46
|
+
return api
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
__all__ = ["init_api"]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Application factory for creating Flask microservice applications with standardized configuration."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from flask import Flask
|
|
7
|
+
from flask_restx import Namespace
|
|
8
|
+
|
|
9
|
+
from zecmf.api import init_api
|
|
10
|
+
from zecmf.auth import init_jwt
|
|
11
|
+
from zecmf.cli import register_commands
|
|
12
|
+
from zecmf.config import BaseConfig, get_config
|
|
13
|
+
from zecmf.constants import Config as ConfigConstants
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_app(
|
|
19
|
+
config_name: str,
|
|
20
|
+
api_namespaces: list[tuple[Namespace, str]],
|
|
21
|
+
app_config_module: str = ConfigConstants.APP_CONFIG_MODULE,
|
|
22
|
+
) -> Flask:
|
|
23
|
+
"""Create and configure a Flask microservice application.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
config_name: The name of the configuration to use (development, production, etc).
|
|
27
|
+
api_namespaces: List of namespaces to register with the API.
|
|
28
|
+
app_config_module: The module path for the app-specific configuration.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A configured Flask application.
|
|
32
|
+
|
|
33
|
+
"""
|
|
34
|
+
logger.debug(f"Creating app with config_name: {config_name}")
|
|
35
|
+
|
|
36
|
+
if not config_name:
|
|
37
|
+
logger.warning(
|
|
38
|
+
"No config_name provided. Trying to load from environment variable."
|
|
39
|
+
)
|
|
40
|
+
config_name = os.getenv("FLASK_ENV", "development")
|
|
41
|
+
|
|
42
|
+
app_config = _resolve_and_validate_config(config_name, app_config_module)
|
|
43
|
+
app = _initialize_flask_app(app_config)
|
|
44
|
+
_setup_auth(app)
|
|
45
|
+
_setup_api(app, api_namespaces)
|
|
46
|
+
register_commands(app)
|
|
47
|
+
return app
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_and_validate_config(
|
|
51
|
+
config_name: str, app_config_module: str
|
|
52
|
+
) -> BaseConfig:
|
|
53
|
+
"""Resolve and instantiate the configuration class."""
|
|
54
|
+
config_class = get_config(config_name, app_config_module)
|
|
55
|
+
logger.debug(f"Resolved config class: {config_class.__name__}")
|
|
56
|
+
config_instance = config_class() # Validation on instantiation
|
|
57
|
+
return config_instance
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _initialize_flask_app(app_config: BaseConfig) -> Flask:
|
|
61
|
+
"""Create and configure the Flask app instance."""
|
|
62
|
+
app = Flask("app")
|
|
63
|
+
app.config.from_object(app_config)
|
|
64
|
+
return app
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _setup_auth(app: Flask) -> None:
|
|
68
|
+
"""Initialize JWT authentication."""
|
|
69
|
+
init_jwt(app)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _setup_api(
|
|
73
|
+
app: Flask,
|
|
74
|
+
api_namespaces: list[tuple[Namespace, str]],
|
|
75
|
+
) -> None:
|
|
76
|
+
"""Configure API and register namespaces."""
|
|
77
|
+
api = init_api(app)
|
|
78
|
+
for namespace, path in api_namespaces:
|
|
79
|
+
api.add_namespace(namespace, path=path)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Authentication module for micro-framework."""
|
|
2
|
+
|
|
3
|
+
from zecmf.auth.decorators import (
|
|
4
|
+
public_endpoint,
|
|
5
|
+
require_admin_role,
|
|
6
|
+
require_agent_role,
|
|
7
|
+
require_role,
|
|
8
|
+
require_user_role,
|
|
9
|
+
)
|
|
10
|
+
from zecmf.auth.jwt import init_jwt
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"init_jwt",
|
|
14
|
+
"public_endpoint",
|
|
15
|
+
"require_admin_role",
|
|
16
|
+
"require_agent_role",
|
|
17
|
+
"require_role",
|
|
18
|
+
"require_user_role",
|
|
19
|
+
]
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Authentication decorators for role-based access control."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from functools import wraps
|
|
5
|
+
from typing import Any, TypeVar, cast
|
|
6
|
+
|
|
7
|
+
from flask import g
|
|
8
|
+
from werkzeug.exceptions import Forbidden
|
|
9
|
+
|
|
10
|
+
# Type variable for generic functions
|
|
11
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DecoratorFactory:
|
|
15
|
+
"""Factory to create decorators only when needed in a request context."""
|
|
16
|
+
|
|
17
|
+
@staticmethod
|
|
18
|
+
def require_role(role: str) -> Callable[[F], F]:
|
|
19
|
+
"""Create a decorator to require a specific role."""
|
|
20
|
+
|
|
21
|
+
def decorator(f: F) -> F:
|
|
22
|
+
@wraps(f)
|
|
23
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
24
|
+
# Check if user has the required role
|
|
25
|
+
if not hasattr(g, "user_roles") or role not in g.user_roles:
|
|
26
|
+
raise Forbidden(f"Requires role: {role}")
|
|
27
|
+
|
|
28
|
+
return f(*args, **kwargs)
|
|
29
|
+
|
|
30
|
+
return cast("F", wrapper)
|
|
31
|
+
|
|
32
|
+
return decorator
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def public_endpoint() -> Callable[[F], F]:
|
|
36
|
+
"""Create a decorator to mark an endpoint as public.
|
|
37
|
+
|
|
38
|
+
This decorator can be applied to:
|
|
39
|
+
- Regular Flask routes (function-based views)
|
|
40
|
+
- Specific HTTP methods in Flask-RESTX Resources (apply to method)
|
|
41
|
+
|
|
42
|
+
Note: For Flask-RESTX Resource classes, do not apply this decorator directly to
|
|
43
|
+
the class. Instead, apply it to individual methods.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def decorator(f: F) -> F:
|
|
47
|
+
# Mark the original function as public
|
|
48
|
+
f.is_public = True # type: ignore
|
|
49
|
+
|
|
50
|
+
@wraps(f)
|
|
51
|
+
def wrapper(*args: object, **kwargs: object) -> object:
|
|
52
|
+
return f(*args, **kwargs)
|
|
53
|
+
|
|
54
|
+
# Mark the wrapper function as public
|
|
55
|
+
wrapper.is_public = True # type: ignore
|
|
56
|
+
|
|
57
|
+
return cast("F", wrapper)
|
|
58
|
+
|
|
59
|
+
return decorator
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Create decorator factory instance
|
|
63
|
+
decorator_factory = DecoratorFactory()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def require_role(role: str) -> Callable[[F], F]:
|
|
67
|
+
"""Require a specific role for an endpoint."""
|
|
68
|
+
return decorator_factory.require_role(role)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def public_endpoint() -> Callable[[F], F]:
|
|
72
|
+
"""Mark an endpoint as public, exempt from authentication requirements."""
|
|
73
|
+
return decorator_factory.public_endpoint()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Pre-defined role decorators for convenience
|
|
77
|
+
require_admin_role = require_role("admin")
|
|
78
|
+
require_user_role = require_role("user")
|
|
79
|
+
require_agent_role = require_role("agent")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""JWT authentication module for micro-framework."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar
|
|
5
|
+
|
|
6
|
+
from flask import Flask, Response, current_app, g, request
|
|
7
|
+
from flask_jwt_extended import (
|
|
8
|
+
JWTManager,
|
|
9
|
+
get_jwt,
|
|
10
|
+
get_jwt_identity,
|
|
11
|
+
verify_jwt_in_request,
|
|
12
|
+
)
|
|
13
|
+
from werkzeug.exceptions import Unauthorized
|
|
14
|
+
|
|
15
|
+
# Type variable for generic functions
|
|
16
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
jwt = JWTManager()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _check_public_endpoint(endpoint_func: Callable[..., Any]) -> bool:
|
|
23
|
+
"""Check if an endpoint is marked as public."""
|
|
24
|
+
# Check if it's a class-based view (like Flask-RESTX resources)
|
|
25
|
+
if hasattr(endpoint_func, "view_class"):
|
|
26
|
+
view_class = endpoint_func.view_class
|
|
27
|
+
|
|
28
|
+
# Check class for the is_public attribute
|
|
29
|
+
if hasattr(view_class, "is_public") and view_class.is_public:
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
# Check the specific method for the is_public attribute
|
|
33
|
+
method = request.method.lower()
|
|
34
|
+
if hasattr(view_class, method):
|
|
35
|
+
handler = getattr(view_class, method)
|
|
36
|
+
if hasattr(handler, "is_public") and handler.is_public:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
# For regular function-based views
|
|
40
|
+
elif hasattr(endpoint_func, "is_public") and endpoint_func.is_public:
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
return False
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _authenticate_request() -> None:
|
|
47
|
+
"""Authenticate the current request using JWT."""
|
|
48
|
+
try:
|
|
49
|
+
verify_jwt_in_request()
|
|
50
|
+
|
|
51
|
+
# Get JWT claims
|
|
52
|
+
claims = get_jwt()
|
|
53
|
+
user_roles = claims.get("roles", [])
|
|
54
|
+
|
|
55
|
+
# Store user info in flask g object for use in the request
|
|
56
|
+
g.user_id = get_jwt_identity()
|
|
57
|
+
g.user_roles = user_roles
|
|
58
|
+
except Exception as err:
|
|
59
|
+
raise Unauthorized("Authentication required") from err
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def init_jwt(app: Flask) -> None:
|
|
63
|
+
"""Initialize JWT manager with the Flask app."""
|
|
64
|
+
jwt.init_app(app)
|
|
65
|
+
|
|
66
|
+
# Register before_request handler to enforce authentication by default
|
|
67
|
+
@app.before_request
|
|
68
|
+
def enforce_authentication() -> Response | None:
|
|
69
|
+
"""Enforce authentication for each request unless marked as public."""
|
|
70
|
+
# Skip OPTIONS requests for CORS support
|
|
71
|
+
if request.method == "OPTIONS":
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Allow access to swagger.json only in debug mode
|
|
75
|
+
if app.debug and request.path.endswith("/swagger.json"):
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
# Check if the endpoint is public
|
|
79
|
+
if request.endpoint is not None:
|
|
80
|
+
endpoint_func = current_app.view_functions.get(request.endpoint)
|
|
81
|
+
if endpoint_func is not None and _check_public_endpoint(endpoint_func):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
# If we're here, authentication is required
|
|
85
|
+
_authenticate_request()
|
|
86
|
+
return None
|