flowforge-io 1.1.0__py3-none-any.whl

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.
Files changed (122) hide show
  1. flowforge/__init__.py +1 -0
  2. flowforge/api/__init__.py +0 -0
  3. flowforge/api/app.py +233 -0
  4. flowforge/api/auth.py +153 -0
  5. flowforge/api/routes/__init__.py +0 -0
  6. flowforge/api/routes/ai.py +310 -0
  7. flowforge/api/routes/audit.py +114 -0
  8. flowforge/api/routes/auth.py +56 -0
  9. flowforge/api/routes/bulk_loads.py +118 -0
  10. flowforge/api/routes/connections.py +230 -0
  11. flowforge/api/routes/emails.py +171 -0
  12. flowforge/api/routes/metrics.py +60 -0
  13. flowforge/api/routes/mfa.py +204 -0
  14. flowforge/api/routes/password_reset.py +150 -0
  15. flowforge/api/routes/pipelines.py +722 -0
  16. flowforge/api/routes/projects.py +125 -0
  17. flowforge/api/routes/providers.py +118 -0
  18. flowforge/api/routes/recipients.py +95 -0
  19. flowforge/api/routes/reports.py +148 -0
  20. flowforge/api/routes/runs.py +361 -0
  21. flowforge/api/routes/setup.py +93 -0
  22. flowforge/api/routes/sso.py +294 -0
  23. flowforge/api/routes/steps.py +116 -0
  24. flowforge/api/routes/users.py +194 -0
  25. flowforge/api/serializers.py +41 -0
  26. flowforge/api/validators.py +73 -0
  27. flowforge/audit.py +226 -0
  28. flowforge/celery_app.py +65 -0
  29. flowforge/cli.py +477 -0
  30. flowforge/connections/__init__.py +0 -0
  31. flowforge/connections/base.py +34 -0
  32. flowforge/connections/factory.py +64 -0
  33. flowforge/connections/mssql.py +105 -0
  34. flowforge/connections/mysql.py +92 -0
  35. flowforge/connections/odbc.py +96 -0
  36. flowforge/connections/oracle.py +101 -0
  37. flowforge/connections/postgres.py +76 -0
  38. flowforge/connections/ssh.py +128 -0
  39. flowforge/crypto.py +85 -0
  40. flowforge/db/__init__.py +0 -0
  41. flowforge/db/migrations/__init__.py +0 -0
  42. flowforge/db/migrations/env.py +71 -0
  43. flowforge/db/migrations/versions/0001_baseline.py +213 -0
  44. flowforge/db/migrations/versions/0002_timezone_timestamps.py +50 -0
  45. flowforge/db/migrations/versions/0003_indexes_and_constraints.py +53 -0
  46. flowforge/db/migrations/versions/0004_json_report_format.py +32 -0
  47. flowforge/db/migrations/versions/0005_bulk_load_configs.py +60 -0
  48. flowforge/db/migrations/versions/0006_projects.py +70 -0
  49. flowforge/db/migrations/versions/0007_pipeline_variables_index.py +29 -0
  50. flowforge/db/migrations/versions/0008_token_blocklist.py +30 -0
  51. flowforge/db/migrations/versions/0009_narrow_db_type_constraint.py +38 -0
  52. flowforge/db/migrations/versions/0010_webhook_tokens.py +37 -0
  53. flowforge/db/migrations/versions/0011_fix_step_type_constraint.py +37 -0
  54. flowforge/db/migrations/versions/0012_onedrive_support.py +47 -0
  55. flowforge/db/migrations/versions/0013_ai_analyze_step.py +41 -0
  56. flowforge/db/migrations/versions/0014_sftp_transfer_step.py +41 -0
  57. flowforge/db/migrations/versions/0015_datetime_timezone.py +69 -0
  58. flowforge/db/migrations/versions/0016_pipeline_failure_webhook.py +26 -0
  59. flowforge/db/migrations/versions/0017_mysql_connection_type.py +32 -0
  60. flowforge/db/migrations/versions/0018_user_role.py +32 -0
  61. flowforge/db/migrations/versions/0019_backfill_admin_role.py +33 -0
  62. flowforge/db/migrations/versions/0020_mfa_sso.py +38 -0
  63. flowforge/db/migrations/versions/0021_mssql_odbc_pwreset.py +55 -0
  64. flowforge/db/migrations/versions/0023_pipeline_deps_parallel.py +57 -0
  65. flowforge/db/migrations/versions/0024_report_column_formatting.py +33 -0
  66. flowforge/db/migrations/versions/0025_new_providers_notification.py +57 -0
  67. flowforge/db/migrations/versions/6158f44dafca_add_auditlog_model.py +44 -0
  68. flowforge/db/migrations/versions/9c08f36f9ef8_add_send_only_on_failure_to_ff_pipelines.py +28 -0
  69. flowforge/db/migrations/versions/__init__.py +0 -0
  70. flowforge/db/migrations/versions/b7a76582c1ea_add_sshconnection_and_update_step_type_.py +57 -0
  71. flowforge/db/migrations/versions/c4e8f2a1b9d3_add_ssh_health_check_step_type.py +44 -0
  72. flowforge/db/models.py +389 -0
  73. flowforge/email_providers/__init__.py +0 -0
  74. flowforge/email_providers/base.py +27 -0
  75. flowforge/email_providers/factory.py +92 -0
  76. flowforge/email_providers/gmail.py +96 -0
  77. flowforge/email_providers/mailgun.py +85 -0
  78. flowforge/email_providers/microsoft365.py +103 -0
  79. flowforge/email_providers/sendgrid.py +82 -0
  80. flowforge/email_providers/ses.py +103 -0
  81. flowforge/email_providers/smtp.py +93 -0
  82. flowforge/engine/__init__.py +0 -0
  83. flowforge/engine/cleanup.py +57 -0
  84. flowforge/engine/context.py +229 -0
  85. flowforge/engine/launcher.py +97 -0
  86. flowforge/engine/loader.py +83 -0
  87. flowforge/engine/runner.py +511 -0
  88. flowforge/engine/scheduler.py +244 -0
  89. flowforge/engine/shutdown.py +155 -0
  90. flowforge/reports/__init__.py +0 -0
  91. flowforge/reports/csv_report.py +24 -0
  92. flowforge/reports/excel_report.py +127 -0
  93. flowforge/reports/health_report.py +75 -0
  94. flowforge/reports/json_report.py +20 -0
  95. flowforge/reports/pdf_report.py +73 -0
  96. flowforge/steps/__init__.py +0 -0
  97. flowforge/steps/ai_analyze.py +188 -0
  98. flowforge/steps/base.py +54 -0
  99. flowforge/steps/bulk_load.py +528 -0
  100. flowforge/steps/data_load.py +305 -0
  101. flowforge/steps/db_health_check.py +211 -0
  102. flowforge/steps/db_procedure.py +50 -0
  103. flowforge/steps/db_query.py +133 -0
  104. flowforge/steps/drive_upload.py +34 -0
  105. flowforge/steps/email_step.py +222 -0
  106. flowforge/steps/notification.py +136 -0
  107. flowforge/steps/onedrive_upload.py +35 -0
  108. flowforge/steps/report.py +118 -0
  109. flowforge/steps/script_report.py +125 -0
  110. flowforge/steps/sftp_transfer.py +367 -0
  111. flowforge/steps/ssh_command.py +113 -0
  112. flowforge/steps/ssh_health_check.py +170 -0
  113. flowforge/storage/__init__.py +0 -0
  114. flowforge/storage/google_drive.py +90 -0
  115. flowforge/storage/onedrive.py +137 -0
  116. flowforge/tasks.py +44 -0
  117. flowforge_io-1.1.0.dist-info/METADATA +502 -0
  118. flowforge_io-1.1.0.dist-info/RECORD +122 -0
  119. flowforge_io-1.1.0.dist-info/WHEEL +5 -0
  120. flowforge_io-1.1.0.dist-info/entry_points.txt +2 -0
  121. flowforge_io-1.1.0.dist-info/licenses/LICENSE +21 -0
  122. flowforge_io-1.1.0.dist-info/top_level.txt +1 -0
