appmanager-server 0.2.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.
- appmanager/__init__.py +52 -0
- appmanager/admin/__init__.py +3 -0
- appmanager/admin/app_installer.py +739 -0
- appmanager/admin/members.py +95 -0
- appmanager/admin/registry.py +372 -0
- appmanager/admin/routes.py +900 -0
- appmanager/api/__init__.py +5 -0
- appmanager/api/routes.py +373 -0
- appmanager/app_config.py +101 -0
- appmanager/auth/__init__.py +3 -0
- appmanager/auth/oauth.py +77 -0
- appmanager/auth/routes.py +224 -0
- appmanager/auth/utils.py +175 -0
- appmanager/bridge.py +90 -0
- appmanager/cli.py +1171 -0
- appmanager/config.py +58 -0
- appmanager/database.py +120 -0
- appmanager/extension.py +198 -0
- appmanager/extensions.py +225 -0
- appmanager/health.py +133 -0
- appmanager/hooks.py +187 -0
- appmanager/middleware.py +256 -0
- appmanager/models.py +307 -0
- appmanager/py.typed +1 -0
- appmanager/sdk.py +30 -0
- appmanager/security.py +227 -0
- appmanager/security_scanner.py +582 -0
- appmanager/signals.py +21 -0
- appmanager/static/css/app.css +808 -0
- appmanager/static/vendor/fonts/fonts.css +288 -0
- appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- appmanager/static/vendor/htmx/htmx.min.js +1 -0
- appmanager/templates/admin/_member_drawer.html +98 -0
- appmanager/templates/admin/_members_table.html +108 -0
- appmanager/templates/admin/app_detail.html +136 -0
- appmanager/templates/admin/dashboard.html +902 -0
- appmanager/templates/admin/flairs.html +90 -0
- appmanager/templates/admin/permissions.html +88 -0
- appmanager/templates/admin/roles.html +119 -0
- appmanager/templates/admin/users.html +156 -0
- appmanager/templates/auth/login.html +58 -0
- appmanager/templates/auth/profile.html +44 -0
- appmanager/templates/base.html +76 -0
- appmanager/templates/errors/401.html +19 -0
- appmanager/templates/errors/403.html +19 -0
- appmanager/templates/errors/404.html +18 -0
- appmanager/templates/errors/500.html +19 -0
- appmanager/templates/index.html +54 -0
- appmanager_server-0.2.0.dist-info/METADATA +320 -0
- appmanager_server-0.2.0.dist-info/RECORD +89 -0
- appmanager_server-0.2.0.dist-info/WHEEL +5 -0
- appmanager_server-0.2.0.dist-info/entry_points.txt +5 -0
- appmanager_server-0.2.0.dist-info/licenses/LICENSE +21 -0
- appmanager_server-0.2.0.dist-info/top_level.txt +1 -0
appmanager/api/routes.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from functools import wraps
|
|
3
|
+
|
|
4
|
+
from flask import current_app, jsonify, request
|
|
5
|
+
|
|
6
|
+
from appmanager.admin.app_installer import install_from_git, install_from_zip, uninstall_app
|
|
7
|
+
from appmanager.api import api_bp
|
|
8
|
+
from appmanager.auth.utils import decode_jwt
|
|
9
|
+
from appmanager.database import db
|
|
10
|
+
from appmanager.health import check_app_health
|
|
11
|
+
from appmanager.models import AppHealthLog, AppTelemetryLog, InstalledApp, Role, User
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def api_auth_required(f):
|
|
15
|
+
"""
|
|
16
|
+
Decorator requiring valid API key (X-API-Key) or Bearer Admin JWT for API endpoints.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
@wraps(f)
|
|
20
|
+
def decorated_function(*args, **kwargs):
|
|
21
|
+
configured_api_key = current_app.config.get("APPMANAGER_API_KEY")
|
|
22
|
+
|
|
23
|
+
# 1. Check API Key header if configured
|
|
24
|
+
api_key = request.headers.get("X-API-Key")
|
|
25
|
+
if configured_api_key and api_key == configured_api_key:
|
|
26
|
+
return f(*args, **kwargs)
|
|
27
|
+
|
|
28
|
+
# 2. Check Bearer Token (JWT)
|
|
29
|
+
auth_header = request.headers.get("Authorization", "")
|
|
30
|
+
if auth_header.startswith("Bearer "):
|
|
31
|
+
token = auth_header.split(" ")[1]
|
|
32
|
+
# Check if token matches API key
|
|
33
|
+
if configured_api_key and token == configured_api_key:
|
|
34
|
+
return f(*args, **kwargs)
|
|
35
|
+
# Check if token is admin JWT
|
|
36
|
+
payload = decode_jwt(token)
|
|
37
|
+
if payload:
|
|
38
|
+
user = db.session.get(User, payload.get("user_id"))
|
|
39
|
+
if user and user.is_active and user.is_admin():
|
|
40
|
+
return f(*args, **kwargs)
|
|
41
|
+
|
|
42
|
+
# If no API key is set in config and no auth header, in dev/testing check if admin logged in
|
|
43
|
+
if not configured_api_key:
|
|
44
|
+
# Check cookie or JWT
|
|
45
|
+
token = request.cookies.get("appmanager_jwt")
|
|
46
|
+
if token:
|
|
47
|
+
payload = decode_jwt(token)
|
|
48
|
+
if payload:
|
|
49
|
+
user = db.session.get(User, payload.get("user_id"))
|
|
50
|
+
if user and user.is_active and user.is_admin():
|
|
51
|
+
return f(*args, **kwargs)
|
|
52
|
+
|
|
53
|
+
return jsonify(
|
|
54
|
+
{
|
|
55
|
+
"success": False,
|
|
56
|
+
"error": "Unauthorized",
|
|
57
|
+
"message": "Valid X-API-Key or Admin Bearer Token is required.",
|
|
58
|
+
}
|
|
59
|
+
), 401
|
|
60
|
+
|
|
61
|
+
return decorated_function
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@api_bp.route("/health", methods=["GET"])
|
|
65
|
+
def system_health():
|
|
66
|
+
"""
|
|
67
|
+
Returns overall AppManager host system health and database connectivity.
|
|
68
|
+
"""
|
|
69
|
+
db_ok = True
|
|
70
|
+
db_error = None
|
|
71
|
+
try:
|
|
72
|
+
from sqlalchemy import text
|
|
73
|
+
|
|
74
|
+
db.session.execute(text("SELECT 1"))
|
|
75
|
+
except Exception as e:
|
|
76
|
+
db_ok = False
|
|
77
|
+
db_error = str(e)
|
|
78
|
+
|
|
79
|
+
total_apps = InstalledApp.query.count()
|
|
80
|
+
active_apps = InstalledApp.query.filter_by(is_active=True).count()
|
|
81
|
+
|
|
82
|
+
overall_status = "healthy" if db_ok else "unhealthy"
|
|
83
|
+
|
|
84
|
+
return jsonify(
|
|
85
|
+
{
|
|
86
|
+
"status": overall_status,
|
|
87
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
88
|
+
"database": {"connected": db_ok, "error": db_error},
|
|
89
|
+
"apps": {"total": total_apps, "active": active_apps},
|
|
90
|
+
}
|
|
91
|
+
), 200 if db_ok else 503
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@api_bp.route("/apps", methods=["GET"])
|
|
95
|
+
def list_apps():
|
|
96
|
+
"""
|
|
97
|
+
Lists all installed sub-applications and their latest health status.
|
|
98
|
+
"""
|
|
99
|
+
apps = InstalledApp.query.all()
|
|
100
|
+
results = []
|
|
101
|
+
for a in apps:
|
|
102
|
+
app_dict = a.to_dict()
|
|
103
|
+
latest_health = (
|
|
104
|
+
AppHealthLog.query.filter_by(app_id=a.id)
|
|
105
|
+
.order_by(AppHealthLog.checked_at.desc())
|
|
106
|
+
.first()
|
|
107
|
+
)
|
|
108
|
+
app_dict["latest_health"] = {
|
|
109
|
+
"status": latest_health.status if latest_health else "unknown",
|
|
110
|
+
"response_time_ms": latest_health.response_time_ms if latest_health else None,
|
|
111
|
+
"checked_at": latest_health.checked_at.isoformat()
|
|
112
|
+
if latest_health and latest_health.checked_at
|
|
113
|
+
else None,
|
|
114
|
+
}
|
|
115
|
+
results.append(app_dict)
|
|
116
|
+
return jsonify({"success": True, "apps": results, "count": len(results)}), 200
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@api_bp.route("/apps/<slug>", methods=["GET"])
|
|
120
|
+
def get_app(slug):
|
|
121
|
+
"""
|
|
122
|
+
Retrieves details for a specific sub-app by slug.
|
|
123
|
+
"""
|
|
124
|
+
app_record = InstalledApp.query.filter_by(slug=slug).first()
|
|
125
|
+
if not app_record:
|
|
126
|
+
return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
|
|
127
|
+
|
|
128
|
+
app_dict = app_record.to_dict()
|
|
129
|
+
latest_health = (
|
|
130
|
+
AppHealthLog.query.filter_by(app_id=app_record.id)
|
|
131
|
+
.order_by(AppHealthLog.checked_at.desc())
|
|
132
|
+
.first()
|
|
133
|
+
)
|
|
134
|
+
app_dict["latest_health"] = {
|
|
135
|
+
"status": latest_health.status if latest_health else "unknown",
|
|
136
|
+
"response_time_ms": latest_health.response_time_ms if latest_health else None,
|
|
137
|
+
"checked_at": latest_health.checked_at.isoformat()
|
|
138
|
+
if latest_health and latest_health.checked_at
|
|
139
|
+
else None,
|
|
140
|
+
}
|
|
141
|
+
return jsonify({"success": True, "app": app_dict}), 200
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@api_bp.route("/apps/install", methods=["POST"])
|
|
145
|
+
@api_auth_required
|
|
146
|
+
def install_app():
|
|
147
|
+
"""
|
|
148
|
+
Installs a sub-app via Git repository URL (JSON payload) or ZIP upload (multipart).
|
|
149
|
+
"""
|
|
150
|
+
if request.is_json:
|
|
151
|
+
data = request.get_json() or {}
|
|
152
|
+
repo_url = data.get("repo_url", "").strip()
|
|
153
|
+
name = data.get("name", "").strip()
|
|
154
|
+
slug = data.get("slug", "").strip() or None
|
|
155
|
+
description = data.get("description", "").strip() or None
|
|
156
|
+
entry_point = data.get("entry_point", "").strip() or None
|
|
157
|
+
|
|
158
|
+
if not repo_url or not name:
|
|
159
|
+
return jsonify(
|
|
160
|
+
{"success": False, "error": "Missing required fields: repo_url, name"}
|
|
161
|
+
), 400
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
installed = install_from_git(
|
|
165
|
+
repo_url, name, slug=slug, description=description, entry_point=entry_point
|
|
166
|
+
)
|
|
167
|
+
return jsonify(
|
|
168
|
+
{
|
|
169
|
+
"success": True,
|
|
170
|
+
"message": f"App '{installed.name}' installed successfully.",
|
|
171
|
+
"app": installed.to_dict(),
|
|
172
|
+
}
|
|
173
|
+
), 201
|
|
174
|
+
except Exception as e:
|
|
175
|
+
return jsonify({"success": False, "error": str(e)}), 500
|
|
176
|
+
|
|
177
|
+
elif "zip_file" in request.files:
|
|
178
|
+
zip_file = request.files["zip_file"]
|
|
179
|
+
name = request.form.get("name", "").strip()
|
|
180
|
+
slug = request.form.get("slug", "").strip() or None
|
|
181
|
+
description = request.form.get("description", "").strip() or None
|
|
182
|
+
entry_point = request.form.get("entry_point", "").strip() or None
|
|
183
|
+
|
|
184
|
+
if not name:
|
|
185
|
+
return jsonify({"success": False, "error": "Missing required field 'name'"}), 400
|
|
186
|
+
|
|
187
|
+
try:
|
|
188
|
+
installed = install_from_zip(
|
|
189
|
+
zip_file, name, slug=slug, description=description, entry_point=entry_point
|
|
190
|
+
)
|
|
191
|
+
return jsonify(
|
|
192
|
+
{
|
|
193
|
+
"success": True,
|
|
194
|
+
"message": f"App '{installed.name}' installed from ZIP.",
|
|
195
|
+
"app": installed.to_dict(),
|
|
196
|
+
}
|
|
197
|
+
), 201
|
|
198
|
+
except Exception as e:
|
|
199
|
+
return jsonify({"success": False, "error": str(e)}), 500
|
|
200
|
+
|
|
201
|
+
return jsonify(
|
|
202
|
+
{
|
|
203
|
+
"success": False,
|
|
204
|
+
"error": "Invalid request format. Send JSON with repo_url or multipart with zip_file.",
|
|
205
|
+
}
|
|
206
|
+
), 400
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@api_bp.route("/apps/<slug>", methods=["DELETE"])
|
|
210
|
+
@api_auth_required
|
|
211
|
+
def delete_app_by_slug(slug):
|
|
212
|
+
"""
|
|
213
|
+
Uninstalls an application by slug.
|
|
214
|
+
"""
|
|
215
|
+
app_record = InstalledApp.query.filter_by(slug=slug).first()
|
|
216
|
+
if not app_record:
|
|
217
|
+
return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
|
|
218
|
+
|
|
219
|
+
success, msg = uninstall_app(app_record.id)
|
|
220
|
+
if success:
|
|
221
|
+
return jsonify({"success": True, "message": msg}), 200
|
|
222
|
+
return jsonify({"success": False, "error": msg}), 500
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@api_bp.route("/apps/<slug>/health-check", methods=["POST"])
|
|
226
|
+
@api_auth_required
|
|
227
|
+
def trigger_health_check(slug):
|
|
228
|
+
"""
|
|
229
|
+
Triggers an immediate health check for a sub-app and returns the result.
|
|
230
|
+
"""
|
|
231
|
+
app_record = InstalledApp.query.filter_by(slug=slug).first()
|
|
232
|
+
if not app_record:
|
|
233
|
+
return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
|
|
234
|
+
|
|
235
|
+
log = check_app_health(app_record)
|
|
236
|
+
return jsonify(
|
|
237
|
+
{
|
|
238
|
+
"success": True,
|
|
239
|
+
"app_slug": slug,
|
|
240
|
+
"status": log.status,
|
|
241
|
+
"response_time_ms": log.response_time_ms,
|
|
242
|
+
"details": log.details,
|
|
243
|
+
"checked_at": log.checked_at.isoformat() if log.checked_at else None,
|
|
244
|
+
}
|
|
245
|
+
), 200
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@api_bp.route("/apps/<slug>/reload", methods=["POST"])
|
|
249
|
+
@api_auth_required
|
|
250
|
+
def reload_app(slug):
|
|
251
|
+
"""
|
|
252
|
+
Invalidates the in-memory WSGI cache for a sub-app to trigger live reload.
|
|
253
|
+
"""
|
|
254
|
+
app_record = InstalledApp.query.filter_by(slug=slug).first()
|
|
255
|
+
if not app_record:
|
|
256
|
+
return jsonify({"success": False, "error": f"App with slug '{slug}' not found."}), 404
|
|
257
|
+
|
|
258
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
259
|
+
current_app.extensions["appmanager"].clear_cache(slug=slug)
|
|
260
|
+
|
|
261
|
+
return jsonify({"success": True, "message": f"Cache cleared for sub-app '{slug}'."}), 200
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@api_bp.route("/metrics", methods=["GET"])
|
|
265
|
+
def get_metrics():
|
|
266
|
+
"""
|
|
267
|
+
Returns telemetry metrics summary.
|
|
268
|
+
"""
|
|
269
|
+
recent_logs = AppTelemetryLog.query.order_by(AppTelemetryLog.created_at.desc()).limit(100).all()
|
|
270
|
+
metrics_summary = {}
|
|
271
|
+
for log in recent_logs:
|
|
272
|
+
if log.app_slug not in metrics_summary:
|
|
273
|
+
metrics_summary[log.app_slug] = {"total_events": 0, "latest_event": None}
|
|
274
|
+
metrics_summary[log.app_slug]["total_events"] += 1
|
|
275
|
+
if not metrics_summary[log.app_slug]["latest_event"]:
|
|
276
|
+
metrics_summary[log.app_slug]["latest_event"] = {
|
|
277
|
+
"event_type": log.event_type,
|
|
278
|
+
"created_at": log.created_at.isoformat() if log.created_at else None,
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return jsonify(
|
|
282
|
+
{
|
|
283
|
+
"success": True,
|
|
284
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
285
|
+
"metrics": metrics_summary,
|
|
286
|
+
}
|
|
287
|
+
), 200
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@api_bp.route("/roles", methods=["GET"])
|
|
291
|
+
@api_auth_required
|
|
292
|
+
def list_roles_api():
|
|
293
|
+
"""
|
|
294
|
+
Returns list of all configured roles and member counts.
|
|
295
|
+
"""
|
|
296
|
+
roles = Role.query.order_by(Role.name).all()
|
|
297
|
+
data = []
|
|
298
|
+
for r in roles:
|
|
299
|
+
r_dict = r.to_dict()
|
|
300
|
+
r_dict["member_count"] = User.query.filter_by(role=r.slug).count()
|
|
301
|
+
data.append(r_dict)
|
|
302
|
+
return jsonify({"success": True, "count": len(data), "roles": data})
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@api_bp.route("/roles", methods=["POST"])
|
|
306
|
+
@api_auth_required
|
|
307
|
+
def create_role_api():
|
|
308
|
+
"""
|
|
309
|
+
Creates a new custom role.
|
|
310
|
+
"""
|
|
311
|
+
from appmanager.admin.app_installer import sanitize_slug
|
|
312
|
+
|
|
313
|
+
data = request.get_json() or {}
|
|
314
|
+
name = data.get("name", "").strip()
|
|
315
|
+
slug = data.get("slug", "").strip() or sanitize_slug(name)
|
|
316
|
+
description = data.get("description", "").strip()
|
|
317
|
+
|
|
318
|
+
if not name:
|
|
319
|
+
return jsonify({"error": "Role name is required", "success": False}), 400
|
|
320
|
+
|
|
321
|
+
clean_slug = sanitize_slug(slug)
|
|
322
|
+
existing = Role.query.filter((Role.slug == clean_slug) | (Role.name == name)).first()
|
|
323
|
+
if existing:
|
|
324
|
+
return jsonify(
|
|
325
|
+
{
|
|
326
|
+
"error": f"Role with slug '{clean_slug}' or name '{name}' already exists",
|
|
327
|
+
"success": False,
|
|
328
|
+
}
|
|
329
|
+
), 409
|
|
330
|
+
|
|
331
|
+
role = Role(name=name, slug=clean_slug, description=description, is_system=False)
|
|
332
|
+
db.session.add(role)
|
|
333
|
+
db.session.commit()
|
|
334
|
+
|
|
335
|
+
return jsonify(
|
|
336
|
+
{
|
|
337
|
+
"success": True,
|
|
338
|
+
"message": f"Role '{role.name}' created successfully",
|
|
339
|
+
"role": role.to_dict(),
|
|
340
|
+
}
|
|
341
|
+
), 201
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@api_bp.route("/roles/<string:slug>", methods=["DELETE"])
|
|
345
|
+
@api_auth_required
|
|
346
|
+
def delete_role_api(slug: str):
|
|
347
|
+
"""
|
|
348
|
+
Deletes a custom role.
|
|
349
|
+
"""
|
|
350
|
+
role = Role.query.filter_by(slug=slug).first()
|
|
351
|
+
if not role:
|
|
352
|
+
return jsonify({"error": "Role not found", "success": False}), 404
|
|
353
|
+
|
|
354
|
+
if role.is_system:
|
|
355
|
+
return jsonify(
|
|
356
|
+
{"error": f"Cannot delete core system role '{role.name}'", "success": False}
|
|
357
|
+
), 400
|
|
358
|
+
|
|
359
|
+
# Reassign affected users
|
|
360
|
+
affected = User.query.filter_by(role=role.slug).all()
|
|
361
|
+
for u in affected:
|
|
362
|
+
u.role = "user"
|
|
363
|
+
|
|
364
|
+
db.session.delete(role)
|
|
365
|
+
db.session.commit()
|
|
366
|
+
|
|
367
|
+
return jsonify(
|
|
368
|
+
{
|
|
369
|
+
"success": True,
|
|
370
|
+
"message": f"Role '{role.name}' deleted successfully",
|
|
371
|
+
"reassigned_members": len(affected),
|
|
372
|
+
}
|
|
373
|
+
)
|
appmanager/app_config.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Typed per-app configuration helpers.
|
|
3
|
+
|
|
4
|
+
Provides CRUD over the `app_configs` table and a one-time migration from the
|
|
5
|
+
legacy `settings_json` blob. Extensions and the admin console both use these
|
|
6
|
+
helpers so settings stay consistent and queryable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from appmanager.database import db
|
|
10
|
+
from appmanager.models import AppConfig, InstalledApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_config(app_id, key, default=None):
|
|
14
|
+
"""Fetch a single config value (decoded)."""
|
|
15
|
+
row = AppConfig.query.filter_by(app_id=app_id, key=key).first()
|
|
16
|
+
if row is None:
|
|
17
|
+
return default
|
|
18
|
+
return row.get_value()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_configs(app_id):
|
|
22
|
+
"""Fetch all configs for an app as {key: decoded_value}."""
|
|
23
|
+
rows = AppConfig.query.filter_by(app_id=app_id).all()
|
|
24
|
+
return {r.key: r.get_value() for r in rows}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def set_config(app_id, key, value, value_type="json", is_secret=False):
|
|
28
|
+
"""Create or update a single config row."""
|
|
29
|
+
row = AppConfig.query.filter_by(app_id=app_id, key=key).first()
|
|
30
|
+
if not row:
|
|
31
|
+
row = AppConfig(app_id=app_id, key=key, value_type=value_type, is_secret=is_secret)
|
|
32
|
+
db.session.add(row)
|
|
33
|
+
row.set_value(value)
|
|
34
|
+
db.session.commit()
|
|
35
|
+
return row
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def set_configs(app_id, config_dict, schema=None):
|
|
39
|
+
"""
|
|
40
|
+
Bulk upsert configs from a dict. `schema` is an optional list of
|
|
41
|
+
{key, type, default, is_secret} descriptors used to set value_type.
|
|
42
|
+
"""
|
|
43
|
+
schema_map = {s.get("key"): s for s in (schema or [])}
|
|
44
|
+
for key, value in config_dict.items():
|
|
45
|
+
desc = schema_map.get(key, {})
|
|
46
|
+
set_config(
|
|
47
|
+
app_id,
|
|
48
|
+
key,
|
|
49
|
+
value,
|
|
50
|
+
value_type=desc.get("type", "json"),
|
|
51
|
+
is_secret=desc.get("is_secret", False),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def seed_defaults(app_id, schema):
|
|
56
|
+
"""
|
|
57
|
+
Seed default config rows for an app from a settings_schema manifest.
|
|
58
|
+
Only creates rows that don't already exist (doesn't overwrite user edits).
|
|
59
|
+
"""
|
|
60
|
+
for desc in schema or []:
|
|
61
|
+
key = desc.get("key")
|
|
62
|
+
if not key:
|
|
63
|
+
continue
|
|
64
|
+
existing = AppConfig.query.filter_by(app_id=app_id, key=key).first()
|
|
65
|
+
if existing is None:
|
|
66
|
+
row = AppConfig(
|
|
67
|
+
app_id=app_id,
|
|
68
|
+
key=key,
|
|
69
|
+
value_type=desc.get("type", "json"),
|
|
70
|
+
is_secret=desc.get("is_secret", False),
|
|
71
|
+
)
|
|
72
|
+
row.set_value(desc.get("default"))
|
|
73
|
+
db.session.add(row)
|
|
74
|
+
db.session.commit()
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def migrate_from_settings_json(app_id=None):
|
|
78
|
+
"""
|
|
79
|
+
One-time migration: parse each app's legacy `settings_json` blob and seed
|
|
80
|
+
`app_configs` rows. Idempotent — skips apps that already have config rows.
|
|
81
|
+
Returns number of apps migrated.
|
|
82
|
+
"""
|
|
83
|
+
query = InstalledApp.query
|
|
84
|
+
if app_id is not None:
|
|
85
|
+
query = query.filter_by(id=app_id)
|
|
86
|
+
apps = query.all()
|
|
87
|
+
migrated = 0
|
|
88
|
+
for a in apps:
|
|
89
|
+
existing = AppConfig.query.filter_by(app_id=a.id).first()
|
|
90
|
+
if existing is not None:
|
|
91
|
+
continue
|
|
92
|
+
settings = a.get_settings()
|
|
93
|
+
if not settings:
|
|
94
|
+
continue
|
|
95
|
+
for key, value in settings.items():
|
|
96
|
+
row = AppConfig(app_id=a.id, key=str(key), value_type="json")
|
|
97
|
+
row.set_value(value)
|
|
98
|
+
db.session.add(row)
|
|
99
|
+
migrated += 1
|
|
100
|
+
db.session.commit()
|
|
101
|
+
return migrated
|
appmanager/auth/oauth.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import secrets
|
|
2
|
+
import urllib.parse
|
|
3
|
+
|
|
4
|
+
import requests
|
|
5
|
+
from flask import current_app, request, session, url_for
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_google_auth_url():
|
|
9
|
+
client_id = current_app.config.get("GOOGLE_CLIENT_ID")
|
|
10
|
+
if not client_id:
|
|
11
|
+
return None, "Google Client ID is not configured."
|
|
12
|
+
|
|
13
|
+
state = secrets.token_urlsafe(16)
|
|
14
|
+
session["oauth_state"] = state
|
|
15
|
+
|
|
16
|
+
redirect_uri = url_for("auth.google_callback", _external=True)
|
|
17
|
+
|
|
18
|
+
params = {
|
|
19
|
+
"client_id": client_id,
|
|
20
|
+
"redirect_uri": redirect_uri,
|
|
21
|
+
"response_type": "code",
|
|
22
|
+
"scope": "openid email profile",
|
|
23
|
+
"state": state,
|
|
24
|
+
"access_type": "offline",
|
|
25
|
+
"prompt": "select_account",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
auth_url = f"https://accounts.google.com/o/oauth2/v2/auth?{urllib.parse.urlencode(params)}"
|
|
29
|
+
return auth_url, None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def process_google_callback():
|
|
33
|
+
client_id = current_app.config.get("GOOGLE_CLIENT_ID")
|
|
34
|
+
client_secret = current_app.config.get("GOOGLE_CLIENT_SECRET")
|
|
35
|
+
|
|
36
|
+
if not client_id or not client_secret:
|
|
37
|
+
return None, "Google Client ID or Secret missing."
|
|
38
|
+
|
|
39
|
+
state = request.args.get("state")
|
|
40
|
+
saved_state = session.pop("oauth_state", None)
|
|
41
|
+
|
|
42
|
+
if not state or state != saved_state:
|
|
43
|
+
return None, "Invalid OAuth state parameter."
|
|
44
|
+
|
|
45
|
+
code = request.args.get("code")
|
|
46
|
+
if not code:
|
|
47
|
+
return None, "Authorization code not provided by Google."
|
|
48
|
+
|
|
49
|
+
redirect_uri = url_for("auth.google_callback", _external=True)
|
|
50
|
+
|
|
51
|
+
# Exchange code for token
|
|
52
|
+
token_url = "https://oauth2.googleapis.com/token"
|
|
53
|
+
token_data = {
|
|
54
|
+
"code": code,
|
|
55
|
+
"client_id": client_id,
|
|
56
|
+
"client_secret": client_secret,
|
|
57
|
+
"redirect_uri": redirect_uri,
|
|
58
|
+
"grant_type": "authorization_code",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
token_res = requests.post(token_url, data=token_data, timeout=10)
|
|
62
|
+
if token_res.status_code != 200:
|
|
63
|
+
return None, f"Failed to exchange token with Google: {token_res.text}"
|
|
64
|
+
|
|
65
|
+
tokens = token_res.json()
|
|
66
|
+
access_token = tokens.get("access_token")
|
|
67
|
+
|
|
68
|
+
# Get User Info
|
|
69
|
+
userinfo_url = "https://www.googleapis.com/oauth2/v3/userinfo"
|
|
70
|
+
userinfo_res = requests.get(
|
|
71
|
+
userinfo_url, headers={"Authorization": f"Bearer {access_token}"}, timeout=10
|
|
72
|
+
)
|
|
73
|
+
if userinfo_res.status_code != 200:
|
|
74
|
+
return None, "Failed to retrieve user profile from Google."
|
|
75
|
+
|
|
76
|
+
user_data = userinfo_res.json()
|
|
77
|
+
return user_data, None
|