appmanager-server 0.3.1__tar.gz → 0.4.1__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.
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/CHANGELOG.md +54 -0
- {appmanager_server-0.3.1/appmanager_server.egg-info → appmanager_server-0.4.1}/PKG-INFO +95 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/README.md +94 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/__init__.py +1 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/admin/app_installer.py +195 -7
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/admin/registry.py +4 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/admin/routes.py +265 -21
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/api/routes.py +14 -2
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/app_config.py +57 -0
- appmanager_server-0.4.1/appmanager/audit.py +65 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/auth/routes.py +17 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/bridge.py +40 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/cli.py +73 -10
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/config.py +1 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/database.py +95 -0
- appmanager_server-0.4.1/appmanager/db_access.py +378 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/extension.py +114 -5
- appmanager_server-0.4.1/appmanager/host_settings.py +172 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/middleware.py +163 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/models.py +205 -0
- appmanager_server-0.4.1/appmanager/ratelimit.py +68 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/security.py +71 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/css/app.css +227 -36
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/app_detail.html +142 -0
- appmanager_server-0.4.1/appmanager/templates/admin/dashboard.html +1753 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/auth/login.html +9 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/base.html +17 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/index.html +8 -1
- appmanager_server-0.4.1/appmanager/templates/privacy.html +23 -0
- appmanager_server-0.4.1/appmanager/templates/terms.html +26 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1/appmanager_server.egg-info}/PKG-INFO +95 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager_server.egg-info/SOURCES.txt +8 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/pyproject.toml +1 -1
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_auth.py +25 -5
- appmanager_server-0.4.1/tests/test_db_access.py +236 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_invalid_install_failures.py +127 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_packaging_and_cli.py +27 -2
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_security_hardening.py +20 -0
- appmanager_server-0.4.1/tests/test_seo_and_settings.py +333 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_user_elevation_and_visibility.py +20 -3
- appmanager_server-0.3.1/appmanager/templates/admin/dashboard.html +0 -1030
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/LICENSE +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/MANIFEST.in +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/SECURITY.md +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/admin/__init__.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/admin/members.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/api/__init__.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/auth/__init__.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/auth/oauth.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/auth/utils.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/dependency_manager.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/extensions.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/health.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/hooks.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/py.typed +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/sdk.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/security_scanner.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/signals.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/fonts.css +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-0.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-1.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-10.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-11.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-12.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-13.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-14.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-15.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-16.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-17.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-18.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-19.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-2.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-20.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-21.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-22.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-23.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-3.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-4.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-5.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-6.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-7.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-8.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/jetbrainsmono-9.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-0.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-1.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-10.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-11.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-2.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-3.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-4.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-5.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-6.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-7.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-8.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/fonts/spacegrotesk-9.woff2 +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/static/vendor/htmx/htmx.min.js +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/_member_drawer.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/_members_table.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/flairs.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/permissions.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/roles.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/admin/users.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/auth/profile.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/errors/401.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/errors/403.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/errors/404.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager/templates/errors/500.html +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager_server.egg-info/dependency_links.txt +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager_server.egg-info/entry_points.txt +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager_server.egg-info/requires.txt +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/appmanager_server.egg-info/top_level.txt +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/requirements.txt +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/setup.cfg +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_api.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_app_settings.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_app_settings_config.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_app_updates.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_cli_dev_and_templates.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_cli_health.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_cli_scaffolding.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_dependency_manager.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_error_pages.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_extension_apps.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_extension_pattern.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_hooks.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_installer.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_manifest_sdk.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_middleware.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_nested_portal.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_role_management.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_sdk.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_security_precheck_flow.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_security_scanner.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_signals.py +0 -0
- {appmanager_server-0.3.1 → appmanager_server-0.4.1}/tests/test_user_tracking.py +0 -0
|
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
---
|
|
9
9
|
|
|
10
|
+
## [Unreleased]
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **SEO capabilities**: apps can declare SEO metadata in their manifest (`seo`
|
|
14
|
+
block) which the host persists to `InstalledApp` and renders into served HTML.
|
|
15
|
+
- New `InstalledApp` SEO columns (`seo_title`, `seo_description`, `seo_keywords`,
|
|
16
|
+
`seo_canonical_url`, `seo_og_image`, `seo_robots`, `seo_json_ld`) + auto-migration.
|
|
17
|
+
- `base.html` `head_meta` block renders portal SEO (title, description, keywords,
|
|
18
|
+
canonical, OG, Twitter, robots, JSON-LD).
|
|
19
|
+
- Dispatcher middleware injects app SEO into sub-app HTML `<head>` (no duplicate
|
|
20
|
+
tags; auth apps forced to `noindex` when configured).
|
|
21
|
+
- `/robots.txt` and `/sitemap.xml` routes (sitemap lists public apps).
|
|
22
|
+
- Admin app detail page shows a read-only **SEO** panel.
|
|
23
|
+
- **Admin Settings page** (`/admin/settings`) with three sections:
|
|
24
|
+
- **SEO**: master toggle, portal SEO fields, per-app override, auth noindex,
|
|
25
|
+
sitemap toggle.
|
|
26
|
+
- **Dashboard & Login**: login-required toggle, dashboard on/off, default-app
|
|
27
|
+
redirect.
|
|
28
|
+
- **Visibility**: show/hide login-required apps; adaptive button labels
|
|
29
|
+
(Login / Permission Required / Launch).
|
|
30
|
+
- **Host settings store**: new `HostSetting` model + `host_settings` table and
|
|
31
|
+
`appmanager.host_settings` helpers (typed get/set with canonical defaults).
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## [0.4.1] - 2026-09-09
|
|
36
|
+
|
|
37
|
+
### Performance
|
|
38
|
+
- **Batch admin dashboard queries**: health-log fetch for all apps is now a single
|
|
39
|
+
batched query (was 2 queries per app) and role member counts use one `GROUP BY`
|
|
40
|
+
instead of one count query per role. Dashboard health: 60 → 21 queries;
|
|
41
|
+
role counts: 4 → 1 query.
|
|
42
|
+
- **Batch permissions matrix update**: the `/admin/permissions` POST handler now
|
|
43
|
+
loads existing permissions into a lookup map once instead of issuing one query
|
|
44
|
+
per user × app cell (was O(users × apps) queries). 405 → 31 queries, ~4.2×
|
|
45
|
+
faster wall-clock on 20 apps × 10 users.
|
|
46
|
+
- **Bulk-insert grant-all permissions on install**: `app_installer` now inserts
|
|
47
|
+
per-user permissions in a single `bulk_insert_mappings` statement (was one
|
|
48
|
+
INSERT per user), skipping users who already have a row.
|
|
49
|
+
- **Windowed health-log purge**: the CLI maintenance command keeps the latest 100
|
|
50
|
+
health logs per app with a single windowed `DELETE` (was a per-app loop of
|
|
51
|
+
row-by-row deletes).
|
|
52
|
+
- **Batch admin-panel sync**: `sync_panels` loads existing panels for an app once
|
|
53
|
+
into a map instead of one query per declared panel.
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## [0.4.0] - 2026-08-28
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
- **Automatic Sub-App Root Module Resolution**: Automatically prepends sub-app root directory (`app_dir`) to `sys.path` during dynamic module loading in `load_wsgi_app_from_path`, enabling seamless relative/module imports within sub-app packages.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
10
64
|
## [0.3.1] - 2026-08-28
|
|
11
65
|
|
|
12
66
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: appmanager-server
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: High-performance multi-tenant WSGI portal, dynamic sub-app dispatcher, and extension framework for Python/Flask.
|
|
5
5
|
Author: AppManager Contributors
|
|
6
6
|
License: MIT
|
|
@@ -120,6 +120,11 @@ graph LR
|
|
|
120
120
|
- 🩺 **Automated Health Monitoring**: Sub-app health evaluation contract (`/health` endpoint or `get_health()` callable) tracked in `AppHealthLog` with one-click admin execution.
|
|
121
121
|
- 📊 **In-Process Telemetry Bridge**: High-performance telemetry reporting allowing sub-apps to record events and metrics directly to the host database with zero network overhead.
|
|
122
122
|
- 🔒 **Granular Role & Permissions Matrix**: Full RBAC role management and per-user permission matrix controlling access to every installed sub-app.
|
|
123
|
+
- 🗄️ **Per-App Database Access**: Apps can request scoped or full access to the host database; the admin approves/denies at install and can adjust later (with optional data migration).
|
|
124
|
+
- 🔐 **Read-Only Auth Access**: Apps can request a narrow read-only auth subset (login state, display name, role) — never email, id, or tokens.
|
|
125
|
+
- 🔑 **Per-App API Keys**: Each installed app gets a generated API key for service-to-service auth back to the host REST API.
|
|
126
|
+
- 📜 **Audit Log**: Append-only trail of install/uninstall, permission grants/revokes, config changes, and API key rotation.
|
|
127
|
+
- 🚦 **Bridge Rate Limiting**: Token-bucket rate limiting on in-process telemetry/storage calls to stop one app from flooding the host.
|
|
123
128
|
|
|
124
129
|
---
|
|
125
130
|
|
|
@@ -177,6 +182,21 @@ Debug and test your sub-app locally with mock user authentication headers:
|
|
|
177
182
|
appmanager dev analytics --port 5001 --email dev@example.com --role admin
|
|
178
183
|
```
|
|
179
184
|
|
|
185
|
+
### Cryptographic Token & Secrets Generator
|
|
186
|
+
|
|
187
|
+
Generate production-ready secrets (`SECRET_KEY`, `JWT_SECRET`, `AIC_TOKEN_SECRET`) or individual secure tokens:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
# Generate complete set of formatted .env secrets
|
|
191
|
+
appmanager generate-tokens --env
|
|
192
|
+
|
|
193
|
+
# Generate a single 256-bit URL-safe token
|
|
194
|
+
appmanager generate-tokens
|
|
195
|
+
|
|
196
|
+
# Generate a 256-bit Hexadecimal token
|
|
197
|
+
appmanager generate-tokens -f hex
|
|
198
|
+
```
|
|
199
|
+
|
|
180
200
|
---
|
|
181
201
|
|
|
182
202
|
## 🛠️ Sub-App Specification (`manifest.json`)
|
|
@@ -246,6 +266,80 @@ if __name__ == "__main__":
|
|
|
246
266
|
|
|
247
267
|
---
|
|
248
268
|
|
|
269
|
+
## 🗄️ Per-App Database Access & Permissions
|
|
270
|
+
|
|
271
|
+
An app can request access to the host's shared database by declaring it in its
|
|
272
|
+
manifest. On install, the admin approves or denies the request (default is
|
|
273
|
+
**deny**). Permissions are stored in the `app_db_permissions` table and can be
|
|
274
|
+
adjusted after install from the app's **Permissions** panel.
|
|
275
|
+
|
|
276
|
+
### Manifest fields
|
|
277
|
+
|
|
278
|
+
```json
|
|
279
|
+
{
|
|
280
|
+
"requests_database": true,
|
|
281
|
+
"database_access_level": "scoped",
|
|
282
|
+
"database_description": "Stores user preferences in a scoped table.",
|
|
283
|
+
"requests_auth_readonly": true
|
|
284
|
+
}
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
- `requests_database` — request shared DB access.
|
|
288
|
+
- `database_access_level` — `"scoped"` (own table prefix / MySQL schema) or
|
|
289
|
+
`"full"` (raw host DB, trusted only).
|
|
290
|
+
- `database_description` — shown to the admin at approval time.
|
|
291
|
+
- `requests_auth_readonly` — request read-only access to login state, display
|
|
292
|
+
name, and role only.
|
|
293
|
+
|
|
294
|
+
### Approval flow
|
|
295
|
+
|
|
296
|
+
1. The install security report shows a **Permission Requests** panel.
|
|
297
|
+
2. The admin chooses **Deny**, **Scoped**, or **Full** for DB access, and
|
|
298
|
+
**Deny**/**Grant** for read-only auth access.
|
|
299
|
+
3. The decision is stored; the app's SDK receives an in-process engine (never
|
|
300
|
+
raw credentials) when granted, or falls back to its own local SQLite when
|
|
301
|
+
denied.
|
|
302
|
+
|
|
303
|
+
### Adjusting permissions after install
|
|
304
|
+
|
|
305
|
+
From the app's **Permissions** panel, the admin can change the DB scope or
|
|
306
|
+
revoke auth access at any time. If the DB scope changes, the admin is asked
|
|
307
|
+
whether to **migrate** the app's existing data (migration is a distinct,
|
|
308
|
+
confirmable action — never automatic).
|
|
309
|
+
|
|
310
|
+
### Security model
|
|
311
|
+
|
|
312
|
+
- **Scoped** access hands the app a SQLAlchemy engine with its own table prefix
|
|
313
|
+
(`app_<slug>_`) or dedicated MySQL schema.
|
|
314
|
+
- **Full** access hands a raw engine to the host DB and shows a prominent
|
|
315
|
+
warning at approval.
|
|
316
|
+
- **Auth read-only** exposes only login state, display name, and role — never
|
|
317
|
+
email, user id, passwords, or tokens.
|
|
318
|
+
- Credentials never exist as strings in app memory and are never sent over the
|
|
319
|
+
network or written to files.
|
|
320
|
+
|
|
321
|
+
### Per-app API keys
|
|
322
|
+
|
|
323
|
+
Each installed app gets a generated API key (stored as a secret config row).
|
|
324
|
+
The SDK sends it in `X-AppManager-App-Key` to authenticate service-to-service
|
|
325
|
+
calls back to the host REST API. The key is injected by the dispatcher, so the
|
|
326
|
+
app never stores or manages credentials itself.
|
|
327
|
+
|
|
328
|
+
### Audit log
|
|
329
|
+
|
|
330
|
+
Security-relevant actions (install/uninstall, permission grants/revokes, API
|
|
331
|
+
key rotation) are recorded in the `audit_log` table and viewable at
|
|
332
|
+
`/admin/audit-log`.
|
|
333
|
+
|
|
334
|
+
### Bridge rate limiting
|
|
335
|
+
|
|
336
|
+
In-process telemetry/storage calls (`report_event`, `report_metric`) are
|
|
337
|
+
rate-limited per app via a token bucket (default 100 events/min, configurable
|
|
338
|
+
via `BRIDGE_RATE_LIMIT_RATE` / `BRIDGE_RATE_LIMIT_BURST` /
|
|
339
|
+
`BRIDGE_RATE_LIMIT_ENABLED`).
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
249
343
|
## 🧩 Pluggable UI Slots & Extension Hooks
|
|
250
344
|
|
|
251
345
|
Extensions can inject components into host slots:
|
|
@@ -59,6 +59,11 @@ graph LR
|
|
|
59
59
|
- 🩺 **Automated Health Monitoring**: Sub-app health evaluation contract (`/health` endpoint or `get_health()` callable) tracked in `AppHealthLog` with one-click admin execution.
|
|
60
60
|
- 📊 **In-Process Telemetry Bridge**: High-performance telemetry reporting allowing sub-apps to record events and metrics directly to the host database with zero network overhead.
|
|
61
61
|
- 🔒 **Granular Role & Permissions Matrix**: Full RBAC role management and per-user permission matrix controlling access to every installed sub-app.
|
|
62
|
+
- 🗄️ **Per-App Database Access**: Apps can request scoped or full access to the host database; the admin approves/denies at install and can adjust later (with optional data migration).
|
|
63
|
+
- 🔐 **Read-Only Auth Access**: Apps can request a narrow read-only auth subset (login state, display name, role) — never email, id, or tokens.
|
|
64
|
+
- 🔑 **Per-App API Keys**: Each installed app gets a generated API key for service-to-service auth back to the host REST API.
|
|
65
|
+
- 📜 **Audit Log**: Append-only trail of install/uninstall, permission grants/revokes, config changes, and API key rotation.
|
|
66
|
+
- 🚦 **Bridge Rate Limiting**: Token-bucket rate limiting on in-process telemetry/storage calls to stop one app from flooding the host.
|
|
62
67
|
|
|
63
68
|
---
|
|
64
69
|
|
|
@@ -116,6 +121,21 @@ Debug and test your sub-app locally with mock user authentication headers:
|
|
|
116
121
|
appmanager dev analytics --port 5001 --email dev@example.com --role admin
|
|
117
122
|
```
|
|
118
123
|
|
|
124
|
+
### Cryptographic Token & Secrets Generator
|
|
125
|
+
|
|
126
|
+
Generate production-ready secrets (`SECRET_KEY`, `JWT_SECRET`, `AIC_TOKEN_SECRET`) or individual secure tokens:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
# Generate complete set of formatted .env secrets
|
|
130
|
+
appmanager generate-tokens --env
|
|
131
|
+
|
|
132
|
+
# Generate a single 256-bit URL-safe token
|
|
133
|
+
appmanager generate-tokens
|
|
134
|
+
|
|
135
|
+
# Generate a 256-bit Hexadecimal token
|
|
136
|
+
appmanager generate-tokens -f hex
|
|
137
|
+
```
|
|
138
|
+
|
|
119
139
|
---
|
|
120
140
|
|
|
121
141
|
## 🛠️ Sub-App Specification (`manifest.json`)
|
|
@@ -185,6 +205,80 @@ if __name__ == "__main__":
|
|
|
185
205
|
|
|
186
206
|
---
|
|
187
207
|
|
|
208
|
+
## 🗄️ Per-App Database Access & Permissions
|
|
209
|
+
|
|
210
|
+
An app can request access to the host's shared database by declaring it in its
|
|
211
|
+
manifest. On install, the admin approves or denies the request (default is
|
|
212
|
+
**deny**). Permissions are stored in the `app_db_permissions` table and can be
|
|
213
|
+
adjusted after install from the app's **Permissions** panel.
|
|
214
|
+
|
|
215
|
+
### Manifest fields
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"requests_database": true,
|
|
220
|
+
"database_access_level": "scoped",
|
|
221
|
+
"database_description": "Stores user preferences in a scoped table.",
|
|
222
|
+
"requests_auth_readonly": true
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
- `requests_database` — request shared DB access.
|
|
227
|
+
- `database_access_level` — `"scoped"` (own table prefix / MySQL schema) or
|
|
228
|
+
`"full"` (raw host DB, trusted only).
|
|
229
|
+
- `database_description` — shown to the admin at approval time.
|
|
230
|
+
- `requests_auth_readonly` — request read-only access to login state, display
|
|
231
|
+
name, and role only.
|
|
232
|
+
|
|
233
|
+
### Approval flow
|
|
234
|
+
|
|
235
|
+
1. The install security report shows a **Permission Requests** panel.
|
|
236
|
+
2. The admin chooses **Deny**, **Scoped**, or **Full** for DB access, and
|
|
237
|
+
**Deny**/**Grant** for read-only auth access.
|
|
238
|
+
3. The decision is stored; the app's SDK receives an in-process engine (never
|
|
239
|
+
raw credentials) when granted, or falls back to its own local SQLite when
|
|
240
|
+
denied.
|
|
241
|
+
|
|
242
|
+
### Adjusting permissions after install
|
|
243
|
+
|
|
244
|
+
From the app's **Permissions** panel, the admin can change the DB scope or
|
|
245
|
+
revoke auth access at any time. If the DB scope changes, the admin is asked
|
|
246
|
+
whether to **migrate** the app's existing data (migration is a distinct,
|
|
247
|
+
confirmable action — never automatic).
|
|
248
|
+
|
|
249
|
+
### Security model
|
|
250
|
+
|
|
251
|
+
- **Scoped** access hands the app a SQLAlchemy engine with its own table prefix
|
|
252
|
+
(`app_<slug>_`) or dedicated MySQL schema.
|
|
253
|
+
- **Full** access hands a raw engine to the host DB and shows a prominent
|
|
254
|
+
warning at approval.
|
|
255
|
+
- **Auth read-only** exposes only login state, display name, and role — never
|
|
256
|
+
email, user id, passwords, or tokens.
|
|
257
|
+
- Credentials never exist as strings in app memory and are never sent over the
|
|
258
|
+
network or written to files.
|
|
259
|
+
|
|
260
|
+
### Per-app API keys
|
|
261
|
+
|
|
262
|
+
Each installed app gets a generated API key (stored as a secret config row).
|
|
263
|
+
The SDK sends it in `X-AppManager-App-Key` to authenticate service-to-service
|
|
264
|
+
calls back to the host REST API. The key is injected by the dispatcher, so the
|
|
265
|
+
app never stores or manages credentials itself.
|
|
266
|
+
|
|
267
|
+
### Audit log
|
|
268
|
+
|
|
269
|
+
Security-relevant actions (install/uninstall, permission grants/revokes, API
|
|
270
|
+
key rotation) are recorded in the `audit_log` table and viewable at
|
|
271
|
+
`/admin/audit-log`.
|
|
272
|
+
|
|
273
|
+
### Bridge rate limiting
|
|
274
|
+
|
|
275
|
+
In-process telemetry/storage calls (`report_event`, `report_metric`) are
|
|
276
|
+
rate-limited per app via a token bucket (default 100 events/min, configurable
|
|
277
|
+
via `BRIDGE_RATE_LIMIT_RATE` / `BRIDGE_RATE_LIMIT_BURST` /
|
|
278
|
+
`BRIDGE_RATE_LIMIT_ENABLED`).
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
188
282
|
## 🧩 Pluggable UI Slots & Extension Hooks
|
|
189
283
|
|
|
190
284
|
Extensions can inject components into host slots:
|
|
@@ -296,6 +296,8 @@ def load_wsgi_app_from_path(app_dir: str, entry_point: str = "app:app") -> Any:
|
|
|
296
296
|
|
|
297
297
|
mod = importlib.util.module_from_spec(spec)
|
|
298
298
|
sys.modules[scoped_module_name] = mod
|
|
299
|
+
if app_dir not in sys.path:
|
|
300
|
+
sys.path.insert(0, app_dir)
|
|
299
301
|
spec.loader.exec_module(mod)
|
|
300
302
|
|
|
301
303
|
# Check for Flask app instance, callable create_app factory, or WSGI middleware
|
|
@@ -594,15 +596,53 @@ def stage_zip_file(
|
|
|
594
596
|
return staging_id, scan_report, manifest
|
|
595
597
|
|
|
596
598
|
|
|
599
|
+
def slug_conflict_info(
|
|
600
|
+
slug: str, new_manifest: Optional[Dict[str, Any]] = None
|
|
601
|
+
) -> Optional[Dict[str, Any]]:
|
|
602
|
+
"""
|
|
603
|
+
If an app with ``slug`` is already installed, returns a dict describing the
|
|
604
|
+
conflict (existing app id/name/version vs the incoming manifest's version).
|
|
605
|
+
Returns None when there is no conflict.
|
|
606
|
+
"""
|
|
607
|
+
if not slug:
|
|
608
|
+
return None
|
|
609
|
+
existing = InstalledApp.query.filter_by(slug=slug).first()
|
|
610
|
+
if not existing:
|
|
611
|
+
return None
|
|
612
|
+
|
|
613
|
+
existing_version = None
|
|
614
|
+
try:
|
|
615
|
+
from appmanager.admin.registry import manifest_for
|
|
616
|
+
|
|
617
|
+
existing_version = (manifest_for(existing) or {}).get("version")
|
|
618
|
+
except Exception:
|
|
619
|
+
pass
|
|
620
|
+
|
|
621
|
+
new_version = (new_manifest or {}).get("version")
|
|
622
|
+
return {
|
|
623
|
+
"slug_conflict": True,
|
|
624
|
+
"existing_app_id": existing.id,
|
|
625
|
+
"existing_name": existing.name,
|
|
626
|
+
"existing_version": existing_version,
|
|
627
|
+
"new_version": new_version,
|
|
628
|
+
"versions_differ": (existing_version or "") != (new_version or ""),
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
597
632
|
def finalize_staged_installation(
|
|
598
633
|
staging_id: str,
|
|
599
634
|
name: Optional[str] = None,
|
|
600
635
|
slug: Optional[str] = None,
|
|
601
636
|
entry_point: Optional[str] = None,
|
|
637
|
+
replace_existing: bool = False,
|
|
602
638
|
) -> InstalledApp:
|
|
603
639
|
"""
|
|
604
640
|
Finalizes installation of a staged application: moves files into INSTALLED_APPS_DIR,
|
|
605
641
|
installs requirements, registers in database, configures settings and permissions.
|
|
642
|
+
|
|
643
|
+
If ``replace_existing`` is True and an app with the target slug is already installed,
|
|
644
|
+
its files are atomically replaced (preserving the app record, permissions, and settings)
|
|
645
|
+
instead of raising a conflict error.
|
|
606
646
|
"""
|
|
607
647
|
session = get_staged_session(staging_id)
|
|
608
648
|
if not session:
|
|
@@ -620,10 +660,53 @@ def finalize_staged_installation(
|
|
|
620
660
|
target_dir = os.path.join(current_app.config["INSTALLED_APPS_DIR"], final_slug)
|
|
621
661
|
logger.info("Finalizing installation of staged app '%s' into '%s'", final_slug, target_dir)
|
|
622
662
|
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
663
|
+
existing_app = InstalledApp.query.filter_by(slug=final_slug).first()
|
|
664
|
+
if os.path.exists(target_dir) or existing_app:
|
|
665
|
+
if not replace_existing:
|
|
666
|
+
if os.path.exists(staging_dir):
|
|
667
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
668
|
+
raise ValueError(f"An app with slug '{final_slug}' is already installed.")
|
|
669
|
+
|
|
670
|
+
# ---- Replace existing app's files atomically (preserve record/permissions) ----
|
|
671
|
+
if not existing_app:
|
|
672
|
+
raise ValueError(
|
|
673
|
+
f"App directory for slug '{final_slug}' exists but no matching app record was found."
|
|
674
|
+
)
|
|
675
|
+
_replace_staged_files(staging_dir, target_dir)
|
|
676
|
+
|
|
677
|
+
# Refresh manifest-derived metadata on the existing record.
|
|
678
|
+
manifest = parse_manifest(target_dir) or session.get("manifest") or {}
|
|
679
|
+
if manifest.get("name"):
|
|
680
|
+
existing_app.name = manifest["name"]
|
|
681
|
+
if manifest.get("description"):
|
|
682
|
+
existing_app.description = manifest["description"]
|
|
683
|
+
if manifest.get("entry_point"):
|
|
684
|
+
existing_app.entry_point = manifest["entry_point"]
|
|
685
|
+
existing_app.set_seo(manifest.get("seo"))
|
|
686
|
+
db.session.commit()
|
|
687
|
+
|
|
688
|
+
# Clear WSGI cache and trigger reload signals.
|
|
689
|
+
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
690
|
+
current_app.extensions["appmanager"].clear_cache(slug=existing_app.slug)
|
|
691
|
+
try:
|
|
692
|
+
subapp_reloaded.send(None, slug=existing_app.slug)
|
|
693
|
+
subapp_updated.send(
|
|
694
|
+
None, app_slug=existing_app.slug, app_id=existing_app.id, update_type="install"
|
|
695
|
+
)
|
|
696
|
+
except Exception:
|
|
697
|
+
pass
|
|
698
|
+
try:
|
|
699
|
+
from appmanager.hooks import trigger_hook
|
|
700
|
+
|
|
701
|
+
trigger_hook(
|
|
702
|
+
"on_app_updated",
|
|
703
|
+
app_id=existing_app.id,
|
|
704
|
+
app_slug=existing_app.slug,
|
|
705
|
+
update_type="install",
|
|
706
|
+
)
|
|
707
|
+
except Exception:
|
|
708
|
+
pass
|
|
709
|
+
return existing_app
|
|
627
710
|
|
|
628
711
|
# Remove staging metadata file before moving into installed_apps
|
|
629
712
|
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
@@ -687,13 +770,55 @@ def finalize_staged_installation(
|
|
|
687
770
|
if initial_settings:
|
|
688
771
|
installed_app.set_settings(initial_settings)
|
|
689
772
|
|
|
773
|
+
# Persist declarative SEO metadata from the manifest (if any).
|
|
774
|
+
installed_app.set_seo(manifest.get("seo"))
|
|
775
|
+
|
|
690
776
|
db.session.add(installed_app)
|
|
691
777
|
db.session.commit()
|
|
692
778
|
|
|
779
|
+
# Create default (denied) permission rows for DB / auth-readonly requests.
|
|
780
|
+
try:
|
|
781
|
+
from appmanager.db_access import ensure_permission_rows
|
|
782
|
+
|
|
783
|
+
ensure_permission_rows(installed_app, manifest)
|
|
784
|
+
except Exception as e:
|
|
785
|
+
logger.warning("Failed to seed permission rows for '%s': %s", final_slug, e)
|
|
786
|
+
|
|
787
|
+
# Generate a per-app API key (stored as a secret config row).
|
|
788
|
+
try:
|
|
789
|
+
from appmanager.app_config import generate_app_api_key
|
|
790
|
+
|
|
791
|
+
generate_app_api_key(installed_app.id)
|
|
792
|
+
except Exception as e:
|
|
793
|
+
logger.warning("Failed to generate API key for '%s': %s", final_slug, e)
|
|
794
|
+
|
|
795
|
+
# Record the install in the audit log.
|
|
796
|
+
try:
|
|
797
|
+
from appmanager.audit import log_action
|
|
798
|
+
|
|
799
|
+
log_action(
|
|
800
|
+
"app_install",
|
|
801
|
+
actor_type="admin",
|
|
802
|
+
actor_id=getattr(installed_app, "installed_by", None),
|
|
803
|
+
app_id=installed_app.id,
|
|
804
|
+
details={"slug": final_slug, "source_type": session.get("source_type")},
|
|
805
|
+
)
|
|
806
|
+
except Exception:
|
|
807
|
+
pass
|
|
808
|
+
|
|
693
809
|
users = User.query.all()
|
|
694
|
-
for
|
|
695
|
-
|
|
696
|
-
|
|
810
|
+
# Bulk-insert a grant-all permission for every user (one statement instead
|
|
811
|
+
# of one INSERT per user). Skip users who already have a row for this app.
|
|
812
|
+
existing_user_ids = {
|
|
813
|
+
p.user_id for p in UserAppPermission.query.filter_by(app_id=installed_app.id).all()
|
|
814
|
+
}
|
|
815
|
+
new_perms = [
|
|
816
|
+
{"user_id": u.id, "app_id": installed_app.id, "can_access": True}
|
|
817
|
+
for u in users
|
|
818
|
+
if u.id not in existing_user_ids
|
|
819
|
+
]
|
|
820
|
+
if new_perms:
|
|
821
|
+
db.session.bulk_insert_mappings(UserAppPermission, new_perms)
|
|
697
822
|
db.session.commit()
|
|
698
823
|
|
|
699
824
|
# Sync declared admin panels from the manifest
|
|
@@ -881,6 +1006,7 @@ def update_app_from_git(app_id_or_slug: Any) -> Tuple[bool, str, Dict[str, Any]]
|
|
|
881
1006
|
app_record.description = manifest["description"]
|
|
882
1007
|
if manifest.get("entry_point"):
|
|
883
1008
|
app_record.entry_point = manifest["entry_point"]
|
|
1009
|
+
app_record.set_seo(manifest.get("seo"))
|
|
884
1010
|
|
|
885
1011
|
db.session.commit()
|
|
886
1012
|
|
|
@@ -913,6 +1039,46 @@ def update_app_from_git(app_id_or_slug: Any) -> Tuple[bool, str, Dict[str, Any]]
|
|
|
913
1039
|
)
|
|
914
1040
|
|
|
915
1041
|
|
|
1042
|
+
def _replace_staged_files(staging_dir: str, target_dir: str) -> None:
|
|
1043
|
+
"""
|
|
1044
|
+
Atomically replaces the contents of ``target_dir`` with the staged files in
|
|
1045
|
+
``staging_dir``. On failure, restores the original target contents.
|
|
1046
|
+
"""
|
|
1047
|
+
import tempfile
|
|
1048
|
+
|
|
1049
|
+
temp_backup_dir = None
|
|
1050
|
+
try:
|
|
1051
|
+
if os.path.exists(target_dir):
|
|
1052
|
+
temp_backup_dir = tempfile.mkdtemp(prefix="app_backup_")
|
|
1053
|
+
for item in os.listdir(target_dir):
|
|
1054
|
+
shutil.move(os.path.join(target_dir, item), os.path.join(temp_backup_dir, item))
|
|
1055
|
+
|
|
1056
|
+
# Remove staging metadata before moving files in.
|
|
1057
|
+
meta_path = os.path.join(staging_dir, "_staging_meta.json")
|
|
1058
|
+
if os.path.exists(meta_path):
|
|
1059
|
+
try:
|
|
1060
|
+
os.remove(meta_path)
|
|
1061
|
+
except Exception:
|
|
1062
|
+
pass
|
|
1063
|
+
|
|
1064
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
1065
|
+
for item in os.listdir(staging_dir):
|
|
1066
|
+
shutil.move(os.path.join(staging_dir, item), os.path.join(target_dir, item))
|
|
1067
|
+
|
|
1068
|
+
if os.path.exists(staging_dir):
|
|
1069
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
1070
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir):
|
|
1071
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
1072
|
+
except Exception as e:
|
|
1073
|
+
if temp_backup_dir and os.path.exists(temp_backup_dir) and os.path.exists(target_dir):
|
|
1074
|
+
shutil.rmtree(target_dir, ignore_errors=True)
|
|
1075
|
+
os.makedirs(target_dir, exist_ok=True)
|
|
1076
|
+
for item in os.listdir(temp_backup_dir):
|
|
1077
|
+
shutil.move(os.path.join(temp_backup_dir, item), os.path.join(target_dir, item))
|
|
1078
|
+
shutil.rmtree(temp_backup_dir, ignore_errors=True)
|
|
1079
|
+
raise RuntimeError(f"Failed to replace application files: {e}")
|
|
1080
|
+
|
|
1081
|
+
|
|
916
1082
|
def stage_zip_replacement(
|
|
917
1083
|
app_id_or_slug: Any,
|
|
918
1084
|
zip_file_storage: Any,
|
|
@@ -1025,6 +1191,7 @@ def finalize_zip_replacement(staging_id: str) -> InstalledApp:
|
|
|
1025
1191
|
app_record.description = manifest["description"]
|
|
1026
1192
|
if manifest.get("entry_point"):
|
|
1027
1193
|
app_record.entry_point = manifest["entry_point"]
|
|
1194
|
+
app_record.set_seo(manifest.get("seo"))
|
|
1028
1195
|
|
|
1029
1196
|
db.session.commit()
|
|
1030
1197
|
|
|
@@ -1060,6 +1227,27 @@ def uninstall_app(app_id: int) -> Tuple[bool, str]:
|
|
|
1060
1227
|
if hasattr(current_app, "extensions") and "appmanager" in current_app.extensions:
|
|
1061
1228
|
current_app.extensions["appmanager"].clear_cache(slug=slug)
|
|
1062
1229
|
|
|
1230
|
+
# Clean up DB permissions and any scoped tables created for this app.
|
|
1231
|
+
try:
|
|
1232
|
+
from appmanager.db_access import cleanup_app_data
|
|
1233
|
+
|
|
1234
|
+
cleanup_app_data(app_record.id, slug)
|
|
1235
|
+
except Exception as e:
|
|
1236
|
+
logger.warning("Failed to clean up DB data for '%s': %s", slug, e)
|
|
1237
|
+
|
|
1238
|
+
# Record the uninstall in the audit log.
|
|
1239
|
+
try:
|
|
1240
|
+
from appmanager.audit import log_action
|
|
1241
|
+
|
|
1242
|
+
log_action(
|
|
1243
|
+
"app_uninstall",
|
|
1244
|
+
actor_type="admin",
|
|
1245
|
+
app_id=app_id,
|
|
1246
|
+
details={"slug": slug, "name": app_record.name},
|
|
1247
|
+
)
|
|
1248
|
+
except Exception:
|
|
1249
|
+
pass
|
|
1250
|
+
|
|
1063
1251
|
db.session.delete(app_record)
|
|
1064
1252
|
db.session.commit()
|
|
1065
1253
|
|
|
@@ -86,11 +86,14 @@ def sync_panels(app_record, manifest=None):
|
|
|
86
86
|
~AppAdminPanel.panel_id.in_(declared_ids) if declared_ids else True
|
|
87
87
|
).delete(synchronize_session=False)
|
|
88
88
|
|
|
89
|
+
# Load existing panels for this app once (avoids one query per declared panel).
|
|
90
|
+
existing = {p.panel_id: p for p in AppAdminPanel.query.filter_by(app_id=app_record.id).all()}
|
|
91
|
+
|
|
89
92
|
for i, p in enumerate(declared):
|
|
90
93
|
pid = p.get("id")
|
|
91
94
|
if not pid:
|
|
92
95
|
continue
|
|
93
|
-
row =
|
|
96
|
+
row = existing.get(pid)
|
|
94
97
|
if not row:
|
|
95
98
|
row = AppAdminPanel(app_id=app_record.id, panel_id=pid)
|
|
96
99
|
db.session.add(row)
|