flowforge/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
File without changes
flowforge/api/app.py ADDED
@@ -0,0 +1,233 @@
1
+ """Flask application factory."""
2
+ import ipaddress
3
+ import logging
4
+ import os
5
+ from datetime import UTC
6
+ from pathlib import Path
7
+
8
+ from flask import Flask, jsonify, request, send_from_directory
9
+ from flask_cors import CORS
10
+ from flask_limiter import Limiter
11
+ from flask_limiter.util import get_remote_address
12
+ from werkzeug.middleware.proxy_fix import ProxyFix
13
+
14
+ from flowforge.db.models import db
15
+
16
+ limiter = Limiter(key_func=get_remote_address, default_limits=[])
17
+
18
+ # ── constants ──
19
+ _NOT_FOUND = 'Not found'
20
+
21
+ _DIST = Path(__file__).parent.parent.parent / 'frontend' / 'dist'
22
+ _DOCS = Path(__file__).parent.parent.parent / 'docs'
23
+
24
+
25
+ def create_app(config: dict | None = None) -> Flask:
26
+ # Configure root logger from LOG_LEVEL env var — no-op if CLI already called basicConfig.
27
+ _level = getattr(logging, os.environ.get('LOG_LEVEL', 'INFO').upper(), logging.INFO)
28
+ logging.basicConfig(level=_level, format='%(asctime)s %(levelname)s %(name)s — %(message)s')
29
+
30
+ app = Flask(__name__)
31
+
32
+ app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get(
33
+ 'FLOWFORGE_DB_URL', 'postgresql://flowforge:flowforge@localhost:5432/flowforge' # NOSONAR
34
+ )
35
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
36
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16 MB — prevents OOM on large POST bodies
37
+
38
+ # Connection pool tuning — tune via env vars for multi-worker Gunicorn deployments.
39
+ # Rule of thumb: POOL_SIZE × gunicorn_workers ≤ max_connections on PostgreSQL side.
40
+ app.config['SQLALCHEMY_POOL_SIZE'] = int(os.environ.get('SQLALCHEMY_POOL_SIZE', '5'))
41
+ app.config['SQLALCHEMY_MAX_OVERFLOW'] = int(os.environ.get('SQLALCHEMY_MAX_OVERFLOW', '10'))
42
+ app.config['SQLALCHEMY_POOL_TIMEOUT'] = int(os.environ.get('SQLALCHEMY_POOL_TIMEOUT', '30'))
43
+ app.config['SQLALCHEMY_POOL_RECYCLE'] = int(os.environ.get('SQLALCHEMY_POOL_RECYCLE', '1800'))
44
+
45
+ # AES-256 encryption key — used exclusively by flowforge/crypto.py
46
+ app.config['SECRET_KEY'] = os.environ.get('FLOWFORGE_SECRET_KEY', '') # NOSONAR
47
+
48
+ # JWT signing secret — separate from the encryption key (SEC-2)
49
+ # Falls back to SECRET_KEY for backward compatibility; set FLOWFORGE_JWT_SECRET in production.
50
+ jwt_secret = os.environ.get('FLOWFORGE_JWT_SECRET', '')
51
+ if not jwt_secret:
52
+ jwt_secret = app.config['SECRET_KEY']
53
+ if jwt_secret and not (config or {}).get('TESTING'):
54
+ import warnings
55
+ warnings.warn(
56
+ 'FLOWFORGE_JWT_SECRET is not set — falling back to FLOWFORGE_SECRET_KEY for JWT '
57
+ 'signing. Set a separate FLOWFORGE_JWT_SECRET in production.',
58
+ stacklevel=2,
59
+ )
60
+ app.config['JWT_SECRET'] = jwt_secret
61
+ app.config['JWT_ALGORITHM'] = 'HS256'
62
+ app.config['JWT_EXPIRY_HOURS'] = 24
63
+
64
+ if config:
65
+ app.config.update(config)
66
+
67
+ # ProxyFix — unwraps X-Forwarded-For so the rate limiter sees the real client IP (SEC-3)
68
+ # Set FLOWFORGE_TRUSTED_PROXIES=1 when running behind nginx/Traefik/ALB/etc.
69
+ num_proxies = int(os.environ.get('FLOWFORGE_TRUSTED_PROXIES', '0'))
70
+ if num_proxies > 0:
71
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_for=num_proxies, x_proto=num_proxies, x_host=num_proxies)
72
+
73
+ db.init_app(app)
74
+ limiter.init_app(app)
75
+
76
+ if os.environ.get('FLOWFORGE_REDIS_URL'):
77
+ from flowforge.celery_app import init_celery
78
+ init_celery(app)
79
+
80
+ # CORS — warn loudly if FLOWFORGE_CORS_ORIGIN is not set in production (SEC-6)
81
+ cors_origin = os.environ.get('FLOWFORGE_CORS_ORIGIN', '')
82
+ if not cors_origin:
83
+ flask_env = os.environ.get('FLASK_ENV', 'development')
84
+ is_testing = (config or {}).get('TESTING', False)
85
+ if flask_env == 'production' and not is_testing:
86
+ app.logger.warning(
87
+ 'SECURITY: FLOWFORGE_CORS_ORIGIN is not set. '
88
+ 'All cross-origin browser requests to /api/* will be blocked. '
89
+ 'Set FLOWFORGE_CORS_ORIGIN=https://your-domain.com in production.'
90
+ )
91
+ cors_origin = 'http://localhost:5173' # dev/test fallback only
92
+ CORS(app, resources={r'/api/*': {'origins': cors_origin}})
93
+
94
+ _register_blueprints(app)
95
+ _register_error_handlers(app)
96
+ _register_ip_allowlist(app)
97
+
98
+ with app.app_context():
99
+ _sweep_stuck_runs(app)
100
+
101
+ return app
102
+
103
+
104
+ def _register_ip_allowlist(app: Flask) -> None:
105
+ """If FLOWFORGE_ALLOWED_IPS is set, reject /api/* requests from non-listed IPs."""
106
+ raw = os.environ.get('FLOWFORGE_ALLOWED_IPS', '').strip()
107
+ if not raw:
108
+ return
109
+
110
+ networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
111
+ for cidr in raw.split(','):
112
+ cidr = cidr.strip()
113
+ if not cidr:
114
+ continue
115
+ try:
116
+ networks.append(ipaddress.ip_network(cidr, strict=False))
117
+ except ValueError:
118
+ app.logger.warning('FLOWFORGE_ALLOWED_IPS: invalid CIDR %r — skipped', cidr)
119
+
120
+ if not networks:
121
+ return
122
+
123
+ @app.before_request
124
+ def _check_ip():
125
+ if not request.path.startswith('/api/'):
126
+ return None
127
+ client_ip = request.remote_addr or ''
128
+ try:
129
+ addr = ipaddress.ip_address(client_ip)
130
+ if not any(addr in net for net in networks):
131
+ return jsonify({'error': 'Access denied: your IP is not allowed'}), 403
132
+ except ValueError:
133
+ return jsonify({'error': 'Access denied: invalid client IP'}), 403
134
+ return None
135
+
136
+
137
+ def _sweep_stuck_runs(app: Flask) -> None:
138
+ """Mark any pipeline runs left in 'running' state as failed (interrupted by restart)."""
139
+ try:
140
+ from datetime import datetime
141
+
142
+ from flowforge.db.models import PipelineRun
143
+ stuck = db.session.query(PipelineRun).filter_by(status='running').all()
144
+ if not stuck:
145
+ return
146
+ for run in stuck:
147
+ run.status = 'failed'
148
+ run.error_message = 'Run interrupted by server restart'
149
+ if not run.finished_at:
150
+ run.finished_at = datetime.now(UTC)
151
+ db.session.commit()
152
+ app.logger.warning('Swept %d stuck pipeline run(s) left from previous session.', len(stuck))
153
+ except Exception:
154
+ pass # Migrations not yet applied or DB unreachable — skip silently
155
+
156
+
157
+ def _register_blueprints(app: Flask) -> None:
158
+ from flowforge.api.routes.ai import bp as ai_bp
159
+ from flowforge.api.routes.audit import bp as audit_bp
160
+ from flowforge.api.routes.auth import bp as auth_bp
161
+ from flowforge.api.routes.bulk_loads import bp as bulk_loads_bp
162
+ from flowforge.api.routes.connections import bp as connections_bp
163
+ from flowforge.api.routes.emails import bp as emails_bp
164
+ from flowforge.api.routes.metrics import bp as metrics_bp
165
+ from flowforge.api.routes.mfa import bp as mfa_bp
166
+ from flowforge.api.routes.password_reset import bp as password_reset_bp
167
+ from flowforge.api.routes.pipelines import bp as pipelines_bp
168
+ from flowforge.api.routes.projects import bp as projects_bp
169
+ from flowforge.api.routes.providers import bp as providers_bp
170
+ from flowforge.api.routes.recipients import bp as recipients_bp
171
+ from flowforge.api.routes.reports import bp as reports_bp
172
+ from flowforge.api.routes.runs import bp as runs_bp
173
+ from flowforge.api.routes.setup import bp as setup_bp
174
+ from flowforge.api.routes.sso import bp as sso_bp
175
+ from flowforge.api.routes.steps import bp as steps_bp
176
+ from flowforge.api.routes.users import bp as users_bp
177
+
178
+ for blueprint in (
179
+ ai_bp, audit_bp, auth_bp, bulk_loads_bp, connections_bp, emails_bp, metrics_bp,
180
+ mfa_bp, password_reset_bp, pipelines_bp, projects_bp, providers_bp, recipients_bp,
181
+ reports_bp, runs_bp, setup_bp, sso_bp, steps_bp, users_bp,
182
+ ):
183
+ app.register_blueprint(blueprint, url_prefix='/api')
184
+
185
+ @app.get('/api/health')
186
+ def health():
187
+ return jsonify({'status': 'ok'})
188
+
189
+ @app.get('/api/docs/<path:filename>')
190
+ def serve_doc(filename):
191
+ if not _DOCS.is_dir():
192
+ return jsonify({'error': 'Docs not found'}), 404
193
+ file_path = (_DOCS / filename).resolve()
194
+ if not str(file_path).startswith(str(_DOCS.resolve())):
195
+ return jsonify({'error': _NOT_FOUND}), 404
196
+ if not file_path.is_file():
197
+ return jsonify({'error': _NOT_FOUND}), 404
198
+ return send_from_directory(_DOCS, filename, mimetype='text/plain; charset=utf-8')
199
+
200
+ if _DIST.is_dir():
201
+ @app.get('/', defaults={'path': ''})
202
+ @app.get('/<path:path>')
203
+ def serve_spa(path):
204
+ file_path = _DIST / path
205
+ if path and file_path.is_file():
206
+ return send_from_directory(_DIST, path)
207
+ return send_from_directory(_DIST, 'index.html')
208
+
209
+
210
+ def _register_error_handlers(app: Flask) -> None:
211
+ @app.errorhandler(429)
212
+ def rate_limited(e):
213
+ return jsonify({'error': 'Too many login attempts. Try again in a minute.'}), 429
214
+
215
+ @app.errorhandler(400)
216
+ def bad_request(e):
217
+ return jsonify({'error': str(e)}), 400
218
+
219
+ @app.errorhandler(401)
220
+ def unauthorized(e):
221
+ return jsonify({'error': 'Unauthorized'}), 401
222
+
223
+ @app.errorhandler(403)
224
+ def forbidden(e):
225
+ return jsonify({'error': 'Forbidden'}), 403
226
+
227
+ @app.errorhandler(404)
228
+ def not_found(e):
229
+ return jsonify({'error': _NOT_FOUND}), 404
230
+
231
+ @app.errorhandler(500)
232
+ def server_error(e):
233
+ return jsonify({'error': 'Internal server error'}), 500
flowforge/api/auth.py ADDED
@@ -0,0 +1,153 @@
1
+ """JWT authentication — single-user v1 / multi-user v2 with optional MFA."""
2
+ import uuid as _uuid_mod
3
+ from datetime import UTC, datetime, timedelta
4
+ from functools import wraps
5
+
6
+ import bcrypt
7
+ import jwt
8
+ from flask import current_app, g, jsonify, request
9
+
10
+ from flowforge.db.models import TokenBlocklist, User, db
11
+
12
+
13
+ def _algorithm() -> str:
14
+ return current_app.config.get('JWT_ALGORITHM', 'HS256')
15
+
16
+
17
+ def _secret() -> str:
18
+ # SEC-2: use JWT_SECRET (separate from the AES encryption key)
19
+ return current_app.config.get('JWT_SECRET') or current_app.config['SECRET_KEY']
20
+
21
+
22
+ def _expiry_hours() -> int:
23
+ return current_app.config.get('JWT_EXPIRY_HOURS', 24)
24
+
25
+
26
+ def generate_token(user: User) -> str:
27
+ payload = {
28
+ 'sub': user.username,
29
+ 'uid': user.id,
30
+ 'role': user.role,
31
+ 'jti': str(_uuid_mod.uuid4()),
32
+ 'iat': datetime.now(UTC),
33
+ 'exp': datetime.now(UTC) + timedelta(hours=_expiry_hours()),
34
+ }
35
+ return jwt.encode(payload, _secret(), algorithm=_algorithm())
36
+
37
+
38
+ def generate_mfa_token(user: User) -> str:
39
+ """Short-lived (5 min) challenge token issued when password is correct but MFA pending."""
40
+ payload = {
41
+ 'sub': user.username,
42
+ 'uid': user.id,
43
+ 'role': user.role,
44
+ 'jti': str(_uuid_mod.uuid4()),
45
+ 'mfa_step': True,
46
+ 'iat': datetime.now(UTC),
47
+ 'exp': datetime.now(UTC) + timedelta(minutes=5),
48
+ }
49
+ return jwt.encode(payload, _secret(), algorithm=_algorithm())
50
+
51
+
52
+ def verify_mfa_token(token: str) -> dict | None:
53
+ """Verify a MFA challenge token. Returns payload if valid and un-revoked, else None."""
54
+ try:
55
+ payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
56
+ if not payload.get('mfa_step'):
57
+ return None
58
+ jti = payload.get('jti')
59
+ if jti and db.session.get(TokenBlocklist, jti) is not None:
60
+ return None
61
+ return payload
62
+ except jwt.PyJWTError:
63
+ return None
64
+
65
+
66
+ def verify_token(token: str) -> dict | None:
67
+ """Return the payload if the token is valid and not revoked, else None."""
68
+ try:
69
+ payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
70
+ # Reject MFA challenge tokens from being used as full session tokens.
71
+ if payload.get('mfa_step'):
72
+ return None
73
+ jti = payload.get('jti')
74
+ if jti and db.session.get(TokenBlocklist, jti) is not None:
75
+ return None
76
+ return payload
77
+ except jwt.PyJWTError:
78
+ return None
79
+
80
+
81
+ def revoke_token(token: str) -> str | None:
82
+ """Add the token's jti to the blocklist. Returns the username on success, None if invalid.
83
+
84
+ Tokens issued before jti was added (no jti claim) are not blocklisted — the client
85
+ must still clear its local storage; the token will expire naturally within 24h.
86
+ """
87
+ try:
88
+ payload = jwt.decode(token, _secret(), algorithms=[_algorithm()])
89
+ except jwt.PyJWTError:
90
+ return None
91
+ jti = payload.get('jti')
92
+ exp = payload.get('exp')
93
+ if jti:
94
+ expires_at = datetime.fromtimestamp(exp, tz=UTC) if exp else datetime.now(UTC)
95
+ db.session.merge(TokenBlocklist(jti=jti, expires_at=expires_at))
96
+ db.session.commit()
97
+ return payload.get('sub')
98
+
99
+
100
+ def login(username: str, password: str) -> 'str | dict | None':
101
+ """Verify credentials.
102
+
103
+ Returns:
104
+ str — full JWT (MFA not enabled or not configured)
105
+ dict — {'mfa_required': True, 'mfa_token': '...'} when MFA is enabled
106
+ None — invalid credentials
107
+ """
108
+ user = db.session.query(User).filter_by(username=username).first()
109
+ if not user:
110
+ return None
111
+ if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
112
+ return None
113
+ if user.mfa_enabled:
114
+ return {'mfa_required': True, 'mfa_token': generate_mfa_token(user)}
115
+ return generate_token(user)
116
+
117
+
118
+ def require_auth(f):
119
+ """Decorator: require a valid Bearer JWT in the Authorization header.
120
+ Stores the user payload in flask.g.user_token.
121
+ """
122
+ @wraps(f)
123
+ def wrapper(*args, **kwargs):
124
+ header = request.headers.get('Authorization', '')
125
+ if not header.startswith('Bearer '):
126
+ return jsonify({'error': 'Missing or invalid Authorization header'}), 401
127
+ token = header[len('Bearer '):]
128
+ payload = verify_token(token)
129
+ if not payload:
130
+ return jsonify({'error': 'Invalid or expired token'}), 401
131
+ g.user_token = payload
132
+ g.current_user_id = payload.get('uid')
133
+ return f(*args, **kwargs)
134
+ return wrapper
135
+
136
+
137
+ def require_role(roles: str | list[str]):
138
+ """Decorator: require the user to have one of the specified roles."""
139
+ if isinstance(roles, str):
140
+ roles = [roles]
141
+
142
+ def decorator(f):
143
+ @wraps(f)
144
+ @require_auth
145
+ def wrapper(*args, **kwargs):
146
+ user_role = g.user_token.get('role', 'viewer')
147
+ if 'admin' == user_role: # admins can do everything
148
+ return f(*args, **kwargs)
149
+ if user_role not in roles:
150
+ return jsonify({'error': f'Access denied: required role in {roles}'}), 403
151
+ return f(*args, **kwargs)
152
+ return wrapper
153
+ return decorator
File without changes