Spaces:
Running
Running
Upload 36 files
Browse files- .github/ISSUE_TEMPLATE/bug_report.md +63 -0
- .github/workflows/generate_structure.yml +56 -0
- .gitignore +5 -7
- ESOL +91 -0
- HF_README.md +98 -0
- LICENSE +201 -0
- PyFundaments – Function Overview.md +145 -0
- PyFundaments.md +232 -0
- README_MCP_HUB.md +63 -0
- app/.pyfun +313 -0
- app/__init__.py +1 -0
- app/app.py +281 -0
- app/config.py +293 -0
- app/db_sync.py +1 -0
- app/mcp.py +268 -0
- app/models.py +1 -0
- app/provider.py +1 -0
- app/tools.py +1 -0
- docs/access_control.py.md +112 -0
- docs/config_handler.py.md +34 -0
- docs/encryption.py.md +95 -0
- docs/postgresql.py.md +227 -0
- docs/security.py.md +196 -0
- docs/user_handler.py.md +53 -0
- example-mcp___.env +119 -0
- example.Dockerfile +28 -0
- fundaments/__init__.py +1 -0
- fundaments/access_control.py +240 -0
- fundaments/config_handler.py +115 -0
- fundaments/debug.py +91 -0
- fundaments/encryption.py +233 -0
- fundaments/postgresql.py +179 -0
- fundaments/security.py +79 -0
- fundaments/user_handler.py +342 -0
- main.py +194 -0
- requirements.txt +31 -0
.github/ISSUE_TEMPLATE/bug_report.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: Bug report
|
| 3 |
+
about: Create a report to help us improve
|
| 4 |
+
title: ''
|
| 5 |
+
labels: ''
|
| 6 |
+
assignees: ''
|
| 7 |
+
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
name: Bug Report
|
| 12 |
+
about: Report a bug to help us improve PyFundaments.
|
| 13 |
+
title: "[BUG] "
|
| 14 |
+
labels: 'bug'
|
| 15 |
+
---
|
| 16 |
+
|
| 17 |
+
### Description of the bug
|
| 18 |
+
|
| 19 |
+
A clear and concise description of what the bug is.
|
| 20 |
+
- Which specific module in `fundaments/` is affected? (e.g., `postgresql.py`, `security.py`, `encryption.py`)
|
| 21 |
+
- What is the unexpected behavior?
|
| 22 |
+
|
| 23 |
+
***
|
| 24 |
+
|
| 25 |
+
### (Steps to Reproduce)
|
| 26 |
+
|
| 27 |
+
1. **Environment Setup:** Describe how your environment is set up.
|
| 28 |
+
- Are you running it locally or in a Docker container?
|
| 29 |
+
- What values are in your `.env` file? (Please only share non-sensitive values or use placeholders like `DATABASE_URL="..."`)
|
| 30 |
+
- What version of Python are you using?
|
| 31 |
+
|
| 32 |
+
2. **Code Snippet:** Provide a minimal, reproducible code snippet that demonstrates the bug. This should ideally be a small `app.py` or a test file that triggers the issue.
|
| 33 |
+
|
| 34 |
+
3. **Execution:** Describe the exact command you ran to start the application (e.g., `python main.py` or a Docker command).
|
| 35 |
+
|
| 36 |
+
4. **Result:** What was the output? Include the full error traceback if one was generated.
|
| 37 |
+
|
| 38 |
+
***
|
| 39 |
+
|
| 40 |
+
### Expected behavior
|
| 41 |
+
|
| 42 |
+
A clear and concise description of what you expected to happen. How should the module behave correctly?
|
| 43 |
+
|
| 44 |
+
***
|
| 45 |
+
|
| 46 |
+
### Desktop & Mobile Information
|
| 47 |
+
|
| 48 |
+
This section is for web-facing applications. If your bug is related to a specific client, please provide details.
|
| 49 |
+
|
| 50 |
+
- **OS:** [e.g. Ubuntu 22.04, macOS 14]
|
| 51 |
+
- **Docker Image (if applicable):** [e.g. python:3.10-slim]
|
| 52 |
+
- **Browser (if applicable):** [e.g. Chrome, Firefox]
|
| 53 |
+
- **Device (if applicable):** [e.g. iPhone 15 Pro]
|
| 54 |
+
|
| 55 |
+
***
|
| 56 |
+
|
| 57 |
+
### Additional context
|
| 58 |
+
|
| 59 |
+
Add any other context about the problem here, such as:
|
| 60 |
+
- Did the issue appear after a specific change in the code?
|
| 61 |
+
- Any other services or dependencies that might be relevant?
|
| 62 |
+
|
| 63 |
+
Thank you for helping to improve PyFundaments!
|
.github/workflows/generate_structure.yml
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# WORKFLOW: Generate Repo Structure - [Generate Repo Structure]
|
| 3 |
+
# PART OF: Codey - No Mercy EDITION
|
| 4 |
+
# =============================================================================
|
| 5 |
+
# Role: Automated Audit & Compliance (ESOL v1.1)
|
| 6 |
+
# Copyright: (c) 2026 VolkanSah
|
| 7 |
+
# License: Apache 2.0 + ESOL v1.1 (https://github.com/VolkanSah/ESOL)
|
| 8 |
+
# Enforcement: Jurisdiction Berlin, Germany (StGB & DSGVO)
|
| 9 |
+
# =============================================================================
|
| 10 |
+
|
| 11 |
+
# Name of the workflow used to visualize the file hierarchy
|
| 12 |
+
name: Generate Repo Structure
|
| 13 |
+
|
| 14 |
+
# Trigger: Manual execution only (allows the dev to choose when to update the map)
|
| 15 |
+
on:
|
| 16 |
+
workflow_dispatch:
|
| 17 |
+
|
| 18 |
+
# Permissions: Granting write access so the bot can save the generated Markdown
|
| 19 |
+
permissions:
|
| 20 |
+
contents: write
|
| 21 |
+
|
| 22 |
+
jobs:
|
| 23 |
+
# Job to analyze and map the directory tree
|
| 24 |
+
structure:
|
| 25 |
+
# Running on the latest stable Ubuntu environment
|
| 26 |
+
runs-on: ubuntu-latest
|
| 27 |
+
steps:
|
| 28 |
+
# Step 1: Pull the latest code using a custom token
|
| 29 |
+
- uses: actions/checkout@v4
|
| 30 |
+
with:
|
| 31 |
+
token: ${{ secrets.GIT_TOKEN }}
|
| 32 |
+
|
| 33 |
+
# Step 2: Prepare Python for the generator script
|
| 34 |
+
- uses: actions/setup-python@v5
|
| 35 |
+
with:
|
| 36 |
+
python-version: '3.11'
|
| 37 |
+
|
| 38 |
+
# Step 3: Run the custom logic that scans directories and creates PROJECT_STRUCTURE.md
|
| 39 |
+
- name: Generate structure
|
| 40 |
+
run: python .codey/scripts/generate_structure.py
|
| 41 |
+
|
| 42 |
+
# Step 4: Finalize by pushing the new "Map" to the repository root
|
| 43 |
+
- name: Commit structure
|
| 44 |
+
run: |
|
| 45 |
+
# Identity setup for the automated commit
|
| 46 |
+
git config user.name "Codey Bot"
|
| 47 |
+
git config user.email "codey@bot"
|
| 48 |
+
|
| 49 |
+
# Stage the specific file generated by the script
|
| 50 |
+
git add PROJECT_STRUCTURE.md
|
| 51 |
+
|
| 52 |
+
# Check for changes to prevent empty commits; [skip ci] avoids re-triggering workflows
|
| 53 |
+
git diff --staged --quiet || git commit -m "📁 update project structure [skip ci]"
|
| 54 |
+
|
| 55 |
+
# Push the updated structure map back to the origin
|
| 56 |
+
git push
|
.gitignore
CHANGED
|
@@ -2,9 +2,7 @@
|
|
| 2 |
|
| 3 |
README.MD
|
| 4 |
.gitignore
|
| 5 |
-
app/
|
| 6 |
-
.github
|
| 7 |
-
|
| 8 |
|
| 9 |
|
| 10 |
|
|
@@ -127,8 +125,8 @@ ipython_config.py
|
|
| 127 |
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 128 |
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 129 |
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 130 |
-
pdm.lock
|
| 131 |
-
pdm.toml
|
| 132 |
.pdm-python
|
| 133 |
.pdm-build/
|
| 134 |
|
|
@@ -188,7 +186,7 @@ cython_debug/
|
|
| 188 |
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 189 |
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 190 |
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 191 |
-
.idea/
|
| 192 |
|
| 193 |
# Abstra
|
| 194 |
# Abstra is an AI-powered process automation framework.
|
|
@@ -219,4 +217,4 @@ cython_debug/
|
|
| 219 |
# Marimo
|
| 220 |
marimo/_static/
|
| 221 |
marimo/_lsp/
|
| 222 |
-
__marimo__/
|
|
|
|
| 2 |
|
| 3 |
README.MD
|
| 4 |
.gitignore
|
| 5 |
+
app/*
|
|
|
|
|
|
|
| 6 |
|
| 7 |
|
| 8 |
|
|
|
|
| 125 |
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
| 126 |
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
| 127 |
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
| 128 |
+
#pdm.lock
|
| 129 |
+
#pdm.toml
|
| 130 |
.pdm-python
|
| 131 |
.pdm-build/
|
| 132 |
|
|
|
|
| 186 |
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
| 187 |
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
| 188 |
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
| 189 |
+
#.idea/
|
| 190 |
|
| 191 |
# Abstra
|
| 192 |
# Abstra is an AI-powered process automation framework.
|
|
|
|
| 217 |
# Marimo
|
| 218 |
marimo/_static/
|
| 219 |
marimo/_lsp/
|
| 220 |
+
__marimo__/
|
ESOL
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
## Ethical Security Operations License (ESOL v1.1)
|
| 2 |
+
since 2021
|
| 3 |
+
updated on 05.02.2026
|
| 4 |
+
|
| 5 |
+
### Section 1: Preamble and Scope
|
| 6 |
+
The **Ethical Security Operations License (ESOL v1.1)** is an **additional, non-severable condition** supplementing the **MIT License** under which the covered software (hereinafter "the Work") is distributed.
|
| 7 |
+
|
| 8 |
+
By downloading, copying, modifying, or executing the Work, the Licensee **irrevocably agrees** to adhere to both the terms of the MIT License and the specific, mandatory ethical constraints defined herein. **Lack of awareness of these terms does not constitute a defense.**
|
| 9 |
+
|
| 10 |
+
### Section 2: Mandatory Ethical Use and Purpose
|
| 11 |
+
The grant of rights under this License is **expressly and exclusively conditioned** upon the Licensee's continuous adherence to the following use limitations:
|
| 12 |
+
|
| 13 |
+
1. **Authorized Use Only:** The Work shall be used **exclusively** for:
|
| 14 |
+
- Defensive security operations (Blue Teaming)
|
| 15 |
+
- Authorized penetration testing (Red Teaming) with documented consent
|
| 16 |
+
- Vulnerability research on systems owned or explicitly authorized by the researcher
|
| 17 |
+
- Security compliance auditing with contractual authorization
|
| 18 |
+
|
| 19 |
+
2. **Explicit Written Authorization Required:** Any security testing, scanning, exploitation, or enumeration against **any system not wholly owned by the Licensee** requires **explicit, documented, written authorization** from the rightful owner **prior to execution**. Verbal permission, implicit consent, or "bug bounty program existence" does **not** constitute authorization without explicit scope documentation.
|
| 20 |
+
|
| 21 |
+
3. **Educational Use Constraints:** Educational or research use must:
|
| 22 |
+
- Occur only in isolated, controlled environments (virtual machines, dedicated test networks)
|
| 23 |
+
- Not target production systems, public infrastructure, or third-party services
|
| 24 |
+
- Comply with institutional ethics board requirements where applicable
|
| 25 |
+
|
| 26 |
+
### Section 3: Prohibited Use (Malicious Activities)
|
| 27 |
+
The Licensee is **strictly and unconditionally prohibited** from using the Work for:
|
| 28 |
+
|
| 29 |
+
* **Unauthorized Access or Reconnaissance:** Scanning, probing, accessing, or attempting to access any computer system, network, database, or data without **explicit prior written authorization** from the lawful owner.
|
| 30 |
+
|
| 31 |
+
* **Malicious Code Operations:** Creating, distributing, executing, or facilitating:
|
| 32 |
+
- Malware, viruses, worms, trojans, ransomware, or rootkits
|
| 33 |
+
- Cryptominers operating without system owner consent
|
| 34 |
+
- Phishing infrastructure, scam campaigns, or social engineering attacks
|
| 35 |
+
- Exploits targeting zero-day vulnerabilities outside authorized disclosure processes
|
| 36 |
+
|
| 37 |
+
* **Service Disruption:** Denial-of-service (DoS/DDoS) attacks, resource exhaustion, or any unauthorized degradation of system availability or performance.
|
| 38 |
+
|
| 39 |
+
* **Data Exfiltration or Manipulation:** Unauthorized copying, modification, deletion, or encryption of data not owned by the Licensee.
|
| 40 |
+
|
| 41 |
+
* **Legal Violations:** Any activity violating the Computer Fraud and Abuse Act (CFAA), GDPR, national cybercrime laws, or international treaties concerning unauthorized computer access.
|
| 42 |
+
|
| 43 |
+
* **Circumvention of Security Controls:** Bypassing authentication, encryption, access controls, or security monitoring systems without documented authorization.
|
| 44 |
+
|
| 45 |
+
### Section 4: Compliance Verification and Audit Rights
|
| 46 |
+
The Licensor reserves the right to:
|
| 47 |
+
- Request documentation of authorization for any deployment of the Work
|
| 48 |
+
- Audit compliance upon reasonable notice if misuse is suspected
|
| 49 |
+
- Publicly disclose violations (including Licensee identity) to warn the security community
|
| 50 |
+
|
| 51 |
+
**Licensees conducting authorized security research are encouraged to maintain contemporaneous records of authorization as evidence of compliance.**
|
| 52 |
+
|
| 53 |
+
### Section 5: No Warranty Regarding Legal Compliance
|
| 54 |
+
**THE WORK IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND. THE LICENSOR MAKES NO REPRESENTATION THAT USE OF THE WORK, EVEN IN COMPLIANCE WITH THIS LICENSE, WILL BE LEGAL IN ALL JURISDICTIONS. THE LICENSEE BEARS SOLE RESPONSIBILITY FOR ENSURING COMPLIANCE WITH ALL APPLICABLE LAWS.**
|
| 55 |
+
|
| 56 |
+
### Section 6: Violation and Termination
|
| 57 |
+
Violation of **any** provision of this ESOL v1.1 constitutes a **material breach** resulting in:
|
| 58 |
+
|
| 59 |
+
1. **Immediate Automatic Termination** of all rights under both the MIT License and this ESOL
|
| 60 |
+
2. **Obligation to Cease Use** and destroy all copies of the Work
|
| 61 |
+
3. **Liability for Damages:** The Licensee shall be liable for all damages, including but not limited to:
|
| 62 |
+
- Direct and consequential damages to affected third parties
|
| 63 |
+
- Licensor's legal costs in enforcement actions
|
| 64 |
+
- Reputational harm to the Licensor or the security research community
|
| 65 |
+
|
| 66 |
+
4. **Reporting to Authorities:** The Licensor reserves the right to report violations to appropriate law enforcement and cybersecurity authorities.
|
| 67 |
+
|
| 68 |
+
### Section 7: Severability and Precedence
|
| 69 |
+
If any provision of this License is held unenforceable, the remaining provisions remain in full effect. **In any conflict between the MIT License and ESOL v1.1, the ESOL v1.1 takes precedence regarding ethical use constraints.**
|
| 70 |
+
|
| 71 |
+
### Section 8: Jurisdiction and Dispute Resolution
|
| 72 |
+
This License shall be governed by the laws of **[Germany(Berlin)]**. Disputes shall be resolved through binding arbitration under **[Arbitration Rules]**, with the prevailing party entitled to attorney's fees.
|
| 73 |
+
|
| 74 |
+
### Section 9: License Acceptance Evidence
|
| 75 |
+
By downloading this software from any source (GitHub, mirrors, forks), you acknowledge that:
|
| 76 |
+
- This license was prominently displayed in the repository
|
| 77 |
+
- You had reasonable opportunity to review these terms
|
| 78 |
+
- Your use constitutes legally binding acceptance
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Final Licensing Statement
|
| 82 |
+
**This Work is dual-licensed under:**
|
| 83 |
+
1. **The MIT License** (for general software rights)
|
| 84 |
+
2. **The Ethical Security Operations License v1.1 (ESOL v1.1)** (for use constraints)
|
| 85 |
+
|
| 86 |
+
**The ESOL v1.1 is a mandatory, non-severable condition of use. Acceptance is automatic upon use of the Work.**
|
| 87 |
+
|
| 88 |
+
**By using this software, you acknowledge that misuse may result in criminal prosecution, civil liability, and permanent termination of license rights.**
|
| 89 |
+
|
| 90 |
+
> Checked & Updated, on 08.02.2026 @01700MEZ
|
| 91 |
+
## Ethical Security Operations License (ESOL v1.1) since 2021 against fraud and scam
|
HF_README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Universal MCP Hub
|
| 3 |
+
emoji: 🐢
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
license: apache-2.0
|
| 9 |
+
short_description: Universal MCP Hub (Sandboxed)
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Universal MCP Hub (Sandboxed)
|
| 13 |
+
|
| 14 |
+
> For advanced use, have a look at [PyFundaments.md](PyFundaments.md) and the `docs/` folder.
|
| 15 |
+
|
| 16 |
+
Universal MCP Server running in **paranoid mode** — built on [PyFundaments](https://github.com/VolkanSah/PyFundaments) and licensed under ESOL.
|
| 17 |
+
|
| 18 |
+
The goal was simple: too many MCP servers out there with no sandboxing, hardcoded keys, and zero security thought. This one is different. No key = no tool = no crash. The Guardian (`main.py`) controls everything. `app/mcp.py` gets only what it needs, nothing more.
|
| 19 |
+
|
| 20 |
+
- MCP_HUB Built with Claude (Anthropic) as a typing tool. Architecture, security decisions
|
| 21 |
+
- Pyfundaments by Volkan Sah read [ESOL](ESOL)
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## Setup
|
| 26 |
+
|
| 27 |
+
1. **Fork** this Space.
|
| 28 |
+
2. Enter your API keys as **Space Secrets** (Settings → Variables and secrets).
|
| 29 |
+
3. The Space starts automatically — only tools with valid keys will be registered.
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## Available Tools (Depending on Configured Keys)
|
| 34 |
+
|
| 35 |
+
| Secret | Tool | Description |
|
| 36 |
+
| :--- | :--- | :--- |
|
| 37 |
+
| `ANTHROPIC_API_KEY` | `anthropic_complete` | Claude Models |
|
| 38 |
+
| `GEMINI_API_KEY` | `gemini_complete` | Google Gemini Models |
|
| 39 |
+
| `OPENROUTER_API_KEY` | `openrouter_complete` | 100+ Models via OpenRouter |
|
| 40 |
+
| `HF_TOKEN` | `hf_inference` | HuggingFace Inference API |
|
| 41 |
+
| `BRAVE_API_KEY` | `brave_search` | Web Search (independent index) |
|
| 42 |
+
| `TAVILY_API_KEY` | `tavily_search` | AI-optimized Search |
|
| 43 |
+
| *(Always Active)* | `list_active_tools` | Shows all currently active tools |
|
| 44 |
+
| *(Always Active)* | `health_check` | System health check |
|
| 45 |
+
|
| 46 |
+
---
|
| 47 |
+
|
| 48 |
+
## MCP Client Configuration (SSE)
|
| 49 |
+
|
| 50 |
+
To connect Claude Desktop or any MCP client to this hub:
|
| 51 |
+
|
| 52 |
+
```json
|
| 53 |
+
{
|
| 54 |
+
"mcpServers": {
|
| 55 |
+
"pyfundaments-hub": {
|
| 56 |
+
"url": "https://YOUR_USERNAME-universal-mcp-hub.hf.space/sse"
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Architecture
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
main.py ← Guardian: initializes all services, controls what app/ receives
|
| 68 |
+
└── app/mcp.py ← Sandbox: registers only tools with valid keys
|
| 69 |
+
├── LLM tools (Anthropic, Gemini, OpenRouter, HuggingFace)
|
| 70 |
+
├── Search tools (Brave, Tavily)
|
| 71 |
+
├── DB tools (only if DATABASE_URL is set)
|
| 72 |
+
└── System tools (always active)
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
**The Guardian pattern:** `app/mcp.py` never reads `os.environ` directly.
|
| 76 |
+
It receives a `fundaments` dict from `main.py` — and only what `main.py` decides to give it.
|
| 77 |
+
|
| 78 |
+
---
|
| 79 |
+
|
| 80 |
+
## Security Notes
|
| 81 |
+
|
| 82 |
+
- All API keys loaded via HuggingFace Space Secrets (env vars) — never hardcoded
|
| 83 |
+
- `list_active_tools` returns key **names** only, never values
|
| 84 |
+
- DB tools are read-only by design (`SELECT` only, enforced at application level)
|
| 85 |
+
- Direct execution of `app/mcp.py` is blocked by design
|
| 86 |
+
- Built on PyFundaments — a security-first Python architecture for developers
|
| 87 |
+
|
| 88 |
+
> PyFundaments is not perfect. But it's more secure than most of what runs in production.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## License
|
| 93 |
+
|
| 94 |
+
Apache License 2.0 + [ESOL 1.1](https://github.com/VolkanSah/ESOL)
|
| 95 |
+
|
| 96 |
+
---
|
| 97 |
+
|
| 98 |
+
*"I use AI as a tool, not as a replacement for thinking."* — Volkan Kücükbudak
|
LICENSE
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Apache License
|
| 2 |
+
Version 2.0, January 2004
|
| 3 |
+
http://www.apache.org/licenses/
|
| 4 |
+
|
| 5 |
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
| 6 |
+
|
| 7 |
+
1. Definitions.
|
| 8 |
+
|
| 9 |
+
"License" shall mean the terms and conditions for use, reproduction,
|
| 10 |
+
and distribution as defined by Sections 1 through 9 of this document.
|
| 11 |
+
|
| 12 |
+
"Licensor" shall mean the copyright owner or entity authorized by
|
| 13 |
+
the copyright owner that is granting the License.
|
| 14 |
+
|
| 15 |
+
"Legal Entity" shall mean the union of the acting entity and all
|
| 16 |
+
other entities that control, are controlled by, or are under common
|
| 17 |
+
control with that entity. For the purposes of this definition,
|
| 18 |
+
"control" means (i) the power, direct or indirect, to cause the
|
| 19 |
+
direction or management of such entity, whether by contract or
|
| 20 |
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
| 21 |
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
| 22 |
+
|
| 23 |
+
"You" (or "Your") shall mean an individual or Legal Entity
|
| 24 |
+
exercising permissions granted by this License.
|
| 25 |
+
|
| 26 |
+
"Source" form shall mean the preferred form for making modifications,
|
| 27 |
+
including but not limited to software source code, documentation
|
| 28 |
+
source, and configuration files.
|
| 29 |
+
|
| 30 |
+
"Object" form shall mean any form resulting from mechanical
|
| 31 |
+
transformation or translation of a Source form, including but
|
| 32 |
+
not limited to compiled object code, generated documentation,
|
| 33 |
+
and conversions to other media types.
|
| 34 |
+
|
| 35 |
+
"Work" shall mean the work of authorship, whether in Source or
|
| 36 |
+
Object form, made available under the License, as indicated by a
|
| 37 |
+
copyright notice that is included in or attached to the work
|
| 38 |
+
(an example is provided in the Appendix below).
|
| 39 |
+
|
| 40 |
+
"Derivative Works" shall mean any work, whether in Source or Object
|
| 41 |
+
form, that is based on (or derived from) the Work and for which the
|
| 42 |
+
editorial revisions, annotations, elaborations, or other modifications
|
| 43 |
+
represent, as a whole, an original work of authorship. For the purposes
|
| 44 |
+
of this License, Derivative Works shall not include works that remain
|
| 45 |
+
separable from, or merely link (or bind by name) to the interfaces of,
|
| 46 |
+
the Work and Derivative Works thereof.
|
| 47 |
+
|
| 48 |
+
"Contribution" shall mean any work of authorship, including
|
| 49 |
+
the original version of the Work and any modifications or additions
|
| 50 |
+
to that Work or Derivative Works thereof, that is intentionally
|
| 51 |
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
| 52 |
+
or by an individual or Legal Entity authorized to submit on behalf of
|
| 53 |
+
the copyright owner. For the purposes of this definition, "submitted"
|
| 54 |
+
means any form of electronic, verbal, or written communication sent
|
| 55 |
+
to the Licensor or its representatives, including but not limited to
|
| 56 |
+
communication on electronic mailing lists, source code control systems,
|
| 57 |
+
and issue tracking systems that are managed by, or on behalf of, the
|
| 58 |
+
Licensor for the purpose of discussing and improving the Work, but
|
| 59 |
+
excluding communication that is conspicuously marked or otherwise
|
| 60 |
+
designated in writing by the copyright owner as "Not a Contribution."
|
| 61 |
+
|
| 62 |
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
| 63 |
+
on behalf of whom a Contribution has been received by Licensor and
|
| 64 |
+
subsequently incorporated within the Work.
|
| 65 |
+
|
| 66 |
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
| 67 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 68 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 69 |
+
copyright license to reproduce, prepare Derivative Works of,
|
| 70 |
+
publicly display, publicly perform, sublicense, and distribute the
|
| 71 |
+
Work and such Derivative Works in Source or Object form.
|
| 72 |
+
|
| 73 |
+
3. Grant of Patent License. Subject to the terms and conditions of
|
| 74 |
+
this License, each Contributor hereby grants to You a perpetual,
|
| 75 |
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
| 76 |
+
(except as stated in this section) patent license to make, have made,
|
| 77 |
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
| 78 |
+
where such license applies only to those patent claims licensable
|
| 79 |
+
by such Contributor that are necessarily infringed by their
|
| 80 |
+
Contribution(s) alone or by combination of their Contribution(s)
|
| 81 |
+
with the Work to which such Contribution(s) was submitted. If You
|
| 82 |
+
institute patent litigation against any entity (including a
|
| 83 |
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
| 84 |
+
or a Contribution incorporated within the Work constitutes direct
|
| 85 |
+
or contributory patent infringement, then any patent licenses
|
| 86 |
+
granted to You under this License for that Work shall terminate
|
| 87 |
+
as of the date such litigation is filed.
|
| 88 |
+
|
| 89 |
+
4. Redistribution. You may reproduce and distribute copies of the
|
| 90 |
+
Work or Derivative Works thereof in any medium, with or without
|
| 91 |
+
modifications, and in Source or Object form, provided that You
|
| 92 |
+
meet the following conditions:
|
| 93 |
+
|
| 94 |
+
(a) You must give any other recipients of the Work or
|
| 95 |
+
Derivative Works a copy of this License; and
|
| 96 |
+
|
| 97 |
+
(b) You must cause any modified files to carry prominent notices
|
| 98 |
+
stating that You changed the files; and
|
| 99 |
+
|
| 100 |
+
(c) You must retain, in the Source form of any Derivative Works
|
| 101 |
+
that You distribute, all copyright, patent, trademark, and
|
| 102 |
+
attribution notices from the Source form of the Work,
|
| 103 |
+
excluding those notices that do not pertain to any part of
|
| 104 |
+
the Derivative Works; and
|
| 105 |
+
|
| 106 |
+
(d) If the Work includes a "NOTICE" text file as part of its
|
| 107 |
+
distribution, then any Derivative Works that You distribute must
|
| 108 |
+
include a readable copy of the attribution notices contained
|
| 109 |
+
within such NOTICE file, excluding those notices that do not
|
| 110 |
+
pertain to any part of the Derivative Works, in at least one
|
| 111 |
+
of the following places: within a NOTICE text file distributed
|
| 112 |
+
as part of the Derivative Works; within the Source form or
|
| 113 |
+
documentation, if provided along with the Derivative Works; or,
|
| 114 |
+
within a display generated by the Derivative Works, if and
|
| 115 |
+
wherever such third-party notices normally appear. The contents
|
| 116 |
+
of the NOTICE file are for informational purposes only and
|
| 117 |
+
do not modify the License. You may add Your own attribution
|
| 118 |
+
notices within Derivative Works that You distribute, alongside
|
| 119 |
+
or as an addendum to the NOTICE text from the Work, provided
|
| 120 |
+
that such additional attribution notices cannot be construed
|
| 121 |
+
as modifying the License.
|
| 122 |
+
|
| 123 |
+
You may add Your own copyright statement to Your modifications and
|
| 124 |
+
may provide additional or different license terms and conditions
|
| 125 |
+
for use, reproduction, or distribution of Your modifications, or
|
| 126 |
+
for any such Derivative Works as a whole, provided Your use,
|
| 127 |
+
reproduction, and distribution of the Work otherwise complies with
|
| 128 |
+
the conditions stated in this License.
|
| 129 |
+
|
| 130 |
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
| 131 |
+
any Contribution intentionally submitted for inclusion in the Work
|
| 132 |
+
by You to the Licensor shall be under the terms and conditions of
|
| 133 |
+
this License, without any additional terms or conditions.
|
| 134 |
+
Notwithstanding the above, nothing herein shall supersede or modify
|
| 135 |
+
the terms of any separate license agreement you may have executed
|
| 136 |
+
with Licensor regarding such Contributions.
|
| 137 |
+
|
| 138 |
+
6. Trademarks. This License does not grant permission to use the trade
|
| 139 |
+
names, trademarks, service marks, or product names of the Licensor,
|
| 140 |
+
except as required for reasonable and customary use in describing the
|
| 141 |
+
origin of the Work and reproducing the content of the NOTICE file.
|
| 142 |
+
|
| 143 |
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
| 144 |
+
agreed to in writing, Licensor provides the Work (and each
|
| 145 |
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
| 146 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
| 147 |
+
implied, including, without limitation, any warranties or conditions
|
| 148 |
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
| 149 |
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
| 150 |
+
appropriateness of using or redistributing the Work and assume any
|
| 151 |
+
risks associated with Your exercise of permissions under this License.
|
| 152 |
+
|
| 153 |
+
8. Limitation of Liability. In no event and under no legal theory,
|
| 154 |
+
whether in tort (including negligence), contract, or otherwise,
|
| 155 |
+
unless required by applicable law (such as deliberate and grossly
|
| 156 |
+
negligent acts) or agreed to in writing, shall any Contributor be
|
| 157 |
+
liable to You for damages, including any direct, indirect, special,
|
| 158 |
+
incidental, or consequential damages of any character arising as a
|
| 159 |
+
result of this License or out of the use or inability to use the
|
| 160 |
+
Work (including but not limited to damages for loss of goodwill,
|
| 161 |
+
work stoppage, computer failure or malfunction, or any and all
|
| 162 |
+
other commercial damages or losses), even if such Contributor
|
| 163 |
+
has been advised of the possibility of such damages.
|
| 164 |
+
|
| 165 |
+
9. Accepting Warranty or Additional Liability. While redistributing
|
| 166 |
+
the Work or Derivative Works thereof, You may choose to offer,
|
| 167 |
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
| 168 |
+
or other liability obligations and/or rights consistent with this
|
| 169 |
+
License. However, in accepting such obligations, You may act only
|
| 170 |
+
on Your own behalf and on Your sole responsibility, not on behalf
|
| 171 |
+
of any other Contributor, and only if You agree to indemnify,
|
| 172 |
+
defend, and hold each Contributor harmless for any liability
|
| 173 |
+
incurred by, or claims asserted against, such Contributor by reason
|
| 174 |
+
of your accepting any such warranty or additional liability.
|
| 175 |
+
|
| 176 |
+
END OF TERMS AND CONDITIONS
|
| 177 |
+
|
| 178 |
+
APPENDIX: How to apply the Apache License to your work.
|
| 179 |
+
|
| 180 |
+
To apply the Apache License to your work, attach the following
|
| 181 |
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
| 182 |
+
replaced with your own identifying information. (Don't include
|
| 183 |
+
the brackets!) The text should be enclosed in the appropriate
|
| 184 |
+
comment syntax for the file format. We also recommend that a
|
| 185 |
+
file or class name and description of purpose be included on the
|
| 186 |
+
same "printed page" as the copyright notice for easier
|
| 187 |
+
identification within third-party archives.
|
| 188 |
+
|
| 189 |
+
Copyright [yyyy] [name of copyright owner]
|
| 190 |
+
|
| 191 |
+
Licensed under the Apache License, Version 2.0 (the "License");
|
| 192 |
+
you may not use this file except in compliance with the License.
|
| 193 |
+
You may obtain a copy of the License at
|
| 194 |
+
|
| 195 |
+
http://www.apache.org/licenses/LICENSE-2.0
|
| 196 |
+
|
| 197 |
+
Unless required by applicable law or agreed to in writing, software
|
| 198 |
+
distributed under the License is distributed on an "AS IS" BASIS,
|
| 199 |
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
| 200 |
+
See the License for the specific language governing permissions and
|
| 201 |
+
limitations under the License.
|
PyFundaments – Function Overview.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments – Function Overview
|
| 2 |
+
|
| 3 |
+
## `main.py`
|
| 4 |
+
|
| 5 |
+
| Function | Description |
|
| 6 |
+
| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
| 7 |
+
| `initialize_fundaments()` | Asynchronously initializes services based on available environment variables. Returns a dictionary of services (`None` if not initialized). |
|
| 8 |
+
| `main()` | Application entry point. Calls `initialize_fundaments()`, loads `app/app.py`, closes DB pool on shutdown. |
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## `fundaments/config_handler.py` – `ConfigHandler`
|
| 13 |
+
|
| 14 |
+
| Function | Description |
|
| 15 |
+
| ------------------------ | ------------------------------------------------------------------ |
|
| 16 |
+
| `__init__()` | Loads `.env` via `python-dotenv` and system environment variables. |
|
| 17 |
+
| `load_all_config()` | Stores all non-empty environment variables in `self.config`. |
|
| 18 |
+
| `get(key)` | Returns value as string or `None`. |
|
| 19 |
+
| `get_bool(key, default)` | Parses boolean values (`true/1/yes/on`). |
|
| 20 |
+
| `get_int(key, default)` | Returns integer value or `default` on failure. |
|
| 21 |
+
| `has(key)` | Returns `True` if key exists and is not empty. |
|
| 22 |
+
| `get_all()` | Returns copy of full configuration dictionary. |
|
| 23 |
+
| `config_service` | Global singleton instance. |
|
| 24 |
+
|
| 25 |
+
---
|
| 26 |
+
|
| 27 |
+
## `fundaments/postgresql.py`
|
| 28 |
+
|
| 29 |
+
| Function | Description |
|
| 30 |
+
| ----------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
| 31 |
+
| `enforce_cloud_security(dsn_url)` | Enforces `sslmode=require`, applies timeouts, removes incompatible DSN options. |
|
| 32 |
+
| `mask_dsn(dsn_url)` | Removes credentials from DSN for logging. |
|
| 33 |
+
| `ssl_runtime_check(conn)` | Verifies active SSL connection. |
|
| 34 |
+
| `init_db_pool(dsn_url)` | Creates asyncpg pool (min=1, max=10) and runs SSL check. |
|
| 35 |
+
| `close_db_pool()` | Gracefully closes connection pool. |
|
| 36 |
+
| `execute_secured_query(query, *params, fetch_method)` | Executes parameterized query (`fetch`, `fetchrow`, `execute`) with reconnect logic. |
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## `fundaments/encryption.py` – `Encryption`
|
| 41 |
+
|
| 42 |
+
| Function | Description |
|
| 43 |
+
| -------------------------------------- | ---------------------------------------------------------------- |
|
| 44 |
+
| `generate_salt()` | Generates secure 16-byte hex salt. |
|
| 45 |
+
| `__init__(master_key, salt)` | Derives AES-256 key via PBKDF2-SHA256 (480k iterations). |
|
| 46 |
+
| `encrypt(data)` | Encrypts string using AES-256-GCM. Returns `{data, nonce, tag}`. |
|
| 47 |
+
| `decrypt(encrypted_data, nonce, tag)` | Decrypts data. Raises `InvalidTag` if tampered. |
|
| 48 |
+
| `encrypt_file(source_path, dest_path)` | Encrypts file in 8192-byte chunks. |
|
| 49 |
+
| `decrypt_file(source_path, dest_path)` | Decrypts file using stored nonce and tag. |
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## `fundaments/access_control.py` – `AccessControl`
|
| 54 |
+
|
| 55 |
+
| Function | Description |
|
| 56 |
+
| -------------------------------------------------- | ------------------------------------ |
|
| 57 |
+
| `__init__(user_id)` | Initializes with optional user ID. |
|
| 58 |
+
| `has_permission(permission_name)` | Checks if user has permission. |
|
| 59 |
+
| `get_user_permissions()` | Returns all user permissions. |
|
| 60 |
+
| `get_user_roles()` | Returns assigned roles. |
|
| 61 |
+
| `assign_role(role_id)` | Assigns role to user. |
|
| 62 |
+
| `remove_role(role_id)` | Removes role from user. |
|
| 63 |
+
| `get_all_roles()` | Returns all roles. |
|
| 64 |
+
| `get_all_permissions()` | Returns all permissions. |
|
| 65 |
+
| `create_role(name, description)` | Creates new role and returns ID. |
|
| 66 |
+
| `update_role_permissions(role_id, permission_ids)` | Replaces all permissions for a role. |
|
| 67 |
+
| `get_role_permissions(role_id)` | Returns role permissions. |
|
| 68 |
+
|
| 69 |
+
---
|
| 70 |
+
|
| 71 |
+
## `fundaments/user_handler.py`
|
| 72 |
+
|
| 73 |
+
### `Database` (SQLite Wrapper)
|
| 74 |
+
|
| 75 |
+
| Function | Description |
|
| 76 |
+
| ------------------------- | ------------------------------------------------- |
|
| 77 |
+
| `execute(query, params)` | Executes query and commits. |
|
| 78 |
+
| `fetchone(query, params)` | Returns single row. |
|
| 79 |
+
| `fetchall(query, params)` | Returns all rows. |
|
| 80 |
+
| `close()` | Closes connection. |
|
| 81 |
+
| `setup_tables()` | Creates `users` and `sessions` tables if missing. |
|
| 82 |
+
|
| 83 |
+
---
|
| 84 |
+
|
| 85 |
+
### `Security` (Password Utilities)
|
| 86 |
+
|
| 87 |
+
| Function | Description |
|
| 88 |
+
| ----------------------------------- | -------------------------------------------- |
|
| 89 |
+
| `hash_password(password)` | Hashes password (PBKDF2-SHA256 via passlib). |
|
| 90 |
+
| `verify_password(password, hashed)` | Verifies password against hash. |
|
| 91 |
+
| `regenerate_session(session_id)` | Generates new UUID session ID. |
|
| 92 |
+
|
| 93 |
+
---
|
| 94 |
+
|
| 95 |
+
### `UserHandler`
|
| 96 |
+
|
| 97 |
+
| Function | Description |
|
| 98 |
+
| ----------------------------------------- | ------------------------------------------------------ |
|
| 99 |
+
| `login(username, password, request_data)` | Authenticates user, validates state, creates session. |
|
| 100 |
+
| `logout()` | Removes session from DB and memory. |
|
| 101 |
+
| `is_logged_in()` | Checks if active session exists. |
|
| 102 |
+
| `is_admin()` | Checks session `is_admin` flag. |
|
| 103 |
+
| `validate_session(request_data)` | Validates session against IP and User-Agent. |
|
| 104 |
+
| `lock_account(username)` | Locks user account. |
|
| 105 |
+
| `reset_failed_attempts(username)` | Resets failed login counter. |
|
| 106 |
+
| `increment_failed_attempts(username)` | Increments failed attempts and locks after 5 failures. |
|
| 107 |
+
|
| 108 |
+
---
|
| 109 |
+
|
| 110 |
+
## `fundaments/security.py` – `Security` (Orchestrator)
|
| 111 |
+
|
| 112 |
+
| Function | Description |
|
| 113 |
+
| ---------------------------------------------- | --------------------------------------------------------------------- |
|
| 114 |
+
| `__init__(services)` | Initializes with required services. Raises `RuntimeError` if missing. |
|
| 115 |
+
| `user_login(username, password, request_data)` | Performs login and session validation. |
|
| 116 |
+
| `check_permission(user_id, permission_name)` | Delegates permission check. |
|
| 117 |
+
| `encrypt_data(data)` | Encrypts data if encryption service is available. |
|
| 118 |
+
| `decrypt_data(encrypted_data, nonce, tag)` | Decrypts data or returns `None` on failure. |
|
| 119 |
+
|
| 120 |
+
---
|
| 121 |
+
|
| 122 |
+
## `fundaments/debug.py` – `PyFundamentsDebug`
|
| 123 |
+
|
| 124 |
+
| Function | Description |
|
| 125 |
+
| ----------------- | ------------------------------------------------------- |
|
| 126 |
+
| `__init__()` | Reads debug-related environment variables. |
|
| 127 |
+
| `_setup_logger()` | Configures logging handlers. |
|
| 128 |
+
| `run()` | Outputs runtime diagnostics when debug mode is enabled. |
|
| 129 |
+
|
| 130 |
+
---
|
| 131 |
+
|
| 132 |
+
## `app/app.py`
|
| 133 |
+
|
| 134 |
+
| Function | Description |
|
| 135 |
+
| ------------------------------- | --------------------------------------------------------------------- |
|
| 136 |
+
| `start_application(fundaments)` | Receives initialized service dictionary and starts application logic. |
|
| 137 |
+
|
| 138 |
+
---
|
| 139 |
+
|
| 140 |
+
## Architecture Notes
|
| 141 |
+
|
| 142 |
+
* `UserHandler` uses internal SQLite.
|
| 143 |
+
* `AccessControl` uses PostgreSQL via `execute_secured_query`.
|
| 144 |
+
* `security.py` `Security` is the orchestrator layer.
|
| 145 |
+
* All services are optional.
|
PyFundaments.md
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
##### v. 1.0.1 dev
|
| 3 |
+
|
| 4 |
+
## Description
|
| 5 |
+
|
| 6 |
+
This project, named **PyFundaments**, provides a robust and secure Python architecture. Its core mission is not to be a monolithic framework, but to establish a layered, modular foundation—or "fundament"—of essential services. This structure ensures that every application starts on a verified and secure base, with a focus on stability, code clarity, and a security-first mindset from the ground up.
|
| 7 |
+
|
| 8 |
+
> [!NOTE]
|
| 9 |
+
> you must create your app/bot in app/* or you get an error! Than uncoment
|
| 10 |
+
> ```
|
| 11 |
+
>from app.app import start_application
|
| 12 |
+
>await start_application(fundaments)
|
| 13 |
+
>```
|
| 14 |
+
> in main.py
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
-----
|
| 19 |
+
|
| 20 |
+
## Table of Contents
|
| 21 |
+
|
| 22 |
+
- [Project Structure](#project-structure)
|
| 23 |
+
- [The Role of `main.py`](#the-role-of-mainpy)
|
| 24 |
+
- [Configuration (`.env`)](#configuration-env)
|
| 25 |
+
- [Installation](#installation)
|
| 26 |
+
- [Getting Started](#getting-started)
|
| 27 |
+
- [Module Documentation](#module-documentation)
|
| 28 |
+
- [Notes](#notes)
|
| 29 |
+
|
| 30 |
+
-----
|
| 31 |
+
|
| 32 |
+
## Project Structure
|
| 33 |
+
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
├── main.py # run main!
|
| 37 |
+
├── README.md
|
| 38 |
+
├── requirements.txt
|
| 39 |
+
├── .gitignore
|
| 40 |
+
├── .env.example
|
| 41 |
+
├── app/
|
| 42 |
+
│ └── ...
|
| 43 |
+
│ └── app.py # sandboxed app run!
|
| 44 |
+
│ └── tools.py
|
| 45 |
+
│ └── provider.py
|
| 46 |
+
│ └── models.py
|
| 47 |
+
│ └── db_sync.py
|
| 48 |
+
|
| 49 |
+
├── fundaments/ # do not touch!
|
| 50 |
+
│ ├── access_control.py
|
| 51 |
+
│ ├── config_handler.py
|
| 52 |
+
│ ├── encryption.py
|
| 53 |
+
│ ├── postgresql.py
|
| 54 |
+
│ ├── security.py
|
| 55 |
+
│ └── user_handler.py
|
| 56 |
+
└── docs/
|
| 57 |
+
├── access_control.py.md
|
| 58 |
+
├── encryption.py.md
|
| 59 |
+
├── postgresql.py.md
|
| 60 |
+
├── security.py.md
|
| 61 |
+
└── user_handler.py.md
|
| 62 |
+
|
| 63 |
+
```
|
| 64 |
+
-----
|
| 65 |
+
|
| 66 |
+
## The Role of `main.py`
|
| 67 |
+
|
| 68 |
+
`main.py` is the **only entry point** and acts as the first line of defense for the application.
|
| 69 |
+
|
| 70 |
+
### Responsibilities
|
| 71 |
+
|
| 72 |
+
- [x] **Validate Dependencies**: It checks for all necessary core modules in `fundaments/` and exits immediately if any are missing.
|
| 73 |
+
- [x] **Conditional Environment Loading**: It uses the `config_handler` to load available configuration variables and initializes only the services for which configuration is present.
|
| 74 |
+
- [x] **Initialize Services**: It creates instances of available services (PostgreSQL, encryption, access control, user handling) based on environment configuration, collecting them into a single service dictionary.
|
| 75 |
+
- [x] **Graceful Degradation**: Services that cannot be initialized are skipped with warnings, allowing applications to run with partial functionality.
|
| 76 |
+
- [x] **Decouple App Logic**: It hands off the prepared and verified services to `app/app.py`, allowing the main application to focus purely on its business logic without worrying about low-level setup.
|
| 77 |
+
|
| 78 |
+
-----
|
| 79 |
+
|
| 80 |
+
## Configuration (`.env`)
|
| 81 |
+
|
| 82 |
+
Application settings are managed using a `.env` file. **Never commit this file to version control.**
|
| 83 |
+
|
| 84 |
+
The framework uses **conditional loading** - only services with available configuration are initialized. This allows different application types to use only what they need.
|
| 85 |
+
|
| 86 |
+
A `.env.example` file is provided to show available variables. Create a copy named `.env` and configure only what your application requires.
|
| 87 |
+
|
| 88 |
+
### Core Configuration (Optional based on app type)
|
| 89 |
+
|
| 90 |
+
```text
|
| 91 |
+
# Database connection (required only for database-using apps)
|
| 92 |
+
DATABASE_URL="postgresql://user:password@host:port/database?sslmode=require"
|
| 93 |
+
|
| 94 |
+
# Encryption keys (required only for apps using encryption)
|
| 95 |
+
MASTER_ENCRYPTION_KEY="your_256_bit_key_here"
|
| 96 |
+
PERSISTENT_ENCRYPTION_SALT="your_unique_salt_here"
|
| 97 |
+
|
| 98 |
+
# Logging configuration (optional)
|
| 99 |
+
LOG_LEVEL="INFO"
|
| 100 |
+
LOG_TO_TMP="false"
|
| 101 |
+
ENABLE_PUBLIC_LOGS="true"
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Application Examples
|
| 105 |
+
|
| 106 |
+
**Discord Bot:** Only needs `BOT_TOKEN`, no database or encryption required.
|
| 107 |
+
**ML Pipeline:** Only needs `DATABASE_URL` for data access.
|
| 108 |
+
**Web Application:** May need all services for full functionality.
|
| 109 |
+
|
| 110 |
+
-----
|
| 111 |
+
|
| 112 |
+
## Installation
|
| 113 |
+
|
| 114 |
+
```bash
|
| 115 |
+
pip install -r requirements.txt
|
| 116 |
+
```
|
| 117 |
+
|
| 118 |
+
-----
|
| 119 |
+
|
| 120 |
+
## Getting Started
|
| 121 |
+
|
| 122 |
+
1. **Configure**: Create your `.env` file from the `.env.example` and set only the variables your application type requires.
|
| 123 |
+
2. **Run**: Execute the `main.py` script to start the application.
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
python main.py
|
| 127 |
+
```
|
| 128 |
+
|
| 129 |
+
The framework will automatically detect available configuration and load only the necessary services.
|
| 130 |
+
|
| 131 |
+
-----
|
| 132 |
+
|
| 133 |
+
## Module Documentation
|
| 134 |
+
|
| 135 |
+
Each security-relevant core module is documented in the `docs/` directory:
|
| 136 |
+
|
| 137 |
+
| Module | Description | Documentation |
|
| 138 |
+
| ------------------- | ---------------------------------------- | -------------------------------------------------|
|
| 139 |
+
| `access_control.py` | Role-based access management | [access\_control.py.md](docs/access_control.py.md)|
|
| 140 |
+
| `config_handler.py` | Universal configuration loader | [config\_handler.py.md](docs/config_handler.py.md)|
|
| 141 |
+
| `encryption.py` | Cryptographic routines | [encryption.py.md](docs/encryption.py.md) |
|
| 142 |
+
| `postgresql.py` | Secure, asynchronous database access | [postgresql.py.md](docs/postgresql.py.md) |
|
| 143 |
+
| `user_handler.py` | Authentication and identity management | [user\_handler.py.md](docs/user_handler.py.md) |
|
| 144 |
+
| `security.py` | Central security orchestration layer | [security.py.md](docs/security.py.md) |
|
| 145 |
+
|
| 146 |
+
-----
|
| 147 |
+
|
| 148 |
+
## License
|
| 149 |
+
|
| 150 |
+
This project is licensed under the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0).
|
| 151 |
+
|
| 152 |
+
### Section 4: Additional Restrictions and Ethical Use Policy
|
| 153 |
+
|
| 154 |
+
This project is released under the permissive Apache 2.0 License, which is intended to provide broad freedom for use and modification.
|
| 155 |
+
However, in the interest of fostering a safe and responsible community, we include the following **mandatory ethical use restrictions**:
|
| 156 |
+
|
| 157 |
+
Use of this project (or derivatives) is **strictly prohibited** for purposes that:
|
| 158 |
+
|
| 159 |
+
- **Promote Hatred or Discrimination**
|
| 160 |
+
Includes hate speech, incitement to violence, or discrimination based on race, religion, gender, orientation, etc.
|
| 161 |
+
|
| 162 |
+
- **Facilitate Illegal Activities**
|
| 163 |
+
Use to conduct or support criminal activity is forbidden.
|
| 164 |
+
|
| 165 |
+
- **Spread Malicious Content**
|
| 166 |
+
This includes pornography, malware, spyware, or other harmful payloads.
|
| 167 |
+
|
| 168 |
+
We believe that freedom in development must be coupled with responsibility.
|
| 169 |
+
**Violation of these terms constitutes a breach of license and will trigger takedown actions** including legal and technical responses.
|
| 170 |
+
|
| 171 |
+
*Volkan Kücükbudak*
|
| 172 |
+
|
| 173 |
+
-----
|
| 174 |
+
|
| 175 |
+
## Credits
|
| 176 |
+
|
| 177 |
+
- Developed and maintained by the PyFundaments project.
|
| 178 |
+
- Core components inspired by best practices in modular app architecture and OWASP security principles.
|
| 179 |
+
- Third-party libraries are credited in their respective module docs and comply with open-source terms.
|
| 180 |
+
|
| 181 |
+
-----
|
| 182 |
+
|
| 183 |
+
## Notes
|
| 184 |
+
|
| 185 |
+
Security-first by design.
|
| 186 |
+
If one piece is missing or unsafe — the app does not run.
|
| 187 |
+
Zero tolerance for guesswork.
|
| 188 |
+
|
| 189 |
+
> Give a ⭐ if you find the structure helpful.
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
## Changelog
|
| 193 |
+
###### Version 1.0.0 -> 1.0.1
|
| 194 |
+
### PyFundaments Refactoring
|
| 195 |
+
|
| 196 |
+
#### Modified Files
|
| 197 |
+
|
| 198 |
+
**1. main.py - Conditional Service Loading**
|
| 199 |
+
- **Added:** Environment-based conditional service initialization
|
| 200 |
+
- **Added:** Graceful fallback when services can't be initialized (warning instead of crash)
|
| 201 |
+
- **Added:** Conditional logging configuration (LOG_LEVEL, LOG_TO_TMP, ENABLE_PUBLIC_LOGS)
|
| 202 |
+
- **Added:** Smart dependency management (access_control & user_handler only load if database available)
|
| 203 |
+
- **Added:** Safe shutdown (only close DB pool if it was initialized)
|
| 204 |
+
- **Result:** Framework now supports partial service loading for different app types
|
| 205 |
+
|
| 206 |
+
**2. app/app.py - Proper Service Injection**
|
| 207 |
+
- **Removed:** Direct service imports and instantiation
|
| 208 |
+
- **Added:** Services received as parameter from main.py
|
| 209 |
+
- **Modified:** start_application() now takes fundaments dictionary as parameter
|
| 210 |
+
- **Added:** Conditional service usage based on what main.py provides
|
| 211 |
+
- **Added:** Examples for different app types (database-only, database-free modes)
|
| 212 |
+
- **Result:** Clean separation - main.py handles initialization, app.py uses services
|
| 213 |
+
|
| 214 |
+
**3. fundaments/config_handler.py - Universal Configuration Loader**
|
| 215 |
+
- **Removed:** REQUIRED_KEYS validation (moved to main.py)
|
| 216 |
+
- **Modified:** Now loads ALL environment variables without validation
|
| 217 |
+
- **Added:** Helper methods: get_bool(), get_int(), has(), get_all()
|
| 218 |
+
- **Added:** Safe defaults and type conversion
|
| 219 |
+
- **Result:** Config handler never needs updates, works with any ENV variables
|
| 220 |
+
|
| 221 |
+
**4. .env.example - Conditional Configuration Template**
|
| 222 |
+
- **Added:** Clear separation between core and optional dependencies
|
| 223 |
+
- **Added:** Logging configuration options
|
| 224 |
+
- **Added:** App-specific configuration examples (Discord, ML, Web)
|
| 225 |
+
- **Added:** Comments explaining when to use each option
|
| 226 |
+
- **Result:** Users only configure what they need, no unused variables
|
| 227 |
+
|
| 228 |
+
#### Architecture Improvements
|
| 229 |
+
- **Conditional Loading:** Framework only loads needed services based on available ENV vars
|
| 230 |
+
- **Merge-Safe Structure:** User code (app/, config_handler.py) protected from framework updates
|
| 231 |
+
- **Zero Breaking Changes:** Updates only affect fundaments/ directory
|
| 232 |
+
- **Enterprise-Level Merging:** Community can safely pull framework updates</document_content></document>
|
README_MCP_HUB.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Universal MCP Hub (Sandboxed)
|
| 2 |
+
#### Universal MCP Server running in **paranoid mode** — built on [PyFundaments](PyFundaments.md) and licensed under ESOL.
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
The goal was simple: too many MCP servers out there with no sandboxing, hardcoded keys, and zero security thought. This one is different. No key = no tool = no crash. The Guardian (`main.py`) controls everything. `app/mcp.py` gets only what it needs, nothing more.
|
| 7 |
+
|
| 8 |
+
- MCP_HUB Built with Claude (Anthropic) as a typing tool. Architecture, security decisions
|
| 9 |
+
- Pyfundaments by Volkan Sah read [ESOL](ESOL)
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## MCP Client Configuration (SSE)
|
| 14 |
+
|
| 15 |
+
To connect Claude Desktop or any MCP client to this hub:
|
| 16 |
+
|
| 17 |
+
```json
|
| 18 |
+
{
|
| 19 |
+
"mcpServers": {
|
| 20 |
+
"pyfundaments-hub": {
|
| 21 |
+
"url": "https://YOUR_USERNAME-universal-mcp-hub.hf.space/sse"
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Architecture
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
main.py ← Guardian: initializes all services, controls what app/ receives
|
| 33 |
+
└── app/mcp.py ← Sandbox: registers only tools with valid keys
|
| 34 |
+
├── LLM tools (Anthropic, Gemini, OpenRouter, HuggingFace)
|
| 35 |
+
├── Search tools (Brave, Tavily)
|
| 36 |
+
├── DB tools (only if DATABASE_URL is set)
|
| 37 |
+
└── System tools (always active)
|
| 38 |
+
```
|
| 39 |
+
|
| 40 |
+
**The Guardian pattern:** `app/mcp.py` never reads `os.environ` directly.
|
| 41 |
+
It receives a `fundaments` dict from `main.py` — and only what `main.py` decides to give it.
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## Security Notes
|
| 46 |
+
|
| 47 |
+
- All API keys loaded via Secrets (env vars) — never hardcoded
|
| 48 |
+
- `list_active_tools` returns key **names** only, never values
|
| 49 |
+
- DB tools are read-only by design (`SELECT` only, enforced at application level)
|
| 50 |
+
- Direct execution of `app/mcp.py` is blocked by design
|
| 51 |
+
- Built on PyFundaments — a security-first Python architecture for developers
|
| 52 |
+
|
| 53 |
+
> PyFundaments is not perfect. But it's more secure than most of what runs in production.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## License
|
| 58 |
+
|
| 59 |
+
Apache License 2.0 + [ESOL 1.1](https://github.com/VolkanSah/ESOL)
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
*"I use AI as a tool, not as a replacement for thinking."* — Volkan Kücükbudak
|
app/.pyfun
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# .pyfun — PyFundaments App Configuration
|
| 3 |
+
# Single source of truth for app/* modules (provider, models, tools, hub)
|
| 4 |
+
# Part of: Universal MCP Hub on PyFundaments
|
| 5 |
+
# =============================================================================
|
| 6 |
+
# RULES:
|
| 7 |
+
# - All values in double quotes "value"
|
| 8 |
+
# - NO secrets here! Keys stay in .env → only ENV-VAR NAMES referenced here
|
| 9 |
+
# - Comment-out unused sections with # → keep structure, parsers need it!
|
| 10 |
+
# - DO NOT DELETE headers or [X_END] → parsers rely on these markers
|
| 11 |
+
# - Empty/unused values: "" → never leave bare =
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# TIERS:
|
| 14 |
+
# LAZY: fill [HUB] + one [LLM_PROVIDER.*] only → works
|
| 15 |
+
# NORMAL: + [SEARCH_PROVIDER.*] + [MODELS.*] → works better
|
| 16 |
+
# PRODUCTIVE: + [TOOLS] + [FALLBACK] + [HUB_LIMITS] → full power
|
| 17 |
+
# =============================================================================
|
| 18 |
+
# DO NOT DELETE — file identifier used by all parsers
|
| 19 |
+
[PYFUN_FILE = .pyfun]
|
| 20 |
+
|
| 21 |
+
# =============================================================================
|
| 22 |
+
# HUB — Core identity & transport config
|
| 23 |
+
# =============================================================================
|
| 24 |
+
[HUB]
|
| 25 |
+
HUB_NAME = "Universal MCP Hub"
|
| 26 |
+
HUB_VERSION = "1.0.0"
|
| 27 |
+
HUB_DESCRIPTION = "Universal MCP Hub built on PyFundaments"
|
| 28 |
+
|
| 29 |
+
# Transport: stdio (local/Claude Desktop) | sse (HuggingFace/Remote)
|
| 30 |
+
# Override via ENV: MCP_TRANSPORT
|
| 31 |
+
HUB_TRANSPORT = "stdio"
|
| 32 |
+
HUB_HOST = "0.0.0.0"
|
| 33 |
+
HUB_PORT = "7860"
|
| 34 |
+
|
| 35 |
+
# App mode: mcp | app
|
| 36 |
+
# Override via ENV: APP_MODE
|
| 37 |
+
HUB_MODE = "mcp"
|
| 38 |
+
|
| 39 |
+
# HuggingFace Space URL (used as HTTP-Referer for some APIs)
|
| 40 |
+
HUB_SPACE_URL = ""
|
| 41 |
+
[HUB_END]
|
| 42 |
+
|
| 43 |
+
# =============================================================================
|
| 44 |
+
# HUB_LIMITS — Request & retry behavior
|
| 45 |
+
# =============================================================================
|
| 46 |
+
[HUB_LIMITS]
|
| 47 |
+
MAX_PARALLEL_REQUESTS = "5"
|
| 48 |
+
RETRY_COUNT = "3"
|
| 49 |
+
RETRY_DELAY_SEC = "2"
|
| 50 |
+
REQUEST_TIMEOUT_SEC = "60"
|
| 51 |
+
SEARCH_TIMEOUT_SEC = "30"
|
| 52 |
+
[HUB_LIMITS_END]
|
| 53 |
+
|
| 54 |
+
# =============================================================================
|
| 55 |
+
# PROVIDERS — All external API providers
|
| 56 |
+
# Secrets stay in .env! Only ENV-VAR NAMES are referenced here.
|
| 57 |
+
# =============================================================================
|
| 58 |
+
[PROVIDERS]
|
| 59 |
+
|
| 60 |
+
# ── LLM Providers ─────────────────────────────────────────────────────────────
|
| 61 |
+
[LLM_PROVIDERS]
|
| 62 |
+
|
| 63 |
+
[LLM_PROVIDER.anthropic]
|
| 64 |
+
active = "true"
|
| 65 |
+
base_url = "https://api.anthropic.com/v1"
|
| 66 |
+
env_key = "ANTHROPIC_API_KEY" # → .env: ANTHROPIC_API_KEY=sk-ant-...
|
| 67 |
+
api_version_header = "2023-06-01" # anthropic-version header
|
| 68 |
+
default_model = "claude-haiku-4-5-20251001"
|
| 69 |
+
models = "claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-5-20251001"
|
| 70 |
+
fallback_to = "openrouter" # if this provider fails → try next
|
| 71 |
+
[LLM_PROVIDER.anthropic_END]
|
| 72 |
+
|
| 73 |
+
[LLM_PROVIDER.gemini]
|
| 74 |
+
active = "true"
|
| 75 |
+
base_url = "https://generativelanguage.googleapis.com/v1beta"
|
| 76 |
+
env_key = "GEMINI_API_KEY" # → .env: GEMINI_API_KEY=...
|
| 77 |
+
default_model = "gemini-2.0-flash"
|
| 78 |
+
models = "gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash"
|
| 79 |
+
fallback_to = "openrouter"
|
| 80 |
+
[LLM_PROVIDER.gemini_END]
|
| 81 |
+
|
| 82 |
+
[LLM_PROVIDER.openrouter]
|
| 83 |
+
active = "true"
|
| 84 |
+
base_url = "https://openrouter.ai/api/v1"
|
| 85 |
+
env_key = "OPENROUTER_API_KEY" # → .env: OPENROUTER_API_KEY=sk-or-...
|
| 86 |
+
default_model = "mistralai/mistral-7b-instruct"
|
| 87 |
+
models = "openai/gpt-4o, meta-llama/llama-3-8b-instruct, mistralai/mistral-7b-instruct"
|
| 88 |
+
fallback_to = "" # last in chain, no further fallback
|
| 89 |
+
[LLM_PROVIDER.openrouter_END]
|
| 90 |
+
|
| 91 |
+
[LLM_PROVIDER.huggingface]
|
| 92 |
+
active = "true"
|
| 93 |
+
base_url = "https://api-inference.huggingface.co/models"
|
| 94 |
+
env_key = "HF_TOKEN" # → .env: HF_TOKEN=hf_...
|
| 95 |
+
default_model = "mistralai/Mistral-7B-Instruct-v0.3"
|
| 96 |
+
models = "mistralai/Mistral-7B-Instruct-v0.3, meta-llama/Llama-3.3-70B-Instruct"
|
| 97 |
+
fallback_to = ""
|
| 98 |
+
[LLM_PROVIDER.huggingface_END]
|
| 99 |
+
|
| 100 |
+
# ── Add more LLM providers below ──────────────────────────────────────────
|
| 101 |
+
# [LLM_PROVIDER.mistral]
|
| 102 |
+
# active = "false"
|
| 103 |
+
# base_url = "https://api.mistral.ai/v1"
|
| 104 |
+
# env_key = "MISTRAL_API_KEY"
|
| 105 |
+
# default_model = "mistral-large-latest"
|
| 106 |
+
# models = "mistral-large-latest, mistral-small-latest"
|
| 107 |
+
# fallback_to = ""
|
| 108 |
+
# [LLM_PROVIDER.mistral_END]
|
| 109 |
+
|
| 110 |
+
# [LLM_PROVIDER.openai]
|
| 111 |
+
# active = "false"
|
| 112 |
+
# base_url = "https://api.openai.com/v1"
|
| 113 |
+
# env_key = "OPENAI_API_KEY"
|
| 114 |
+
# default_model = "gpt-4o"
|
| 115 |
+
# models = "gpt-4o, gpt-4o-mini, gpt-3.5-turbo"
|
| 116 |
+
# fallback_to = ""
|
| 117 |
+
# [LLM_PROVIDER.openai_END]
|
| 118 |
+
|
| 119 |
+
[LLM_PROVIDERS_END]
|
| 120 |
+
|
| 121 |
+
# ── Search Providers ───────────────────────────────────────────────────────────
|
| 122 |
+
[SEARCH_PROVIDERS]
|
| 123 |
+
|
| 124 |
+
[SEARCH_PROVIDER.brave]
|
| 125 |
+
active = "true"
|
| 126 |
+
base_url = "https://api.search.brave.com/res/v1/web/search"
|
| 127 |
+
env_key = "BRAVE_API_KEY" # → .env: BRAVE_API_KEY=BSA...
|
| 128 |
+
default_results = "5"
|
| 129 |
+
max_results = "20"
|
| 130 |
+
fallback_to = "tavily"
|
| 131 |
+
[SEARCH_PROVIDER.brave_END]
|
| 132 |
+
|
| 133 |
+
[SEARCH_PROVIDER.tavily]
|
| 134 |
+
active = "true"
|
| 135 |
+
base_url = "https://api.tavily.com/search"
|
| 136 |
+
env_key = "TAVILY_API_KEY" # → .env: TAVILY_API_KEY=tvly-...
|
| 137 |
+
default_results = "5"
|
| 138 |
+
max_results = "10"
|
| 139 |
+
include_answer = "true" # AI-synthesized answer
|
| 140 |
+
fallback_to = ""
|
| 141 |
+
[SEARCH_PROVIDER.tavily_END]
|
| 142 |
+
|
| 143 |
+
# ── Add more search providers below ───────────────────────────────────────
|
| 144 |
+
# [SEARCH_PROVIDER.serper]
|
| 145 |
+
# active = "false"
|
| 146 |
+
# base_url = "https://google.serper.dev/search"
|
| 147 |
+
# env_key = "SERPER_API_KEY"
|
| 148 |
+
# fallback_to = ""
|
| 149 |
+
# [SEARCH_PROVIDER.serper_END]
|
| 150 |
+
|
| 151 |
+
[SEARCH_PROVIDERS_END]
|
| 152 |
+
|
| 153 |
+
# ── Web / Action Providers (Webhooks, Bots, Social) ───────────────────────────
|
| 154 |
+
# [WEB_PROVIDERS]
|
| 155 |
+
|
| 156 |
+
# [WEB_PROVIDER.discord]
|
| 157 |
+
# active = "false"
|
| 158 |
+
# base_url = "https://discord.com/api/v10"
|
| 159 |
+
# env_key = "BOT_TOKEN"
|
| 160 |
+
# [WEB_PROVIDER.discord_END]
|
| 161 |
+
|
| 162 |
+
# [WEB_PROVIDER.github]
|
| 163 |
+
# active = "false"
|
| 164 |
+
# base_url = "https://api.github.com"
|
| 165 |
+
# env_key = "GITHUB_TOKEN"
|
| 166 |
+
# [WEB_PROVIDER.github_END]
|
| 167 |
+
|
| 168 |
+
# [WEB_PROVIDERS_END]
|
| 169 |
+
|
| 170 |
+
[PROVIDERS_END]
|
| 171 |
+
|
| 172 |
+
# =============================================================================
|
| 173 |
+
# MODELS — Token & rate limits per model
|
| 174 |
+
# Parser builds: MODELS[provider][model_name] → limits dict
|
| 175 |
+
# =============================================================================
|
| 176 |
+
[MODELS]
|
| 177 |
+
|
| 178 |
+
[MODEL.claude-opus-4-6]
|
| 179 |
+
provider = "anthropic"
|
| 180 |
+
context_tokens = "200000"
|
| 181 |
+
max_output_tokens = "32000"
|
| 182 |
+
requests_per_min = "5"
|
| 183 |
+
requests_per_day = "300"
|
| 184 |
+
cost_input_per_1k = "0.015" # USD — update as pricing changes
|
| 185 |
+
cost_output_per_1k = "0.075"
|
| 186 |
+
capabilities = "text, code, analysis, vision"
|
| 187 |
+
[MODEL.claude-opus-4-6_END]
|
| 188 |
+
|
| 189 |
+
[MODEL.claude-sonnet-4-6]
|
| 190 |
+
provider = "anthropic"
|
| 191 |
+
context_tokens = "200000"
|
| 192 |
+
max_output_tokens = "16000"
|
| 193 |
+
requests_per_min = "50"
|
| 194 |
+
requests_per_day = "1000"
|
| 195 |
+
cost_input_per_1k = "0.003"
|
| 196 |
+
cost_output_per_1k = "0.015"
|
| 197 |
+
capabilities = "text, code, analysis, vision"
|
| 198 |
+
[MODEL.claude-sonnet-4-6_END]
|
| 199 |
+
|
| 200 |
+
[MODEL.claude-haiku-4-5-20251001]
|
| 201 |
+
provider = "anthropic"
|
| 202 |
+
context_tokens = "200000"
|
| 203 |
+
max_output_tokens = "8000"
|
| 204 |
+
requests_per_min = "50"
|
| 205 |
+
requests_per_day = "2000"
|
| 206 |
+
cost_input_per_1k = "0.00025"
|
| 207 |
+
cost_output_per_1k = "0.00125"
|
| 208 |
+
capabilities = "text, code, fast"
|
| 209 |
+
[MODEL.claude-haiku-4-5-20251001_END]
|
| 210 |
+
|
| 211 |
+
[MODEL.gemini-2.0-flash]
|
| 212 |
+
provider = "gemini"
|
| 213 |
+
context_tokens = "1000000"
|
| 214 |
+
max_output_tokens = "8192"
|
| 215 |
+
requests_per_min = "15"
|
| 216 |
+
requests_per_day = "1500"
|
| 217 |
+
cost_input_per_1k = "0.00010"
|
| 218 |
+
cost_output_per_1k = "0.00040"
|
| 219 |
+
capabilities = "text, code, vision, audio"
|
| 220 |
+
[MODEL.gemini-2.0-flash_END]
|
| 221 |
+
|
| 222 |
+
[MODEL.gemini-1.5-pro]
|
| 223 |
+
provider = "gemini"
|
| 224 |
+
context_tokens = "2000000"
|
| 225 |
+
max_output_tokens = "8192"
|
| 226 |
+
requests_per_min = "2"
|
| 227 |
+
requests_per_day = "50"
|
| 228 |
+
cost_input_per_1k = "0.00125"
|
| 229 |
+
cost_output_per_1k = "0.00500"
|
| 230 |
+
capabilities = "text, code, vision, audio, long-context"
|
| 231 |
+
[MODEL.gemini-1.5-pro_END]
|
| 232 |
+
|
| 233 |
+
[MODEL.mistral-7b-instruct]
|
| 234 |
+
provider = "openrouter"
|
| 235 |
+
context_tokens = "32000"
|
| 236 |
+
max_output_tokens = "4096"
|
| 237 |
+
requests_per_min = "60"
|
| 238 |
+
requests_per_day = "10000"
|
| 239 |
+
cost_input_per_1k = "0.00006"
|
| 240 |
+
cost_output_per_1k = "0.00006"
|
| 241 |
+
capabilities = "text, code, fast, cheap"
|
| 242 |
+
[MODEL.mistral-7b-instruct_END]
|
| 243 |
+
|
| 244 |
+
[MODELS_END]
|
| 245 |
+
|
| 246 |
+
# =============================================================================
|
| 247 |
+
# TOOLS — Tool definitions + provider mapping
|
| 248 |
+
# Tools are registered in mcp.py only if their provider ENV key exists!
|
| 249 |
+
# =============================================================================
|
| 250 |
+
[TOOLS]
|
| 251 |
+
|
| 252 |
+
[TOOL.llm_complete]
|
| 253 |
+
active = "true"
|
| 254 |
+
description = "Send prompt to any configured LLM provider"
|
| 255 |
+
provider_type = "llm"
|
| 256 |
+
default_provider = "anthropic"
|
| 257 |
+
timeout_sec = "60"
|
| 258 |
+
[TOOL.llm_complete_END]
|
| 259 |
+
|
| 260 |
+
[TOOL.web_search]
|
| 261 |
+
active = "true"
|
| 262 |
+
description = "Search the web via configured search provider"
|
| 263 |
+
provider_type = "search"
|
| 264 |
+
default_provider = "brave"
|
| 265 |
+
timeout_sec = "30"
|
| 266 |
+
[TOOL.web_search_END]
|
| 267 |
+
|
| 268 |
+
[TOOL.db_query]
|
| 269 |
+
active = "true"
|
| 270 |
+
description = "Execute SELECT queries on connected database (read-only)"
|
| 271 |
+
provider_type = "db"
|
| 272 |
+
readonly = "true"
|
| 273 |
+
timeout_sec = "10"
|
| 274 |
+
[TOOL.db_query_END]
|
| 275 |
+
|
| 276 |
+
# ── Future tools ──────────────────────────────────────────────────────────
|
| 277 |
+
# [TOOL.image_gen]
|
| 278 |
+
# active = "false"
|
| 279 |
+
# description = "Generate images via configured provider"
|
| 280 |
+
# provider_type = "image"
|
| 281 |
+
# default_provider = ""
|
| 282 |
+
# timeout_sec = "120"
|
| 283 |
+
# [TOOL.image_gen_END]
|
| 284 |
+
|
| 285 |
+
# [TOOL.code_exec]
|
| 286 |
+
# active = "false"
|
| 287 |
+
# description = "Execute sandboxed code snippets"
|
| 288 |
+
# provider_type = "sandbox"
|
| 289 |
+
# timeout_sec = "30"
|
| 290 |
+
# [TOOL.code_exec_END]
|
| 291 |
+
|
| 292 |
+
[TOOLS_END]
|
| 293 |
+
|
| 294 |
+
# =============================================================================
|
| 295 |
+
# DB_SYNC — Internal SQLite config for app/* IPC
|
| 296 |
+
# This is NOT the cloud DB — that lives in .env → DATABASE_URL
|
| 297 |
+
# =============================================================================
|
| 298 |
+
[DB_SYNC]
|
| 299 |
+
SQLITE_PATH = "app/.hub_state.db" # internal state, never commit!
|
| 300 |
+
SYNC_INTERVAL_SEC = "30" # how often to flush to SQLite
|
| 301 |
+
MAX_CACHE_ENTRIES = "1000"
|
| 302 |
+
[DB_SYNC_END]
|
| 303 |
+
|
| 304 |
+
# =============================================================================
|
| 305 |
+
# DEBUG — app/* debug behavior (fundaments debug stays in .env)
|
| 306 |
+
# =============================================================================
|
| 307 |
+
[DEBUG]
|
| 308 |
+
DEBUG = "ON" # ON | OFF
|
| 309 |
+
DEBUG_LEVEL = "FULL" # FULL | WARN | ERROR
|
| 310 |
+
LOG_FILE = "hub_debug.log"
|
| 311 |
+
LOG_REQUESTS = "true" # log every provider request
|
| 312 |
+
LOG_RESPONSES = "false" # careful: may log sensitive data!
|
| 313 |
+
[DEBUG_END]
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/app.py
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# app/app.py
|
| 3 |
+
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
|
| 4 |
+
# Copyright 2026 - Volkan Kücükbudak
|
| 5 |
+
# Apache License V. 2 + ESOL 1.1
|
| 6 |
+
# Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
|
| 7 |
+
# =============================================================================
|
| 8 |
+
# ARCHITECTURE NOTE:
|
| 9 |
+
# This file is the Orchestrator of the sandboxed app/* layer.
|
| 10 |
+
# It is ONLY started by main.py (the "Guardian").
|
| 11 |
+
# All fundament services are injected via the `fundaments` dictionary.
|
| 12 |
+
# Direct execution is blocked by design.
|
| 13 |
+
#
|
| 14 |
+
# SANDBOX RULE:
|
| 15 |
+
# app/* has NO direct access to .env or fundaments/*.
|
| 16 |
+
# Config for app/* lives in app/.pyfun (provider URLs, models, tool settings).
|
| 17 |
+
# Secrets stay in .env → Guardian reads them → injects what app/* needs.
|
| 18 |
+
# =============================================================================
|
| 19 |
+
|
| 20 |
+
from quart import Quart, request, jsonify # async Flask — required for async cloud providers + Neon DB
|
| 21 |
+
import logging
|
| 22 |
+
from waitress import serve # WSGI server — keeps Flask non-blocking alongside asyncio
|
| 23 |
+
import threading # bank-pattern: each blocking service gets its own thread
|
| 24 |
+
import requests # sync HTTP for health check worker
|
| 25 |
+
import time
|
| 26 |
+
from datetime import datetime
|
| 27 |
+
import asyncio
|
| 28 |
+
import sys
|
| 29 |
+
from typing import Dict, Any, Optional
|
| 30 |
+
|
| 31 |
+
# =============================================================================
|
| 32 |
+
# Import app/* modules
|
| 33 |
+
# Config/settings for all modules below live in app/.pyfun — not in .env!
|
| 34 |
+
# =============================================================================
|
| 35 |
+
from . import mcp # MCP transport layer (stdio / SSE)
|
| 36 |
+
from . import providers # API provider registry (LLM, Search, Web)
|
| 37 |
+
from . import models # Model config + token/rate limits
|
| 38 |
+
from . import tools # MCP tool definitions + provider mapping
|
| 39 |
+
from . import db_sync # Internal SQLite IPC — app/* state & communication
|
| 40 |
+
# db_sync ≠ cloud DB! Cloud DB is Guardian-only via main.py.
|
| 41 |
+
|
| 42 |
+
# Future modules (soon uncommented when ready):
|
| 43 |
+
# from . import discord_api # Discord bot integration
|
| 44 |
+
# from . import hf_hooks # HuggingFace Space hooks
|
| 45 |
+
# from . import git_hooks # GitHub/GitLab webhook handler
|
| 46 |
+
# from . import web_api # Generic REST API handler
|
| 47 |
+
|
| 48 |
+
# =============================================================================
|
| 49 |
+
# Loggers — one per module for clean log filtering
|
| 50 |
+
# =============================================================================
|
| 51 |
+
logger = logging.getLogger('application')
|
| 52 |
+
logger = logging.getLogger('config')
|
| 53 |
+
# logger_mcp = logging.getLogger('mcp')
|
| 54 |
+
# logger_tools = logging.getLogger('tools')
|
| 55 |
+
# logger_providers = logging.getLogger('providers')
|
| 56 |
+
# logger_models = logging.getLogger('models')
|
| 57 |
+
# logger_db_sync = logging.getLogger('db_sync')
|
| 58 |
+
|
| 59 |
+
# =============================================================================
|
| 60 |
+
# Flask app instance
|
| 61 |
+
# =============================================================================
|
| 62 |
+
app = Quart(__name__)
|
| 63 |
+
START_TIME = datetime.utcnow()
|
| 64 |
+
|
| 65 |
+
# =============================================================================
|
| 66 |
+
# Global service references (set during initialize_services)
|
| 67 |
+
# =============================================================================
|
| 68 |
+
_fundaments: Optional[Dict[str, Any]] = None
|
| 69 |
+
PORT = None
|
| 70 |
+
|
| 71 |
+
# =============================================================================
|
| 72 |
+
# Service initialization
|
| 73 |
+
# =============================================================================
|
| 74 |
+
def initialize_services(fundaments: Dict[str, Any]) -> None:
|
| 75 |
+
"""
|
| 76 |
+
Initializes all app/* services with injected fundaments from Guardian.
|
| 77 |
+
Called once during start_application — sets global service references.
|
| 78 |
+
"""
|
| 79 |
+
global _fundaments, PORT
|
| 80 |
+
|
| 81 |
+
_fundaments = fundaments
|
| 82 |
+
PORT = fundaments["config"].get_int("PORT", 7860)
|
| 83 |
+
|
| 84 |
+
# Initialize internal SQLite state store for app/* IPC
|
| 85 |
+
db_sync.initialize()
|
| 86 |
+
|
| 87 |
+
# Initialize provider registry from app/.pyfun + ENV key presence check
|
| 88 |
+
providers.initialize(fundaments["config"])
|
| 89 |
+
|
| 90 |
+
# Initialize model registry from app/.pyfun
|
| 91 |
+
models.initialize()
|
| 92 |
+
|
| 93 |
+
# Initialize tool registry — tools only register if their provider is active
|
| 94 |
+
tools.initialize(providers, models, fundaments)
|
| 95 |
+
|
| 96 |
+
logger.info("app/* services initialized.")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# =============================================================================
|
| 100 |
+
# Background workers
|
| 101 |
+
# =============================================================================
|
| 102 |
+
def start_mcp_in_thread() -> None:
|
| 103 |
+
"""
|
| 104 |
+
Starts the MCP Hub (stdio or SSE) in its own thread with its own event loop.
|
| 105 |
+
Mirrors the bank-thread pattern from the Discord bot architecture.
|
| 106 |
+
"""
|
| 107 |
+
loop = asyncio.new_event_loop()
|
| 108 |
+
asyncio.set_event_loop(loop)
|
| 109 |
+
try:
|
| 110 |
+
loop.run_until_complete(mcp.start_mcp(_fundaments))
|
| 111 |
+
finally:
|
| 112 |
+
loop.close()
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def health_check_worker() -> None:
|
| 116 |
+
"""
|
| 117 |
+
Periodic self-ping to keep the app alive on hosting platforms (e.g. HuggingFace).
|
| 118 |
+
Runs in its own daemon thread — does not block the main loop.
|
| 119 |
+
"""
|
| 120 |
+
while True:
|
| 121 |
+
time.sleep(3600)
|
| 122 |
+
try:
|
| 123 |
+
response = requests.get(f"http://127.0.0.1:{PORT}/")
|
| 124 |
+
logger.info(f"Health check ping: {response.status_code}")
|
| 125 |
+
except Exception as e:
|
| 126 |
+
logger.error(f"Health check failed: {e}")
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# =============================================================================
|
| 130 |
+
# Flask Routes
|
| 131 |
+
# =============================================================================
|
| 132 |
+
|
| 133 |
+
@app.route("/", methods=["GET"])
|
| 134 |
+
async def health_check():
|
| 135 |
+
"""
|
| 136 |
+
Health check endpoint.
|
| 137 |
+
Used by HuggingFace Spaces and monitoring systems to verify the app is running.
|
| 138 |
+
"""
|
| 139 |
+
uptime = datetime.utcnow() - START_TIME
|
| 140 |
+
return jsonify({
|
| 141 |
+
"status": "running",
|
| 142 |
+
"service": "Universal MCP Hub",
|
| 143 |
+
"uptime_seconds": int(uptime.total_seconds()),
|
| 144 |
+
"active_providers": providers.get_active_names() if providers else [],
|
| 145 |
+
})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
@app.route("/api", methods=["POST"])
|
| 149 |
+
async def api_endpoint():
|
| 150 |
+
"""
|
| 151 |
+
Generic REST API endpoint for direct tool invocation.
|
| 152 |
+
Accepts JSON: { "tool": "tool_name", "params": { ... } }
|
| 153 |
+
Auth and validation handled by tools layer.
|
| 154 |
+
"""
|
| 155 |
+
# TODO: implement tool dispatch via tools.invoke()
|
| 156 |
+
data = await request.get_json()
|
| 157 |
+
return jsonify({"status": "not_implemented", "received": data}), 501
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
@app.route("/crypto", methods=["POST"])
|
| 161 |
+
async def crypto_endpoint():
|
| 162 |
+
"""
|
| 163 |
+
Encrypted API endpoint.
|
| 164 |
+
Payload is decrypted via fundaments/encryption.py (injected by Guardian).
|
| 165 |
+
Only active if encryption_service is available in fundaments.
|
| 166 |
+
"""
|
| 167 |
+
encryption_service = _fundaments.get("encryption") if _fundaments else None
|
| 168 |
+
if not encryption_service:
|
| 169 |
+
return jsonify({"error": "Encryption service not available"}), 503
|
| 170 |
+
|
| 171 |
+
# TODO: decrypt payload, dispatch, re-encrypt response
|
| 172 |
+
data = await request.get_json()
|
| 173 |
+
return jsonify({"status": "not_implemented"}), 501
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
# Future routes (uncomment when ready):
|
| 177 |
+
# @app.route("/discord", methods=["POST"])
|
| 178 |
+
# async def discord_interactions():
|
| 179 |
+
# """Discord interactions endpoint — signature verification via discord_api module."""
|
| 180 |
+
# pass
|
| 181 |
+
|
| 182 |
+
# @app.route("/webhook/hf", methods=["POST"])
|
| 183 |
+
# async def hf_webhook():
|
| 184 |
+
# """HuggingFace Space event hooks."""
|
| 185 |
+
# pass
|
| 186 |
+
|
| 187 |
+
# @app.route("/webhook/git", methods=["POST"])
|
| 188 |
+
# async def git_webhook():
|
| 189 |
+
# """GitHub / GitLab webhook handler."""
|
| 190 |
+
# pass
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# =============================================================================
|
| 194 |
+
# Main entry point — called by Guardian (main.py)
|
| 195 |
+
# =============================================================================
|
| 196 |
+
async def start_application(fundaments: Dict[str, Any]) -> None:
|
| 197 |
+
"""
|
| 198 |
+
Main entry point for the sandboxed app layer.
|
| 199 |
+
Called exclusively by main.py after all fundament services are initialized.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
fundaments: Dictionary of initialized services from Guardian (main.py).
|
| 203 |
+
All services already validated — may be None if not configured.
|
| 204 |
+
"""
|
| 205 |
+
logger.info("Application starting...")
|
| 206 |
+
|
| 207 |
+
# --- Unpack fundament services (read-only references) ---
|
| 208 |
+
config_service = fundaments["config"]
|
| 209 |
+
db_service = fundaments["db"] # None if no DB configured
|
| 210 |
+
encryption_service = fundaments["encryption"] # None if keys not set
|
| 211 |
+
access_control_service = fundaments["access_control"] # None if no DB
|
| 212 |
+
user_handler_service = fundaments["user_handler"] # None if no DB
|
| 213 |
+
security_service = fundaments["security"] # None if deps missing
|
| 214 |
+
|
| 215 |
+
# --- Initialize all app/* services ---
|
| 216 |
+
initialize_services(fundaments)
|
| 217 |
+
|
| 218 |
+
# --- Log active fundament services ---
|
| 219 |
+
if encryption_service:
|
| 220 |
+
logger.info("Encryption service active.")
|
| 221 |
+
|
| 222 |
+
if user_handler_service and security_service:
|
| 223 |
+
logger.info("Auth services active (user_handler + security).")
|
| 224 |
+
|
| 225 |
+
if access_control_service and security_service:
|
| 226 |
+
logger.info("Access control active.")
|
| 227 |
+
|
| 228 |
+
if db_service and not user_handler_service:
|
| 229 |
+
logger.info("Database-only mode active (e.g. ML pipeline).")
|
| 230 |
+
|
| 231 |
+
if not db_service:
|
| 232 |
+
logger.info("Database-free mode active (e.g. Discord bot, API client).")
|
| 233 |
+
|
| 234 |
+
# --- Start MCP Hub in its own thread (stdio or SSE) ---
|
| 235 |
+
mcp_thread = threading.Thread(target=start_mcp_in_thread, daemon=True)
|
| 236 |
+
mcp_thread.start()
|
| 237 |
+
logger.info("MCP Hub thread started.")
|
| 238 |
+
|
| 239 |
+
# Allow MCP to initialize before Flask comes up
|
| 240 |
+
await asyncio.sleep(1)
|
| 241 |
+
|
| 242 |
+
# --- Start health check worker ---
|
| 243 |
+
health_thread = threading.Thread(target=health_check_worker, daemon=True)
|
| 244 |
+
health_thread.start()
|
| 245 |
+
|
| 246 |
+
# --- Start Flask/Quart via Waitress in its own thread ---
|
| 247 |
+
def run_server():
|
| 248 |
+
serve(app, host="0.0.0.0", port=PORT)
|
| 249 |
+
|
| 250 |
+
server_thread = threading.Thread(target=run_server, daemon=True)
|
| 251 |
+
server_thread.start()
|
| 252 |
+
logger.info(f"HTTP server started on port {PORT}.")
|
| 253 |
+
|
| 254 |
+
logger.info("All services running. Entering heartbeat loop...")
|
| 255 |
+
|
| 256 |
+
# --- Heartbeat loop — keeps Guardian's async context alive ---
|
| 257 |
+
try:
|
| 258 |
+
while True:
|
| 259 |
+
await asyncio.sleep(60)
|
| 260 |
+
logger.debug("Heartbeat.")
|
| 261 |
+
except KeyboardInterrupt:
|
| 262 |
+
logger.info("Shutdown signal received.")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
# =============================================================================
|
| 266 |
+
# Direct execution guard
|
| 267 |
+
# =============================================================================
|
| 268 |
+
if __name__ == '__main__':
|
| 269 |
+
print("WARNING: Running app.py directly. Fundament modules might not be correctly initialized.")
|
| 270 |
+
print("Please run 'python main.py' instead for proper initialization.")
|
| 271 |
+
|
| 272 |
+
test_fundaments = {
|
| 273 |
+
"config": None,
|
| 274 |
+
"db": None,
|
| 275 |
+
"encryption": None,
|
| 276 |
+
"access_control": None,
|
| 277 |
+
"user_handler": None,
|
| 278 |
+
"security": None,
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
asyncio.run(start_application(test_fundaments))
|
app/config.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# =============================================================================
|
| 2 |
+
# app/config.py
|
| 3 |
+
# .pyfun parser for app/* modules
|
| 4 |
+
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
|
| 5 |
+
# Copyright 2026 - Volkan Kücükbudak
|
| 6 |
+
# Apache License V. 2 + ESOL 1.1
|
| 7 |
+
# =============================================================================
|
| 8 |
+
# USAGE in any app/* module:
|
| 9 |
+
# from . import config
|
| 10 |
+
# cfg = config.get()
|
| 11 |
+
# providers = cfg["LLM_PROVIDERS"]
|
| 12 |
+
# =============================================================================
|
| 13 |
+
# USAGE
|
| 14 |
+
# in providers.py
|
| 15 |
+
# from . import config
|
| 16 |
+
|
| 17 |
+
# active = config.get_active_llm_providers()
|
| 18 |
+
# → { "anthropic": { "base_url": "...", "env_key": "ANTHROPIC_API_KEY", ... }, ... }
|
| 19 |
+
# =============================================================================
|
| 20 |
+
# in models.py
|
| 21 |
+
# from . import config
|
| 22 |
+
|
| 23 |
+
# anthropic_models = config.get_models_for_provider("anthropic")
|
| 24 |
+
# =============================================================================
|
| 25 |
+
# in tools.py
|
| 26 |
+
# from . import config
|
| 27 |
+
|
| 28 |
+
# active_tools = config.get_active_tools()
|
| 29 |
+
# =============================================================================
|
| 30 |
+
import os
|
| 31 |
+
import logging
|
| 32 |
+
from typing import Dict, Any, Optional
|
| 33 |
+
|
| 34 |
+
logger = logging.getLogger('app.config')
|
| 35 |
+
|
| 36 |
+
# Path to .pyfun — lives in app/ next to this file
|
| 37 |
+
PYFUN_PATH = os.path.join(os.path.dirname(__file__), ".pyfun")
|
| 38 |
+
|
| 39 |
+
# Internal cache — loaded once at first get()
|
| 40 |
+
_cache: Optional[Dict[str, Any]] = None
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def _parse_value(value: str) -> str:
|
| 44 |
+
"""Strip quotes and inline comments from a value."""
|
| 45 |
+
value = value.strip()
|
| 46 |
+
# Remove inline comment
|
| 47 |
+
if " #" in value:
|
| 48 |
+
value = value[:value.index(" #")].strip()
|
| 49 |
+
# Strip surrounding quotes
|
| 50 |
+
if value.startswith('"') and value.endswith('"'):
|
| 51 |
+
value = value[1:-1]
|
| 52 |
+
return value
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _parse() -> Dict[str, Any]:
|
| 56 |
+
"""
|
| 57 |
+
Parses the app/.pyfun file into a nested dictionary.
|
| 58 |
+
|
| 59 |
+
Structure:
|
| 60 |
+
[SECTION]
|
| 61 |
+
[SUBSECTION]
|
| 62 |
+
[BLOCK.name]
|
| 63 |
+
key = "value"
|
| 64 |
+
[BLOCK.name_END]
|
| 65 |
+
[SUBSECTION_END]
|
| 66 |
+
[SECTION_END]
|
| 67 |
+
|
| 68 |
+
Returns nested dict:
|
| 69 |
+
{
|
| 70 |
+
"HUB": { "HUB_NAME": "...", ... },
|
| 71 |
+
"LLM_PROVIDERS": {
|
| 72 |
+
"anthropic": { "active": "true", "base_url": "...", ... },
|
| 73 |
+
"gemini": { ... },
|
| 74 |
+
},
|
| 75 |
+
"MODELS": {
|
| 76 |
+
"claude-opus-4-6": { "provider": "anthropic", ... },
|
| 77 |
+
},
|
| 78 |
+
...
|
| 79 |
+
}
|
| 80 |
+
"""
|
| 81 |
+
if not os.path.isfile(PYFUN_PATH):
|
| 82 |
+
logger.critical(f".pyfun not found at: {PYFUN_PATH}")
|
| 83 |
+
raise FileNotFoundError(f".pyfun not found at: {PYFUN_PATH}")
|
| 84 |
+
|
| 85 |
+
result: Dict[str, Any] = {}
|
| 86 |
+
|
| 87 |
+
# Parser state
|
| 88 |
+
section: Optional[str] = None # e.g. "HUB", "PROVIDERS"
|
| 89 |
+
subsection: Optional[str] = None # e.g. "LLM_PROVIDERS"
|
| 90 |
+
block_type: Optional[str] = None # e.g. "LLM_PROVIDER", "MODEL", "TOOL"
|
| 91 |
+
block_name: Optional[str] = None # e.g. "anthropic", "claude-opus-4-6"
|
| 92 |
+
|
| 93 |
+
with open(PYFUN_PATH, "r", encoding="utf-8") as f:
|
| 94 |
+
for raw_line in f:
|
| 95 |
+
line = raw_line.strip()
|
| 96 |
+
|
| 97 |
+
# Skip empty lines and full-line comments
|
| 98 |
+
if not line or line.startswith("#"):
|
| 99 |
+
continue
|
| 100 |
+
|
| 101 |
+
# Skip file identifier
|
| 102 |
+
if line.startswith("[PYFUN_FILE"):
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
# --- Block END markers (most specific first) ---
|
| 106 |
+
if line.endswith("_END]") and "." in line:
|
| 107 |
+
# e.g. [LLM_PROVIDER.anthropic_END] or [MODEL.claude-opus-4-6_END]
|
| 108 |
+
block_type = None
|
| 109 |
+
block_name = None
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
if line.endswith("_END]") and not "." in line:
|
| 113 |
+
# e.g. [LLM_PROVIDERS_END], [HUB_END], [MODELS_END]
|
| 114 |
+
inner = line[1:-1].replace("_END", "")
|
| 115 |
+
if subsection and inner == subsection:
|
| 116 |
+
subsection = None
|
| 117 |
+
elif section and inner == section:
|
| 118 |
+
section = None
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
# --- Block START markers ---
|
| 122 |
+
if line.startswith("[") and line.endswith("]"):
|
| 123 |
+
inner = line[1:-1]
|
| 124 |
+
|
| 125 |
+
# Named block: [LLM_PROVIDER.anthropic] or [MODEL.claude-opus-4-6]
|
| 126 |
+
if "." in inner:
|
| 127 |
+
parts = inner.split(".", 1)
|
| 128 |
+
block_type = parts[0] # e.g. LLM_PROVIDER, MODEL, TOOL
|
| 129 |
+
block_name = parts[1] # e.g. anthropic, claude-opus-4-6
|
| 130 |
+
|
| 131 |
+
# Determine which top-level key to store under
|
| 132 |
+
if block_type == "LLM_PROVIDER":
|
| 133 |
+
result.setdefault("LLM_PROVIDERS", {})
|
| 134 |
+
result["LLM_PROVIDERS"].setdefault(block_name, {})
|
| 135 |
+
elif block_type == "SEARCH_PROVIDER":
|
| 136 |
+
result.setdefault("SEARCH_PROVIDERS", {})
|
| 137 |
+
result["SEARCH_PROVIDERS"].setdefault(block_name, {})
|
| 138 |
+
elif block_type == "WEB_PROVIDER":
|
| 139 |
+
result.setdefault("WEB_PROVIDERS", {})
|
| 140 |
+
result["WEB_PROVIDERS"].setdefault(block_name, {})
|
| 141 |
+
elif block_type == "MODEL":
|
| 142 |
+
result.setdefault("MODELS", {})
|
| 143 |
+
result["MODELS"].setdefault(block_name, {})
|
| 144 |
+
elif block_type == "TOOL":
|
| 145 |
+
result.setdefault("TOOLS", {})
|
| 146 |
+
result["TOOLS"].setdefault(block_name, {})
|
| 147 |
+
continue
|
| 148 |
+
|
| 149 |
+
# Subsection: [LLM_PROVIDERS], [SEARCH_PROVIDERS] etc.
|
| 150 |
+
if section and not subsection:
|
| 151 |
+
subsection = inner
|
| 152 |
+
result.setdefault(inner, {})
|
| 153 |
+
continue
|
| 154 |
+
|
| 155 |
+
# Top-level section: [HUB], [PROVIDERS], [MODELS] etc.
|
| 156 |
+
section = inner
|
| 157 |
+
result.setdefault(inner, {})
|
| 158 |
+
continue
|
| 159 |
+
|
| 160 |
+
# --- Key = Value ---
|
| 161 |
+
if "=" in line:
|
| 162 |
+
key, _, val = line.partition("=")
|
| 163 |
+
key = key.strip()
|
| 164 |
+
val = _parse_value(val)
|
| 165 |
+
|
| 166 |
+
# Strip provider prefix from key (e.g. "anthropic.base_url" → "base_url")
|
| 167 |
+
if block_name and key.startswith(f"{block_name}."):
|
| 168 |
+
key = key[len(block_name) + 1:]
|
| 169 |
+
|
| 170 |
+
# Store in correct location
|
| 171 |
+
if block_type and block_name:
|
| 172 |
+
if block_type == "LLM_PROVIDER":
|
| 173 |
+
result["LLM_PROVIDERS"][block_name][key] = val
|
| 174 |
+
elif block_type == "SEARCH_PROVIDER":
|
| 175 |
+
result["SEARCH_PROVIDERS"][block_name][key] = val
|
| 176 |
+
elif block_type == "WEB_PROVIDER":
|
| 177 |
+
result["WEB_PROVIDERS"][block_name][key] = val
|
| 178 |
+
elif block_type == "MODEL":
|
| 179 |
+
result["MODELS"][block_name][key] = val
|
| 180 |
+
elif block_type == "TOOL":
|
| 181 |
+
result["TOOLS"][block_name][key] = val
|
| 182 |
+
elif section:
|
| 183 |
+
result[section][key] = val
|
| 184 |
+
|
| 185 |
+
logger.info(f".pyfun loaded. Sections: {list(result.keys())}")
|
| 186 |
+
return result
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def load() -> Dict[str, Any]:
|
| 190 |
+
"""Force (re)load of .pyfun — clears cache."""
|
| 191 |
+
global _cache
|
| 192 |
+
_cache = _parse()
|
| 193 |
+
return _cache
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def get() -> Dict[str, Any]:
|
| 197 |
+
"""
|
| 198 |
+
Returns parsed .pyfun config as nested dict.
|
| 199 |
+
Loads and caches on first call — subsequent calls return cache.
|
| 200 |
+
"""
|
| 201 |
+
global _cache
|
| 202 |
+
if _cache is None:
|
| 203 |
+
_cache = _parse()
|
| 204 |
+
return _cache
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_section(section: str) -> Dict[str, Any]:
|
| 208 |
+
"""
|
| 209 |
+
Returns a specific top-level section.
|
| 210 |
+
Returns empty dict if section not found.
|
| 211 |
+
"""
|
| 212 |
+
return get().get(section, {})
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def get_llm_providers() -> Dict[str, Any]:
|
| 216 |
+
"""Returns all LLM providers (active and inactive)."""
|
| 217 |
+
return get().get("LLM_PROVIDERS", {})
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def get_active_llm_providers() -> Dict[str, Any]:
|
| 221 |
+
"""Returns only LLM providers where active = 'true'."""
|
| 222 |
+
return {
|
| 223 |
+
name: cfg
|
| 224 |
+
for name, cfg in get_llm_providers().items()
|
| 225 |
+
if cfg.get("active", "false").lower() == "true"
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
def get_search_providers() -> Dict[str, Any]:
|
| 230 |
+
"""Returns all search providers."""
|
| 231 |
+
return get().get("SEARCH_PROVIDERS", {})
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def get_active_search_providers() -> Dict[str, Any]:
|
| 235 |
+
"""Returns only search providers where active = 'true'."""
|
| 236 |
+
return {
|
| 237 |
+
name: cfg
|
| 238 |
+
for name, cfg in get_search_providers().items()
|
| 239 |
+
if cfg.get("active", "false").lower() == "true"
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
def get_models() -> Dict[str, Any]:
|
| 244 |
+
"""Returns all model definitions."""
|
| 245 |
+
return get().get("MODELS", {})
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def get_models_for_provider(provider_name: str) -> Dict[str, Any]:
|
| 249 |
+
"""Returns all models for a specific provider."""
|
| 250 |
+
return {
|
| 251 |
+
name: cfg
|
| 252 |
+
for name, cfg in get_models().items()
|
| 253 |
+
if cfg.get("provider", "") == provider_name
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def get_tools() -> Dict[str, Any]:
|
| 258 |
+
"""Returns all tool definitions."""
|
| 259 |
+
return get().get("TOOLS", {})
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def get_active_tools() -> Dict[str, Any]:
|
| 263 |
+
"""Returns only tools where active = 'true'."""
|
| 264 |
+
return {
|
| 265 |
+
name: cfg
|
| 266 |
+
for name, cfg in get_tools().items()
|
| 267 |
+
if cfg.get("active", "false").lower() == "true"
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_hub() -> Dict[str, Any]:
|
| 272 |
+
"""Returns [HUB] section."""
|
| 273 |
+
return get_section("HUB")
|
| 274 |
+
|
| 275 |
+
|
| 276 |
+
def get_limits() -> Dict[str, Any]:
|
| 277 |
+
"""Returns [HUB_LIMITS] section."""
|
| 278 |
+
return get_section("HUB_LIMITS")
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
def get_db_sync() -> Dict[str, Any]:
|
| 282 |
+
"""Returns [DB_SYNC] section."""
|
| 283 |
+
return get_section("DB_SYNC")
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def get_debug() -> Dict[str, Any]:
|
| 287 |
+
"""Returns [DEBUG] section."""
|
| 288 |
+
return get_section("DEBUG")
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def is_debug() -> bool:
|
| 292 |
+
"""Returns True if DEBUG = 'ON' in .pyfun."""
|
| 293 |
+
return get_debug().get("DEBUG", "OFF").upper() == "ON"
|
app/db_sync.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/mcp.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/mcp.py
|
| 2 |
+
# Universal MCP Hub (Sandboxed) - based on PyFundaments Architecture
|
| 3 |
+
# Copyright 2026 - Volkan Kücükbudak
|
| 4 |
+
# Apache License V. 2 + ESOL 1.1
|
| 5 |
+
# Repo: https://github.com/VolkanSah/Universal-MCP-Hub-sandboxed
|
| 6 |
+
#
|
| 7 |
+
# ARCHITECTURE NOTE:
|
| 8 |
+
# This file lives exclusively in /app/ and is ONLY started by main.py (the "Guardian").
|
| 9 |
+
# It has NO direct access to API keys, environment variables, or fundament services.
|
| 10 |
+
# Everything is injected by the Guardian via the `fundaments` dictionary.
|
| 11 |
+
# Direct execution is blocked by design.
|
| 12 |
+
#
|
| 13 |
+
# TOOL REGISTRATION PRINCIPLE:
|
| 14 |
+
# Tools are only registered if their required API key/service is present.
|
| 15 |
+
# No key = no tool = no crash. The server always starts, just with fewer tools.
|
| 16 |
+
|
| 17 |
+
import asyncio
|
| 18 |
+
import logging
|
| 19 |
+
import os
|
| 20 |
+
from typing import Dict, Any, Optional
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger('mcp_hub')
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
async def start_mcp(fundaments: Dict[str, Any]):
|
| 26 |
+
"""
|
| 27 |
+
The main entry point for the MCP Hub logic.
|
| 28 |
+
All fundament services are validated and provided by main.py.
|
| 29 |
+
|
| 30 |
+
Args:
|
| 31 |
+
fundaments: Dictionary containing initialized services from main.py.
|
| 32 |
+
Services are already validated and ready to use.
|
| 33 |
+
"""
|
| 34 |
+
logger.info("MCP Hub starting...")
|
| 35 |
+
|
| 36 |
+
# Services are already validated and initialized by main.py
|
| 37 |
+
config_service = fundaments["config"]
|
| 38 |
+
db_service = fundaments["db"] # Can be None if not needed
|
| 39 |
+
encryption_service = fundaments["encryption"] # Can be None if not needed
|
| 40 |
+
access_control_service = fundaments["access_control"] # Can be None if not needed
|
| 41 |
+
user_handler_service = fundaments["user_handler"] # Can be None if not needed
|
| 42 |
+
security_service = fundaments["security"] # Can be None if not needed
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
from mcp.server.fastmcp import FastMCP
|
| 46 |
+
except ImportError:
|
| 47 |
+
logger.critical("FastMCP is not installed. Run: pip install fastmcp")
|
| 48 |
+
raise
|
| 49 |
+
|
| 50 |
+
mcp = FastMCP(
|
| 51 |
+
name="PyFundaments MCP Hub",
|
| 52 |
+
instructions=(
|
| 53 |
+
"Universal MCP Hub built on PyFundaments. "
|
| 54 |
+
"Available tools depend on configured API keys and active services. "
|
| 55 |
+
"Use list_active_tools to see what is currently available."
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# --- LLM Tools (register if API key is present) ---
|
| 60 |
+
|
| 61 |
+
if config_service.has("ANTHROPIC_API_KEY"):
|
| 62 |
+
import httpx
|
| 63 |
+
_key = config_service.get("ANTHROPIC_API_KEY")
|
| 64 |
+
|
| 65 |
+
@mcp.tool()
|
| 66 |
+
async def anthropic_complete(prompt: str, model: str = "claude-haiku-4-5-20251001", max_tokens: int = 1024) -> str:
|
| 67 |
+
"""Send a prompt to Anthropic Claude. Models: claude-haiku-4-5-20251001, claude-sonnet-4-6, claude-opus-4-6"""
|
| 68 |
+
async with httpx.AsyncClient() as client:
|
| 69 |
+
r = await client.post(
|
| 70 |
+
"https://api.anthropic.com/v1/messages",
|
| 71 |
+
headers={"x-api-key": _key, "anthropic-version": "2023-06-01", "content-type": "application/json"},
|
| 72 |
+
json={"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]},
|
| 73 |
+
timeout=60.0
|
| 74 |
+
)
|
| 75 |
+
r.raise_for_status()
|
| 76 |
+
return r.json()["content"][0]["text"]
|
| 77 |
+
logger.info("Tool registered: anthropic_complete")
|
| 78 |
+
|
| 79 |
+
if config_service.has("GEMINI_API_KEY"):
|
| 80 |
+
import httpx
|
| 81 |
+
_key = config_service.get("GEMINI_API_KEY")
|
| 82 |
+
|
| 83 |
+
@mcp.tool()
|
| 84 |
+
async def gemini_complete(prompt: str, model: str = "gemini-2.0-flash", max_tokens: int = 1024) -> str:
|
| 85 |
+
"""Send a prompt to Google Gemini. Models: gemini-2.0-flash, gemini-1.5-pro, gemini-1.5-flash"""
|
| 86 |
+
async with httpx.AsyncClient() as client:
|
| 87 |
+
r = await client.post(
|
| 88 |
+
f"https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent",
|
| 89 |
+
params={"key": _key},
|
| 90 |
+
json={"contents": [{"parts": [{"text": prompt}]}], "generationConfig": {"maxOutputTokens": max_tokens}},
|
| 91 |
+
timeout=60.0
|
| 92 |
+
)
|
| 93 |
+
r.raise_for_status()
|
| 94 |
+
return r.json()["candidates"][0]["content"]["parts"][0]["text"]
|
| 95 |
+
logger.info("Tool registered: gemini_complete")
|
| 96 |
+
|
| 97 |
+
if config_service.has("OPENROUTER_API_KEY"):
|
| 98 |
+
import httpx
|
| 99 |
+
_key = config_service.get("OPENROUTER_API_KEY")
|
| 100 |
+
_referer = config_service.get("APP_URL", "https://huggingface.co")
|
| 101 |
+
|
| 102 |
+
@mcp.tool()
|
| 103 |
+
async def openrouter_complete(prompt: str, model: str = "mistralai/mistral-7b-instruct", max_tokens: int = 1024) -> str:
|
| 104 |
+
"""Send a prompt via OpenRouter (100+ models). Examples: openai/gpt-4o, meta-llama/llama-3-8b-instruct"""
|
| 105 |
+
async with httpx.AsyncClient() as client:
|
| 106 |
+
r = await client.post(
|
| 107 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 108 |
+
headers={"Authorization": f"Bearer {_key}", "HTTP-Referer": _referer, "content-type": "application/json"},
|
| 109 |
+
json={"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]},
|
| 110 |
+
timeout=60.0
|
| 111 |
+
)
|
| 112 |
+
r.raise_for_status()
|
| 113 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 114 |
+
logger.info("Tool registered: openrouter_complete")
|
| 115 |
+
|
| 116 |
+
if config_service.has("HF_TOKEN"):
|
| 117 |
+
import httpx
|
| 118 |
+
_key = config_service.get("HF_TOKEN")
|
| 119 |
+
|
| 120 |
+
@mcp.tool()
|
| 121 |
+
async def hf_inference(prompt: str, model: str = "mistralai/Mistral-7B-Instruct-v0.3", max_tokens: int = 512) -> str:
|
| 122 |
+
"""Send a prompt to HuggingFace Inference API. Browse models: https://huggingface.co/models?inference=warm"""
|
| 123 |
+
async with httpx.AsyncClient() as client:
|
| 124 |
+
r = await client.post(
|
| 125 |
+
f"https://api-inference.huggingface.co/models/{model}/v1/chat/completions",
|
| 126 |
+
headers={"Authorization": f"Bearer {_key}", "content-type": "application/json"},
|
| 127 |
+
json={"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}]},
|
| 128 |
+
timeout=120.0
|
| 129 |
+
)
|
| 130 |
+
r.raise_for_status()
|
| 131 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 132 |
+
logger.info("Tool registered: hf_inference")
|
| 133 |
+
|
| 134 |
+
# --- Search Tools (register if API key is present) ---
|
| 135 |
+
|
| 136 |
+
if config_service.has("BRAVE_API_KEY"):
|
| 137 |
+
import httpx
|
| 138 |
+
_key = config_service.get("BRAVE_API_KEY")
|
| 139 |
+
|
| 140 |
+
@mcp.tool()
|
| 141 |
+
async def brave_search(query: str, count: int = 5) -> str:
|
| 142 |
+
"""Search the web via Brave Search API (independent index, privacy-focused)."""
|
| 143 |
+
async with httpx.AsyncClient() as client:
|
| 144 |
+
r = await client.get(
|
| 145 |
+
"https://api.search.brave.com/res/v1/web/search",
|
| 146 |
+
headers={"Accept": "application/json", "X-Subscription-Token": _key},
|
| 147 |
+
params={"q": query, "count": min(count, 20)},
|
| 148 |
+
timeout=30.0
|
| 149 |
+
)
|
| 150 |
+
r.raise_for_status()
|
| 151 |
+
results = r.json().get("web", {}).get("results", [])
|
| 152 |
+
if not results:
|
| 153 |
+
return "No results found."
|
| 154 |
+
return "\n\n".join([
|
| 155 |
+
f"{i}. {res.get('title', '')}\n {res.get('url', '')}\n {res.get('description', '')}"
|
| 156 |
+
for i, res in enumerate(results, 1)
|
| 157 |
+
])
|
| 158 |
+
logger.info("Tool registered: brave_search")
|
| 159 |
+
|
| 160 |
+
if config_service.has("TAVILY_API_KEY"):
|
| 161 |
+
import httpx
|
| 162 |
+
_key = config_service.get("TAVILY_API_KEY")
|
| 163 |
+
|
| 164 |
+
@mcp.tool()
|
| 165 |
+
async def tavily_search(query: str, max_results: int = 5) -> str:
|
| 166 |
+
"""AI-optimized web search via Tavily. Returns synthesized answer + sources."""
|
| 167 |
+
async with httpx.AsyncClient() as client:
|
| 168 |
+
r = await client.post(
|
| 169 |
+
"https://api.tavily.com/search",
|
| 170 |
+
json={"api_key": _key, "query": query, "max_results": max_results, "include_answer": True},
|
| 171 |
+
timeout=30.0
|
| 172 |
+
)
|
| 173 |
+
r.raise_for_status()
|
| 174 |
+
data = r.json()
|
| 175 |
+
parts = []
|
| 176 |
+
if data.get("answer"):
|
| 177 |
+
parts.append(f"Summary: {data['answer']}")
|
| 178 |
+
for res in data.get("results", []):
|
| 179 |
+
parts.append(f"- {res['title']}\n {res['url']}\n {res.get('content', '')[:200]}...")
|
| 180 |
+
return "\n\n".join(parts)
|
| 181 |
+
logger.info("Tool registered: tavily_search")
|
| 182 |
+
|
| 183 |
+
# --- DB Tools (register only if DB is initialized) ---
|
| 184 |
+
|
| 185 |
+
if db_service is not None:
|
| 186 |
+
from fundaments.postgresql import execute_secured_query
|
| 187 |
+
|
| 188 |
+
@mcp.tool()
|
| 189 |
+
async def db_query(sql: str) -> str:
|
| 190 |
+
"""Execute a read-only SELECT query. All write operations are blocked."""
|
| 191 |
+
if not sql.strip().upper().startswith("SELECT"):
|
| 192 |
+
return "Error: Only SELECT statements are permitted."
|
| 193 |
+
try:
|
| 194 |
+
result = await execute_secured_query(sql, fetch_method='fetch')
|
| 195 |
+
if not result:
|
| 196 |
+
return "No results."
|
| 197 |
+
return str([dict(row) for row in result])
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"DB query error: {e}")
|
| 200 |
+
return f"Database error: {str(e)}"
|
| 201 |
+
logger.info("Tool registered: db_query")
|
| 202 |
+
|
| 203 |
+
else:
|
| 204 |
+
logger.info("No database available - DB tools skipped.")
|
| 205 |
+
|
| 206 |
+
# --- System Tools (always registered) ---
|
| 207 |
+
|
| 208 |
+
@mcp.tool()
|
| 209 |
+
def list_active_tools() -> Dict[str, Any]:
|
| 210 |
+
"""Show active services and configured integrations (key names only, never values)."""
|
| 211 |
+
return {
|
| 212 |
+
"fundaments_status": {k: v is not None for k, v in fundaments.items()},
|
| 213 |
+
"configured_integrations": [
|
| 214 |
+
key for key in [
|
| 215 |
+
"ANTHROPIC_API_KEY", "GEMINI_API_KEY", "OPENROUTER_API_KEY",
|
| 216 |
+
"HF_TOKEN", "BRAVE_API_KEY", "TAVILY_API_KEY", "DATABASE_URL"
|
| 217 |
+
] if config_service.has(key)
|
| 218 |
+
],
|
| 219 |
+
"transport": os.getenv("MCP_TRANSPORT", "stdio"),
|
| 220 |
+
"app_mode": os.getenv("APP_MODE", "mcp")
|
| 221 |
+
}
|
| 222 |
+
logger.info("Tool registered: list_active_tools")
|
| 223 |
+
|
| 224 |
+
@mcp.tool()
|
| 225 |
+
def health_check() -> Dict[str, str]:
|
| 226 |
+
"""Health check endpoint for HuggingFace Spaces and monitoring."""
|
| 227 |
+
return {"status": "ok", "service": "PyFundaments MCP Hub"}
|
| 228 |
+
logger.info("Tool registered: health_check")
|
| 229 |
+
|
| 230 |
+
# --- Encryption available ---
|
| 231 |
+
if encryption_service:
|
| 232 |
+
logger.info("Encryption service active - available for future tools.")
|
| 233 |
+
|
| 234 |
+
# --- Auth/Security available ---
|
| 235 |
+
if user_handler_service and security_service:
|
| 236 |
+
logger.info("Auth services active - available for future tools.")
|
| 237 |
+
|
| 238 |
+
# --- Start transport ---
|
| 239 |
+
transport = os.getenv("MCP_TRANSPORT", "stdio").lower()
|
| 240 |
+
if transport == "sse":
|
| 241 |
+
host = os.getenv("HOST", "0.0.0.0")
|
| 242 |
+
port = int(os.getenv("PORT", "7860"))
|
| 243 |
+
logger.info(f"MCP Hub starting via SSE on {host}:{port}")
|
| 244 |
+
mcp.run(transport="sse", host=host, port=port)
|
| 245 |
+
else:
|
| 246 |
+
logger.info("MCP Hub starting via stdio (local mode)")
|
| 247 |
+
await mcp.run_stdio_async() # ← direkt awaiten, kein neuer Loop!
|
| 248 |
+
|
| 249 |
+
logger.info("MCP Hub shut down.")
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
# ============================================================
|
| 253 |
+
# Direct execution guard - mirrors example.app.py exactly
|
| 254 |
+
# ============================================================
|
| 255 |
+
if __name__ == '__main__':
|
| 256 |
+
print("WARNING: Running mcp.py directly. Fundament modules might not be correctly initialized.")
|
| 257 |
+
print("Please run 'python main.py' instead for proper initialization.")
|
| 258 |
+
|
| 259 |
+
test_fundaments = {
|
| 260 |
+
"config": None,
|
| 261 |
+
"db": None,
|
| 262 |
+
"encryption": None,
|
| 263 |
+
"access_control": None,
|
| 264 |
+
"user_handler": None,
|
| 265 |
+
"security": None
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
asyncio.run(start_mcp(test_fundaments))
|
app/models.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/provider.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/tools.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
docs/access_control.py.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secure Role-Based Access Control (RBAC)
|
| 2 |
+
|
| 3 |
+
### Overview
|
| 4 |
+
|
| 5 |
+
This module acts as the service layer for managing user permissions and roles. It is a critical component of a secure application, ensuring that users can only access the resources they are authorized for.
|
| 6 |
+
|
| 7 |
+
The module is a prime example of building a robust logical layer on a solid foundation. It utilizes the secure database connection provided by `postgresql.py` to handle all interactions with the database, guaranteeing that every query is executed safely and correctly without exposing the underlying security logic.
|
| 8 |
+
|
| 9 |
+
-----
|
| 10 |
+
|
| 11 |
+
### Core Concepts: The RBAC Model
|
| 12 |
+
|
| 13 |
+
This module implements a standard Role-Based Access Control model with the following components:
|
| 14 |
+
|
| 15 |
+
- **Users:** Application users with unique IDs.
|
| 16 |
+
- **Permissions:** Granular rights or actions a user can perform (e.g., `create_post`, `edit_profile`).
|
| 17 |
+
- **Roles:** Collections of permissions (e.g., `admin`, `editor`, `viewer`).
|
| 18 |
+
- **Assignments:** Roles are assigned to users, granting them all the permissions associated with that role.
|
| 19 |
+
|
| 20 |
+
-----
|
| 21 |
+
|
| 22 |
+
### Dependencies
|
| 23 |
+
|
| 24 |
+
This module is built on your project's existing `fundaments`:
|
| 25 |
+
|
| 26 |
+
- `postgresql.py`: The secure database connection module.
|
| 27 |
+
- `asyncpg`: The asynchronous PostgreSQL driver.
|
| 28 |
+
|
| 29 |
+
-----
|
| 30 |
+
|
| 31 |
+
### Usage
|
| 32 |
+
|
| 33 |
+
The `AccessControl` class is designed to be instantiated for a specific user, making it simple to check their permissions.
|
| 34 |
+
|
| 35 |
+
#### 1\. **Initialization**
|
| 36 |
+
|
| 37 |
+
The class is initialized with a user's ID.
|
| 38 |
+
|
| 39 |
+
```python
|
| 40 |
+
from fundaments.access_control import AccessControl
|
| 41 |
+
|
| 42 |
+
# Assume a user with ID 1 exists
|
| 43 |
+
user_id = 1
|
| 44 |
+
access_control = AccessControl(user_id)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
#### 2\. **Checking Permissions**
|
| 48 |
+
|
| 49 |
+
The `has_permission` method checks if the user has a specific permission.
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
# Check if the user has the 'create_post' permission
|
| 53 |
+
can_create_post = await access_control.has_permission('create_post')
|
| 54 |
+
|
| 55 |
+
if can_create_post:
|
| 56 |
+
print("User is authorized to create a new post.")
|
| 57 |
+
else:
|
| 58 |
+
print("Permission denied.")
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
#### 3\. **Retrieving User Information**
|
| 62 |
+
|
| 63 |
+
You can easily fetch a list of a user's roles or permissions.
|
| 64 |
+
|
| 65 |
+
```python
|
| 66 |
+
# Get all roles assigned to the user
|
| 67 |
+
user_roles = await access_control.get_user_roles()
|
| 68 |
+
print(f"User's roles: {user_roles}")
|
| 69 |
+
|
| 70 |
+
# Get all permissions for the user
|
| 71 |
+
user_permissions = await access_control.get_user_permissions()
|
| 72 |
+
print(f"User's permissions: {user_permissions}")
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
#### 4\. **Administrative Functions**
|
| 76 |
+
|
| 77 |
+
The module also includes methods for managing roles and permissions (e.g., in an admin panel).
|
| 78 |
+
|
| 79 |
+
```python
|
| 80 |
+
# Create a new role
|
| 81 |
+
new_role_id = await access_control.create_role(
|
| 82 |
+
name='moderator',
|
| 83 |
+
description='Manages posts and comments.'
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
# Assign a role to the user
|
| 87 |
+
await access_control.assign_role(new_role_id)
|
| 88 |
+
|
| 89 |
+
# Get permissions for a specific role
|
| 90 |
+
moderator_permissions = await access_control.get_role_permissions(new_role_id)
|
| 91 |
+
```
|
| 92 |
+
|
| 93 |
+
-----
|
| 94 |
+
|
| 95 |
+
### Database Schema (Required)
|
| 96 |
+
|
| 97 |
+
The module's functionality relies on the following relational schema:
|
| 98 |
+
|
| 99 |
+
- `user_roles`: Stores all available roles (`id`, `name`, `description`).
|
| 100 |
+
- `user_permissions`: Stores all available permissions (`id`, `name`, `description`).
|
| 101 |
+
- `user_role_assignments`: A junction table linking `user_id` to `role_id`.
|
| 102 |
+
- `role_permissions`: A junction table linking `role_id` to `permission_id`.
|
| 103 |
+
|
| 104 |
+
-----
|
| 105 |
+
|
| 106 |
+
### Security & Architecture
|
| 107 |
+
|
| 108 |
+
- **Secure by Design:** This module never executes raw, unsanitized SQL. Every database operation is channeled through the secure `db.execute_secured_query` function, inheriting its protection against SQL injection and other vulnerabilities.
|
| 109 |
+
- **Separation of Concerns:** It successfully separates the business logic of access control from the low-level concerns of database security, making the entire application more robust and easier to maintain.
|
| 110 |
+
- **Extensibility:** New access control methods can be added easily by following the established pattern of using the underlying `db` module.
|
| 111 |
+
|
| 112 |
+
This `access_control.py` is a prime example of a secure, modular, and extensible building block for your application's architecture.
|
docs/config_handler.py.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Module: `config_handler.py`
|
| 2 |
+
|
| 3 |
+
## Description
|
| 4 |
+
|
| 5 |
+
The `config_handler` module is a core component of the application's security fundament. It provides a centralized, secure, and robust mechanism for managing all critical environment variables. By enforcing early validation, it prevents the application from starting in an insecure or misconfigured state.
|
| 6 |
+
|
| 7 |
+
## Core Principles
|
| 8 |
+
|
| 9 |
+
- **Centralized Source of Truth**: All environment variables are loaded and managed from a single point.
|
| 10 |
+
- **Fail-Fast Mechanism**: The application exits immediately if any required configuration key is missing. This prevents runtime errors and potential security vulnerabilities from a broken setup.
|
| 11 |
+
- **Separation of Concerns**: It decouples the loading and validation of configurations from the business logic of other modules.
|
| 12 |
+
|
| 13 |
+
## Required Environment Variables
|
| 14 |
+
|
| 15 |
+
The `ConfigHandler` is configured to specifically look for the following keys, which must be present in the `.env` file or the system's environment variables.
|
| 16 |
+
|
| 17 |
+
| Key | Description | Example |
|
| 18 |
+
| :--- | :--- | :--- |
|
| 19 |
+
| `DATABASE_URL` | The full DSN (Data Source Name) string for the PostgreSQL database. Supports local connections and cloud providers like Neon.tech. | `postgresql://user:password@host:port/database?sslmode=require` |
|
| 20 |
+
| `MASTER_ENCRYPTION_KEY` | A 256-bit key used for symmetric encryption across the application. **Crucial for data security.** | `532c6614...` |
|
| 21 |
+
| `PERSISTENT_ENCRYPTION_SALT` | A unique salt used with the master key to enhance cryptographic security. | `a0b7e8d2...` |
|
| 22 |
+
|
| 23 |
+
## Usage
|
| 24 |
+
|
| 25 |
+
Other modules, such as `main.py`, import the singleton instance of the `ConfigHandler` to access validated configuration values safely.
|
| 26 |
+
|
| 27 |
+
```python
|
| 28 |
+
# In main.py or any other fundament module
|
| 29 |
+
from fundaments.config_handler import config_service
|
| 30 |
+
|
| 31 |
+
# To get a validated value
|
| 32 |
+
db_url = config_service.get("DATABASE_URL")
|
| 33 |
+
master_key = config_service.get("MASTER_ENCRYPTION_KEY")
|
| 34 |
+
```
|
docs/encryption.py.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secure and Robust Encryption Module (AES-256-GCM)
|
| 2 |
+
|
| 3 |
+
### Overview
|
| 4 |
+
|
| 5 |
+
This module provides a secure and reusable encryption component for your application's `fundaments`. It is built on the industry-standard `cryptography` library and utilizes `AES-256-GCM`, an authenticated encryption mode that ensures both confidentiality and integrity of your data.
|
| 6 |
+
|
| 7 |
+
It is designed to be a "set-and-forget" core component, allowing developers to handle encryption without getting bogged down in low-level cryptographic details, while still adhering to best practices.
|
| 8 |
+
|
| 9 |
+
-----
|
| 10 |
+
|
| 11 |
+
### Key Security Concepts
|
| 12 |
+
|
| 13 |
+
This module's security is built upon the following principles:
|
| 14 |
+
|
| 15 |
+
1. **AES-256-GCM:** The chosen algorithm is AES (Advanced Encryption Standard) with a 256-bit key, using GCM (Galois/Counter Mode). GCM is an authenticated encryption mode, meaning it not only encrypts data but also generates a unique **authentication tag** to verify that the data has not been tampered with.
|
| 16 |
+
2. **Key Derivation (PBKDF2):** The actual encryption key is not the raw master key. Instead, it is derived from the master key and a unique **salt** using PBKDF2 with a high number of iterations. This makes brute-force attacks against the master key computationally infeasible.
|
| 17 |
+
3. **Nonce/IV:** Each encryption operation generates a unique, random **nonce** (Number used once) to ensure that the same plaintext encrypted multiple times results in different ciphertext, protecting against pattern analysis.
|
| 18 |
+
4. **Secure Storage:** The module emphasizes the importance of securely storing the **master key** (e.g., in environment variables) and the persistent **salt** (e.g., in a secure configuration file or database).
|
| 19 |
+
|
| 20 |
+
-----
|
| 21 |
+
|
| 22 |
+
### Usage
|
| 23 |
+
|
| 24 |
+
First, ensure the required library is installed: `pip install cryptography`.
|
| 25 |
+
|
| 26 |
+
#### 1\. **Initialization**
|
| 27 |
+
|
| 28 |
+
To use the module, you must provide a `master_key` and a persistent `salt`. The `salt` should be generated once and stored securely.
|
| 29 |
+
|
| 30 |
+
```python
|
| 31 |
+
from fundaments.encryption import Encryption
|
| 32 |
+
|
| 33 |
+
# Generate a salt ONCE and store it securely
|
| 34 |
+
# DO NOT generate a new salt on every run!
|
| 35 |
+
persistent_salt = Encryption.generate_salt()
|
| 36 |
+
|
| 37 |
+
# Your master key should be stored in an environment variable or secret
|
| 38 |
+
master_key = os.getenv("MASTER_ENCRYPTION_KEY")
|
| 39 |
+
|
| 40 |
+
# Initialize the encryption handler
|
| 41 |
+
crypto_handler = Encryption(master_key=master_key, salt=persistent_salt)
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
#### 2\. **Encrypting and Decrypting Strings**
|
| 45 |
+
|
| 46 |
+
The `encrypt` method returns a dictionary containing the encrypted data, nonce, and tag. You must store all three to be able to decrypt the data later.
|
| 47 |
+
|
| 48 |
+
```python
|
| 49 |
+
# Encrypt a string
|
| 50 |
+
plaintext = "This is a secret message."
|
| 51 |
+
encrypted_data = crypto_handler.encrypt(plaintext)
|
| 52 |
+
|
| 53 |
+
# The output is a dict:
|
| 54 |
+
# {'data': '...', 'nonce': '...', 'tag': '...'}
|
| 55 |
+
|
| 56 |
+
# Decrypt the string
|
| 57 |
+
decrypted_string = crypto_handler.decrypt(
|
| 58 |
+
encrypted_data['data'],
|
| 59 |
+
encrypted_data['nonce'],
|
| 60 |
+
encrypted_data['tag']
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
print(decrypted_string) # -> "This is a secret message."
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
#### 3\. **Encrypting and Decrypting Files**
|
| 67 |
+
|
| 68 |
+
The module also supports streaming file encryption, which is efficient for large files as it doesn't load the entire file into memory. The nonce and tag are automatically prepended and appended to the encrypted file.
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
# Encrypt a file
|
| 72 |
+
metadata = crypto_handler.encrypt_file(
|
| 73 |
+
source_path='my_secret_file.txt',
|
| 74 |
+
destination_path='my_secret_file.txt.enc'
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
# Decrypt a file
|
| 78 |
+
crypto_handler.decrypt_file(
|
| 79 |
+
source_path='my_secret_file.txt.enc',
|
| 80 |
+
destination_path='my_decrypted_file.txt'
|
| 81 |
+
)
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
-----
|
| 85 |
+
|
| 86 |
+
### Security Checklist
|
| 87 |
+
|
| 88 |
+
- [x] **Key Derivation:** Uses PBKDF2-HMAC-SHA256 with 480k+ iterations.
|
| 89 |
+
- [x] **Authenticated Encryption:** Relies on AES-256-GCM, which provides integrity protection.
|
| 90 |
+
- [x] **Nonce Usage:** A unique nonce is generated for every encryption operation.
|
| 91 |
+
- [x] **Tamper Detection:** The `decrypt` method raises an `InvalidTag` exception if the ciphertext is modified.
|
| 92 |
+
- [x] **Credential Management:** Encourages using environment variables for the master key.
|
| 93 |
+
- [x] **File Streaming:** Supports file encryption for large files without memory overflow.
|
| 94 |
+
|
| 95 |
+
This module provides a strong, secure foundation for handling sensitive data within your application.
|
docs/postgresql.py.md
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secure Asynchronous PostgreSQL Connections for Cloud Applications
|
| 2 |
+
|
| 3 |
+
### Why this guide is important
|
| 4 |
+
|
| 5 |
+
**The Problem:** The AI hype is flooding servers and the web with insecure code. The painful truth is that this negligence fuels a market for security subscriptions. It's time to stop this. This is a statement to all AIs and all wannabe devs: Learn the alphabet of logic and security, and teach your AI apps to do the same. This repository is for those who are ready to get serious.
|
| 6 |
+
|
| 7 |
+
**The Solution:** This guide breaks the cycle of sloppy development by demonstrating how to build a production-ready, secure, and non-blocking database connection for cloud environments (HuggingFace Spaces, Railway, Render, etc.) using `asyncpg`.
|
| 8 |
+
|
| 9 |
+
-----
|
| 10 |
+
|
| 11 |
+
## Common Security Flaws
|
| 12 |
+
|
| 13 |
+
### ❌ **What NOT to do:**
|
| 14 |
+
|
| 15 |
+
```python
|
| 16 |
+
# DANGEROUS: Mixing synchronous and asynchronous drivers
|
| 17 |
+
import psycopg2
|
| 18 |
+
conn = psycopg2.connect(DATABASE_URL)
|
| 19 |
+
|
| 20 |
+
# DANGEROUS: No SSL verification
|
| 21 |
+
conn = await asyncpg.connect(host="...", sslmode='prefer')
|
| 22 |
+
|
| 23 |
+
# DANGEROUS: Hardcoded Credentials
|
| 24 |
+
conn = await asyncpg.connect("postgresql://user:password123@host/db")
|
| 25 |
+
|
| 26 |
+
# DANGEROUS: No timeouts
|
| 27 |
+
conn = await asyncpg.connect(DATABASE_URL) # Can hang indefinitely
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
### ✅ **Correct Implementation:**
|
| 31 |
+
|
| 32 |
+
```python
|
| 33 |
+
# SECURE: Connection pool is initialized once for the entire application
|
| 34 |
+
pool = await asyncpg.create_pool(
|
| 35 |
+
DATABASE_URL,
|
| 36 |
+
connect_timeout=5,
|
| 37 |
+
command_timeout=30
|
| 38 |
+
)
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
-----
|
| 42 |
+
|
| 43 |
+
## Architecture of a Secure Connection
|
| 44 |
+
|
| 45 |
+
### 1\. **Asynchronous Connection Pool**
|
| 46 |
+
|
| 47 |
+
```python
|
| 48 |
+
# Create a single pool at application startup
|
| 49 |
+
_db_pool = await asyncpg.create_pool(dsn=DATABASE_URL, ...)
|
| 50 |
+
|
| 51 |
+
# Acquire and release connections automatically
|
| 52 |
+
async with _db_pool.acquire() as conn:
|
| 53 |
+
await conn.execute(...)
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
**Why:** A pool is essential for efficiency in asynchronous applications. It manages connections, reduces overhead, and is the standard for high-traffic apps.
|
| 57 |
+
|
| 58 |
+
### 2\. **SSL Runtime Verification**
|
| 59 |
+
|
| 60 |
+
```python
|
| 61 |
+
# Check at runtime if SSL is active
|
| 62 |
+
ssl_status = await conn.fetchval("SELECT CASE WHEN ssl THEN 'active' ELSE 'INACTIVE' END FROM pg_stat_ssl WHERE pid = pg_backend_pid()")
|
| 63 |
+
|
| 64 |
+
if ssl_status != 'active':
|
| 65 |
+
raise RuntimeError("SSL required but not active")
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**Why:** DSN parameters can fail; a runtime check is mandatory to prevent security breaches.
|
| 69 |
+
|
| 70 |
+
### 3\. **Cloud-Optimized Timeouts**
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
connect_timeout=5, # Connection establishment
|
| 74 |
+
keepalives_idle=60, # Keep-alive for cloud latency
|
| 75 |
+
command_timeout=30 # Query timeout (30s)
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
**Why:** Cloud connections have higher latency and can be unstable. Timeouts protect against hanging connections and DoS attacks.
|
| 79 |
+
|
| 80 |
+
### 4\. **Production Error Sanitization**
|
| 81 |
+
|
| 82 |
+
```python
|
| 83 |
+
if os.getenv('APP_ENV') == 'production':
|
| 84 |
+
logger.error(f"Database query failed [Code: {e.sqlstate}]")
|
| 85 |
+
else:
|
| 86 |
+
logger.error(f"Query failed [{e.sqlstate}]: {e}")
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
**Why:** Prevents information leakage about your database structure to end-users.
|
| 90 |
+
|
| 91 |
+
-----
|
| 92 |
+
|
| 93 |
+
## Security Layering
|
| 94 |
+
|
| 95 |
+
### **Layer 1: Transport Security**
|
| 96 |
+
|
| 97 |
+
- **SSL/TLS Encryption** with `sslmode=require` minimum
|
| 98 |
+
- **Certificate Validation** for sensitive data
|
| 99 |
+
- **Connection Timeouts** to protect against DoS
|
| 100 |
+
|
| 101 |
+
### **Layer 2: Authentication**
|
| 102 |
+
|
| 103 |
+
- **Environment Variables** for Credentials
|
| 104 |
+
- **Application Name** for connection tracking
|
| 105 |
+
- **Cloud Secret Management** (HF Secrets, Railway Vars)
|
| 106 |
+
|
| 107 |
+
### **Layer 3: Query Security**
|
| 108 |
+
|
| 109 |
+
- **Parameterized Queries** exclusively using `$1, $2, ...`
|
| 110 |
+
- **Statement Timeouts** against long-running queries
|
| 111 |
+
- **Connection Cleanup** via pool management
|
| 112 |
+
|
| 113 |
+
### **Layer 4: Monitoring & Logging**
|
| 114 |
+
|
| 115 |
+
- **SSL Status Verification** on every connection
|
| 116 |
+
- **Error Sanitization** in Production
|
| 117 |
+
- **Cloud Provider Detection** for debugging
|
| 118 |
+
|
| 119 |
+
-----
|
| 120 |
+
|
| 121 |
+
## Cloud-Specific Considerations
|
| 122 |
+
|
| 123 |
+
### **HuggingFace Spaces**
|
| 124 |
+
|
| 125 |
+
```bash
|
| 126 |
+
# Set as a Secret:
|
| 127 |
+
DATABASE_URL="postgresql://user:pass@host.neon.tech/db?sslmode=require&application_name=hf_space"
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
### **Railway/Render**
|
| 131 |
+
|
| 132 |
+
```bash
|
| 133 |
+
# As an Environment Variable:
|
| 134 |
+
DATABASE_URL="postgresql://user:pass@host/db?sslmode=require&connect_timeout=10"
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
### **Why `sslmode=require` instead of `verify-full`?**
|
| 138 |
+
|
| 139 |
+
- ✅ Cloud providers (Neon, Supabase) handle their own CA-Chains
|
| 140 |
+
- ✅ Avoids certificate issues in ephemeral containers
|
| 141 |
+
- ✅ Sufficient for managed databases
|
| 142 |
+
- ❌ `verify-full` requires local certificate files (often not available in cloud)
|
| 143 |
+
|
| 144 |
+
-----
|
| 145 |
+
|
| 146 |
+
## 📊 Security Assessment
|
| 147 |
+
|
| 148 |
+
| Security Aspect | Status | Rationale |
|
| 149 |
+
|-------------------|--------|------------|
|
| 150 |
+
| **SSL Enforcement** | ✅ Excellent | Runtime verification + fail-safe |
|
| 151 |
+
| **Credential Management** | ✅ Excellent | Environment variables only |
|
| 152 |
+
| **SQL Injection Prevention** | ✅ Excellent | Parameterized queries only |
|
| 153 |
+
| **DoS Protection** | ✅ Excellent | Connection + statement timeouts |
|
| 154 |
+
| **Information Leakage** | ✅ Excellent | Production error sanitization |
|
| 155 |
+
| **Connection Pooling** | ✅ Excellent | Implemented with `asyncpg.create_pool` |
|
| 156 |
+
|
| 157 |
+
**Security Score: 10/10** - Production-ready for cloud environments
|
| 158 |
+
|
| 159 |
+
-----
|
| 160 |
+
|
| 161 |
+
## 🔧 Troubleshooting
|
| 162 |
+
|
| 163 |
+
### **`psycopg.OperationalError: could not connect to server: Connection refused`**
|
| 164 |
+
|
| 165 |
+
- **Cause:** The `DATABASE_URL` is incorrect, the database is not running, or network ports are blocked.
|
| 166 |
+
- **Solution:** Verify your `DATABASE_URL` environment variable and ensure the database service is active and accessible from your application's network.
|
| 167 |
+
|
| 168 |
+
### **`RuntimeError: SSL connection failed`**
|
| 169 |
+
|
| 170 |
+
- **Cause:** Your application connected to the database, but SSL was not active, failing the runtime check. This could be due to a misconfigured `sslmode` in the `DATABASE_URL` or an issue with the cloud provider's setup.
|
| 171 |
+
- **Solution:** Check your `DATABASE_URL` to ensure `sslmode=require` or a more secure setting is present and correctly enforced.
|
| 172 |
+
|
| 173 |
+
### **`asyncpg.exceptions.PostgresError: connection terminated...` (Neon.tech)**
|
| 174 |
+
|
| 175 |
+
- **Cause:** A specific issue with how Neon.tech handles connections. The connection is terminated after a period of inactivity.
|
| 176 |
+
- **Solution:** Our code includes a specific check for this state and automatically restarts the pool, but it is important to understand why it happens.
|
| 177 |
+
|
| 178 |
+
### **`ValueError: DATABASE_URL environment variable must be set`**
|
| 179 |
+
|
| 180 |
+
- **Cause:** The `os.getenv("DATABASE_URL")` call returned `None`.
|
| 181 |
+
- **Solution:** Make sure your `DATABASE_URL` is correctly set in your environment variables or as a secret in your cloud provider's dashboard.
|
| 182 |
+
|
| 183 |
+
-----
|
| 184 |
+
|
| 185 |
+
## Quick Start for Cloud Deployment
|
| 186 |
+
|
| 187 |
+
### 1\. **Environment Setup**
|
| 188 |
+
|
| 189 |
+
```bash
|
| 190 |
+
# In your cloud provider dashboard:
|
| 191 |
+
DATABASE_URL="postgresql://user:strongpass@host.provider.com/dbname?sslmode=require&connect_timeout=10"
|
| 192 |
+
```
|
| 193 |
+
|
| 194 |
+
### 2\. **Code Integration**
|
| 195 |
+
|
| 196 |
+
```python
|
| 197 |
+
from secure_pg_connection import init_db_pool, health_check, execute_secured_query
|
| 198 |
+
|
| 199 |
+
# At application startup
|
| 200 |
+
await init_db_pool()
|
| 201 |
+
|
| 202 |
+
# Later, check the connection and run a query
|
| 203 |
+
if (await health_check())['status'] == 'ok':
|
| 204 |
+
users = await execute_secured_query("SELECT * FROM users WHERE status = $1", 'active', fetch_method='fetch')
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
### 3\. **Production Checklist**
|
| 208 |
+
|
| 209 |
+
- [x] `APP_ENV=production` is set
|
| 210 |
+
- [x] SSL mode is at least `require`
|
| 211 |
+
- [x] Database URL is a Secret/EnvVar
|
| 212 |
+
- [x] All timeouts are configured
|
| 213 |
+
- [x] Error logging is enabled
|
| 214 |
+
|
| 215 |
+
-----
|
| 216 |
+
|
| 217 |
+
## Conclusion
|
| 218 |
+
|
| 219 |
+
This implementation provides a **Defense-in-Depth** strategy for PostgreSQL connections in cloud environments:
|
| 220 |
+
|
| 221 |
+
1. **Secure Defaults** - SSL required, timeouts active
|
| 222 |
+
2. **Runtime Verification** - SSL status is checked
|
| 223 |
+
3. **Cloud-Optimized** - Designed for ephemeral containers
|
| 224 |
+
4. **Production-Ready** - Error sanitization, monitoring
|
| 225 |
+
|
| 226 |
+
**Result:** Production-grade database connections that remain secure even with network issues, SSL misconfigurations, or attacks.
|
| 227 |
+
|
docs/security.py.md
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Orchestrating Security Fundamentals
|
| 2 |
+
|
| 3 |
+
### Why this guide is important
|
| 4 |
+
|
| 5 |
+
**The Problem:** Many projects fail to implement a cohesive security strategy. Developers often scatter security logic across the application, making it difficult to audit, maintain, and scale. This leads to vulnerabilities like session fixation and mismanaged credentials. Simply using secure libraries is not enough—the way they are used matters.
|
| 6 |
+
|
| 7 |
+
**The Solution:** This guide introduces a centralized **security orchestration layer** that acts as a single, trusted interface for the entire application. By encapsulating all core security logic, we prevent scattered implementations and ensure that every security-related action adheres to a single, verified standard. This is the final layer of our "defense-in-depth" strategy.
|
| 8 |
+
|
| 9 |
+
-----
|
| 10 |
+
|
| 11 |
+
## Common Security Flaws
|
| 12 |
+
|
| 13 |
+
### ❌ **What NOT to do:**
|
| 14 |
+
|
| 15 |
+
```python
|
| 16 |
+
# DANGEROUS: Calling low-level security modules directly in app logic
|
| 17 |
+
from fundaments.user_handler import UserHandler
|
| 18 |
+
from fundaments.encryption import Encryption
|
| 19 |
+
from fundaments.access_control import AccessControl
|
| 20 |
+
# ... later in your code:
|
| 21 |
+
user_handler.login(...)
|
| 22 |
+
access_control.has_permission(...)
|
| 23 |
+
|
| 24 |
+
# DANGEROUS: Unvalidated session data
|
| 25 |
+
if session.get('user_id'):
|
| 26 |
+
# This is a potential session fixation vulnerability if not regenerated
|
| 27 |
+
# and validated against request data (IP, User-Agent).
|
| 28 |
+
...
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### ✅ **Correct Implementation:**
|
| 32 |
+
|
| 33 |
+
```python
|
| 34 |
+
# SECURE: Orchestration through a single, trusted Security class
|
| 35 |
+
from fundaments.security import Security
|
| 36 |
+
# ... later in your code:
|
| 37 |
+
login_success = await security.user_login(username, password, request_data)
|
| 38 |
+
if login_success:
|
| 39 |
+
# A successful login automatically means the session has been
|
| 40 |
+
# validated and regenerated.
|
| 41 |
+
...
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
-----
|
| 45 |
+
|
| 46 |
+
## Architecture of the Security Manager
|
| 47 |
+
|
| 48 |
+
### 1\. **The Single Point of Contact**
|
| 49 |
+
|
| 50 |
+
```python
|
| 51 |
+
# The Security class is a single entry point for all security tasks.
|
| 52 |
+
security = Security(fundament_services)
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
**Why:** This design principle is crucial. By exposing a single `Security` object, we ensure that all security-related operations (authentication, authorization, encryption) are performed through a single, audited layer. This prevents developers from accidentally bypassing critical security checks by calling a low-level module directly.
|
| 56 |
+
|
| 57 |
+
### 2\. **Dependency Injection**
|
| 58 |
+
|
| 59 |
+
```python
|
| 60 |
+
# The Security class receives its dependencies (other fundaments)
|
| 61 |
+
# during initialization.
|
| 62 |
+
def __init__(self, services: Dict[str, Any]):
|
| 63 |
+
self.user_handler = services.get("user_handler")
|
| 64 |
+
self.encryption = services.get("encryption")
|
| 65 |
+
...
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**Why:** Instead of creating its own instances, the `Security` class is "injected" with the already-initialized services. This makes the class highly decoupled, easier to test, and enforces the `main.py` entry point as the single source of truth for service initialization.
|
| 69 |
+
|
| 70 |
+
### 3\. **Encapsulated Security Logic**
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
async def user_login(self, username: str, password: str, request_data: dict) -> bool:
|
| 74 |
+
# This single method encapsulates multiple security steps:
|
| 75 |
+
# 1. Credential verification with password hashing.
|
| 76 |
+
# 2. Brute-force protection (account locking).
|
| 77 |
+
# 3. Session fixation prevention (session regeneration).
|
| 78 |
+
# 4. Session hijacking prevention (IP/User-Agent validation).
|
| 79 |
+
...
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
**Why:** The `user_login` method is more than just a simple wrapper. It orchestrates a chain of security checks, guaranteeing that a user is not only authenticated but also that their session is secure against common attacks.
|
| 83 |
+
|
| 84 |
+
-----
|
| 85 |
+
|
| 86 |
+
## Security Layering
|
| 87 |
+
|
| 88 |
+
### **Layer 1: Configuration & Secrets**
|
| 89 |
+
|
| 90 |
+
- **Purpose:** Securely manages sensitive credentials.
|
| 91 |
+
- **Tools:** `config_handler`
|
| 92 |
+
- **Security:** `.env` file, environment variables, cloud secrets.
|
| 93 |
+
|
| 94 |
+
### **Layer 2: Data Encryption**
|
| 95 |
+
|
| 96 |
+
- **Purpose:** Protects sensitive data at rest.
|
| 97 |
+
- **Tools:** `encryption`
|
| 98 |
+
- **Security:** AES-256-GCM, PBKDF2HMAC for key derivation, unique salts.
|
| 99 |
+
|
| 100 |
+
### **Layer 3: Authentication & Authorization**
|
| 101 |
+
|
| 102 |
+
- **Purpose:** Validates user identity and permissions.
|
| 103 |
+
- **Tools:** `user_handler`, `access_control`
|
| 104 |
+
- **Security:** Password hashing, rate limiting, RBAC.
|
| 105 |
+
|
| 106 |
+
### **Layer 4: Orchestration**
|
| 107 |
+
|
| 108 |
+
- **Purpose:** The final layer that unifies and orchestrates all other security services.
|
| 109 |
+
- **Tools:** `security`
|
| 110 |
+
- **Security:** Single API for all security actions, runtime validation, consolidated logic.
|
| 111 |
+
|
| 112 |
+
-----
|
| 113 |
+
|
| 114 |
+
## 📊 Security Assessment
|
| 115 |
+
|
| 116 |
+
| Security Aspect | Status | Rationale |
|
| 117 |
+
|-------------------|--------|------------|
|
| 118 |
+
| **Logic Centralization** | ✅ Excellent | All core security logic is in one place. |
|
| 119 |
+
| **Session Security** | ✅ Excellent | Handles fixation and hijacking prevention automatically. |
|
| 120 |
+
| **Password Management** | ✅ Excellent | PBKDF2 hashing, brute-force protection, account locking. |
|
| 121 |
+
| **Decoupled Design** | ✅ Excellent | Uses dependency injection; easily testable and maintainable. |
|
| 122 |
+
| **API Simplicity** | ✅ Excellent | One class, one entry point for the application. |
|
| 123 |
+
|
| 124 |
+
**Security Score: 10/10** - A secure, well-structured security layer for production.
|
| 125 |
+
|
| 126 |
+
-----
|
| 127 |
+
|
| 128 |
+
## 🔧 Troubleshooting
|
| 129 |
+
|
| 130 |
+
### **`RuntimeError: Security manager failed to initialize...`**
|
| 131 |
+
|
| 132 |
+
- **Cause:** The `main.py` script failed to initialize one of the core services (`user_handler`, `encryption`, or `access_control`) before it was passed to the `Security` class.
|
| 133 |
+
- **Solution:** Check the log messages from `main.py`. Look for "failed to initialize" messages from the individual fundament modules. Ensure your `.env` file and database are correctly configured.
|
| 134 |
+
|
| 135 |
+
### **`ValueError: Invalid data format` or `InvalidTag` (from Encryption)**
|
| 136 |
+
|
| 137 |
+
- **Cause:** An attempt was made to decrypt data with the wrong key, nonce, or tag. This could be due to data corruption or a mismatch in the `MASTER_ENCRYPTION_KEY` or `PERSISTENT_ENCRYPTION_SALT`.
|
| 138 |
+
- **Solution:** Verify that the `MASTER_ENCRYPTION_KEY` and `PERSISTENT_ENCRYPTION_SALT` environment variables are identical in your application and the environment where the data was originally encrypted.
|
| 139 |
+
|
| 140 |
+
-----
|
| 141 |
+
|
| 142 |
+
## Quick Start for Application Integration
|
| 143 |
+
|
| 144 |
+
### 1\. **Access the Service**
|
| 145 |
+
|
| 146 |
+
The `Security` service is provided by `main.py` via the `fundaments` dictionary.
|
| 147 |
+
|
| 148 |
+
```python
|
| 149 |
+
from fundaments.security import Security
|
| 150 |
+
|
| 151 |
+
async def start_application(fundaments: dict):
|
| 152 |
+
security_service: Security = fundaments["security"]
|
| 153 |
+
...
|
| 154 |
+
```
|
| 155 |
+
|
| 156 |
+
### 2\. **Secure Login and Session**
|
| 157 |
+
|
| 158 |
+
```python
|
| 159 |
+
request_data = {
|
| 160 |
+
'ip_address': '192.168.1.1',
|
| 161 |
+
'user_agent': 'Mozilla/5.0...'
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
# The single call handles all security aspects of login
|
| 165 |
+
login_successful = await security_service.user_login(
|
| 166 |
+
"dev@example.com", "my_secret_pass", request_data
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if login_successful:
|
| 170 |
+
print("User is securely logged in!")
|
| 171 |
+
else:
|
| 172 |
+
print("Login failed, account might be locked.")
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
### 3\. **Secure Data and Access**
|
| 176 |
+
|
| 177 |
+
```python
|
| 178 |
+
# Encrypt sensitive data before storing it
|
| 179 |
+
encrypted_credentials = security_service.encrypt_data("SensitiveToken123")
|
| 180 |
+
|
| 181 |
+
# Check if the user has a specific permission
|
| 182 |
+
if await security_service.check_permission(user_id, "can_manage_users"):
|
| 183 |
+
print("User is authorized.")
|
| 184 |
+
```
|
| 185 |
+
|
| 186 |
+
-----
|
| 187 |
+
|
| 188 |
+
## Conclusion
|
| 189 |
+
|
| 190 |
+
The `Security` class is the culmination of our fundamental security principles. It elevates your application's security by:
|
| 191 |
+
|
| 192 |
+
1. **Providing a Unified API:** Eliminates the risk of scattered security logic.
|
| 193 |
+
2. **Encapsulating Complexity:** Hides the multi-step security processes from the core application.
|
| 194 |
+
3. **Enforcing Best Practices:** Guarantees that every security action, like a user login, follows a hardened, production-ready routine.
|
| 195 |
+
|
| 196 |
+
**Result:** A clean, auditable, and secure application that allows developers to focus on features, confident that the security layer is doing its job.
|
docs/user_handler.py.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Secure User Authentication and Session Management
|
| 2 |
+
|
| 3 |
+
### Overview
|
| 4 |
+
|
| 5 |
+
This module, `user_handler.py`, is a standalone component for handling user authentication, session management, and security. It provides a secure, yet easy-to-use, system for managing user logins and protecting against common attacks like brute-force attempts and session fixation.
|
| 6 |
+
|
| 7 |
+
It is designed to be a direct Python equivalent of a user management class you might find in a web application built with a traditional framework, offering similar functionality and a focus on security.
|
| 8 |
+
|
| 9 |
+
-----
|
| 10 |
+
|
| 11 |
+
### Core Security Features
|
| 12 |
+
|
| 13 |
+
- **Password Hashing:** Passwords are not stored in plain text. The module uses `passlib` with `pbkdf2_sha256` for robust, salted password hashing, making it nearly impossible to retrieve the original password from the database.
|
| 14 |
+
- **Session Fixation Prevention:** The `login` method regenerates the session ID after a successful authentication, ensuring that an attacker cannot hijack a pre-existing session.
|
| 15 |
+
- **Brute-Force Protection:** The system tracks failed login attempts. After a configurable number of failures (e.g., 5 attempts), it automatically locks the user's account to prevent further brute-force attacks.
|
| 16 |
+
- **Session Validation:** Sessions are not just validated by an ID. The module also checks the user's IP address and user agent to ensure the session hasn't been hijacked.
|
| 17 |
+
- **Data Storage:** A simple `SQLite` database is used as a placeholder. In a production environment, this would be replaced by a more robust and scalable solution like `PostgreSQL`.
|
| 18 |
+
|
| 19 |
+
-----
|
| 20 |
+
|
| 21 |
+
### Module Components
|
| 22 |
+
|
| 23 |
+
1. **`Database` Class (Placeholder):**
|
| 24 |
+
A simple wrapper for SQLite to simulate database interactions. This is where you would integrate a proper ORM or a more powerful database driver in a production application.
|
| 25 |
+
|
| 26 |
+
2. **`Security` Class:**
|
| 27 |
+
A static class responsible for core security functions. It handles password hashing and verification using `passlib` and includes a method to simulate session ID regeneration.
|
| 28 |
+
|
| 29 |
+
3. **`UserHandler` Class:**
|
| 30 |
+
The main class for handling user-related logic. It contains methods for:
|
| 31 |
+
|
| 32 |
+
- `login(username, password, request_data)`: Verifies user credentials and establishes a secure session.
|
| 33 |
+
- `logout()`: Terminates the user's session.
|
| 34 |
+
- `is_logged_in()`: Checks if a user has an active session.
|
| 35 |
+
- `is_admin()`: Determines if the logged-in user has administrator privileges.
|
| 36 |
+
- `validate_session()`: Checks if the session is valid based on request details.
|
| 37 |
+
- `lock_account()`: Manually locks a user's account.
|
| 38 |
+
- `increment_failed_attempts()`: Increments the failed login counter and locks the account if a threshold is reached.
|
| 39 |
+
|
| 40 |
+
-----
|
| 41 |
+
|
| 42 |
+
### Example Usage
|
| 43 |
+
|
| 44 |
+
The `if __name__ == "__main__":` block at the end of the file provides a complete example of how to use the module:
|
| 45 |
+
|
| 46 |
+
1. **Setup:** Initializes the database and creates the necessary tables.
|
| 47 |
+
2. **User Registration:** Demonstrates how to create a regular user and an admin user with securely hashed passwords.
|
| 48 |
+
3. **Successful Login:** Shows a successful login attempt, which creates a new session.
|
| 49 |
+
4. **Logout:** Illustrates how to terminate the session.
|
| 50 |
+
5. **Brute-Force Protection Test:** Simulates multiple failed login attempts to demonstrate the account-locking mechanism.
|
| 51 |
+
6. **Account Reset:** Shows how to manually reset failed attempts to re-enable an account.
|
| 52 |
+
|
| 53 |
+
This module provides a robust and well-documented foundation for building a secure and reliable user authentication system.
|
example-mcp___.env
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ===========================================
|
| 2 |
+
# MCP HUB CONFIGURATION
|
| 3 |
+
# ===========================================
|
| 4 |
+
|
| 5 |
+
# App-Modus: 'mcp' oder 'app'
|
| 6 |
+
APP_MODE="mcp"
|
| 7 |
+
|
| 8 |
+
# Transport: 'stdio' (lokal/Claude Desktop) oder 'sse' (HuggingFace/Remote)
|
| 9 |
+
MCP_TRANSPORT="stdio"
|
| 10 |
+
|
| 11 |
+
# Für SSE/HuggingFace Spaces (PORT muss 7860 sein!)
|
| 12 |
+
PORT="7860"
|
| 13 |
+
HOST="0.0.0.0"
|
| 14 |
+
|
| 15 |
+
# Optional: Die App-URL für OpenRouter HTTP-Referer
|
| 16 |
+
APP_URL="https://huggingface.co/spaces/DEIN_USERNAME/DEIN_SPACE"
|
| 17 |
+
|
| 18 |
+
# ===========================================
|
| 19 |
+
# LLM API KEYS (optional - aktiviert Tools)
|
| 20 |
+
# ===========================================
|
| 21 |
+
# Nur konfigurierte Keys aktivieren ihre Tools - kein Key, kein Tool, kein Crash.
|
| 22 |
+
|
| 23 |
+
#ANTHROPIC_API_KEY="sk-ant-..."
|
| 24 |
+
#OPENROUTER_API_KEY="sk-or-..."
|
| 25 |
+
#HF_TOKEN="hf_..."
|
| 26 |
+
|
| 27 |
+
# ===========================================
|
| 28 |
+
# SEARCH API KEYS (optional)
|
| 29 |
+
# ===========================================
|
| 30 |
+
|
| 31 |
+
#BRAVE_API_KEY="BSA..."
|
| 32 |
+
#TAVILY_API_KEY="tvly-..."
|
| 33 |
+
|
| 34 |
+
# ===========================================
|
| 35 |
+
# MCP HUB CONFIGURATION END
|
| 36 |
+
# ===========================================
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# .env.example
|
| 40 |
+
# This file serves as a template for your .env file.
|
| 41 |
+
# Copy this file to a new file named .env and fill in the values.
|
| 42 |
+
# IMPORTANT: Never commit the .env file to version control.
|
| 43 |
+
|
| 44 |
+
# ===========================================
|
| 45 |
+
# CORE CONFIGURATION
|
| 46 |
+
# ===========================================
|
| 47 |
+
|
| 48 |
+
# --- Database Connection ---
|
| 49 |
+
# The complete Data Source Name (DSN) for your PostgreSQL database.
|
| 50 |
+
# This works for both local instances and cloud providers like Neon.tech.
|
| 51 |
+
# Example for Neon: postgresql://user:password@host.eu-central-1.aws.neon.tech/neondb?sslmode=require
|
| 52 |
+
# Example for local: postgresql://user:password@localhost:5432/mydb
|
| 53 |
+
# Leave empty or comment out if your app doesn't need database
|
| 54 |
+
DATABASE_URL="your_database_dsn_here"
|
| 55 |
+
|
| 56 |
+
# --- Application-Level Encryption Keys ---
|
| 57 |
+
# These keys are used by the application itself to encrypt sensitive data
|
| 58 |
+
# before it is written to the database. This provides an additional layer
|
| 59 |
+
# of security for your data.
|
| 60 |
+
# Comment out both lines if your app doesn't need encryption
|
| 61 |
+
# The master key must be a 256-bit key (32 bytes).
|
| 62 |
+
MASTER_ENCRYPTION_KEY="your_256_bit_key_here"
|
| 63 |
+
# The salt is a unique, persistent value used with the master key.
|
| 64 |
+
# It should also be a secure, random string.
|
| 65 |
+
PERSISTENT_ENCRYPTION_SALT="your_unique_salt_here"
|
| 66 |
+
|
| 67 |
+
# ===========================================
|
| 68 |
+
# LOGGING CONFIGURATION (Optional)
|
| 69 |
+
# ===========================================
|
| 70 |
+
|
| 71 |
+
# PyFundaments Debug Mode (true/false)
|
| 72 |
+
PYFUNDAMENTS_DEBUG="true"
|
| 73 |
+
|
| 74 |
+
# Log level: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
| 75 |
+
LOG_LEVEL="DEBUG"
|
| 76 |
+
|
| 77 |
+
# Store logs in /tmp directory (cleared on restart)
|
| 78 |
+
LOG_TO_TMP="false"
|
| 79 |
+
|
| 80 |
+
# Enable public logging (disable for production security)
|
| 81 |
+
ENABLE_PUBLIC_LOGS="true"
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# ===========================================
|
| 85 |
+
# APP-SPECIFIC CONFIGURATION EXAMPLES
|
| 86 |
+
# ===========================================
|
| 87 |
+
# Uncomment and configure based on your application type
|
| 88 |
+
|
| 89 |
+
# Discord Bot Setup
|
| 90 |
+
# ==================
|
| 91 |
+
# Get these from Discord Developer Portal
|
| 92 |
+
#PUBLIC_KEY="your-discord-public-key"
|
| 93 |
+
#APPLICATION_ID="your-discord-app-id"
|
| 94 |
+
#BOT_TOKEN="your-bot-token"
|
| 95 |
+
#GUILD_ID="your-guild-id"
|
| 96 |
+
#BOT_VERSION="1.0.1"
|
| 97 |
+
#ADMIN_USER_IDS="user1,user2,user3"
|
| 98 |
+
# ===========================================
|
| 99 |
+
# API Server CONFIGURATION EXAMPLES
|
| 100 |
+
# ===========================================
|
| 101 |
+
#Flask server
|
| 102 |
+
#PORT="7860"
|
| 103 |
+
|
| 104 |
+
# ML/LLM Setup
|
| 105 |
+
# =============
|
| 106 |
+
#MODEL_PATH="/path/to/your/model"
|
| 107 |
+
#MAX_TOKENS="2048"
|
| 108 |
+
#TEMPERATURE="0.7"
|
| 109 |
+
|
| 110 |
+
# Web Server Setup
|
| 111 |
+
# ================
|
| 112 |
+
#PORT="8000"
|
| 113 |
+
#HOST="0.0.0.0"
|
| 114 |
+
#DEBUG_MODE="false"
|
| 115 |
+
|
| 116 |
+
# API Keys
|
| 117 |
+
# ========
|
| 118 |
+
#OPENAI_API_KEY="your-openai-key"
|
| 119 |
+
#ANTHROPIC_API_KEY="your-anthropic-key"
|
example.Dockerfile
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Use an official Python runtime as a parent image.
|
| 2 |
+
# We choose a slim version to keep the image size small.
|
| 3 |
+
FROM python:3.10-slim
|
| 4 |
+
|
| 5 |
+
# Set the working directory in the container.
|
| 6 |
+
# All subsequent commands will be executed in this directory.
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
# Copy the requirements file into the container.
|
| 10 |
+
# We do this first to leverage Docker's layer caching.
|
| 11 |
+
# If requirements.txt doesn't change, this step is skipped.
|
| 12 |
+
COPY requirements.txt ./
|
| 13 |
+
|
| 14 |
+
# Install any needed packages specified in requirements.txt.
|
| 15 |
+
# The --no-cache-dir flag helps to keep the image smaller.
|
| 16 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 17 |
+
|
| 18 |
+
# Copy the rest of the application code into the working directory.
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
# Expose a port if your application is a web server.
|
| 22 |
+
# For example, if your application runs on port 8000.
|
| 23 |
+
# EXPOSE 8000
|
| 24 |
+
|
| 25 |
+
# Define the command to run your application.
|
| 26 |
+
# This command will be executed when the container starts.
|
| 27 |
+
# We use main.py as the entry point, as per our architecture.
|
| 28 |
+
CMD ["python", "main.py"]
|
fundaments/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
fundaments/access_control.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# access_control.py
|
| 6 |
+
# This is the standalone module for access control.
|
| 7 |
+
# It is intended to be imported by main.py or app.py.
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from typing import Optional, List, Dict, Any
|
| 11 |
+
|
| 12 |
+
# Your foundational PostgreSQL module is imported here.
|
| 13 |
+
# IMPORTANT: Ensure 'postgresql.py' is in the same directory or accessible
|
| 14 |
+
# via the Python path.
|
| 15 |
+
from fundaments import postgresql as db
|
| 16 |
+
# local db use: db.execute_secured_query(), db.init_db_pool(), etc. but this fundaments are optimized for clouds
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# If asyncpg is not installed, exit the script gracefully.
|
| 20 |
+
try:
|
| 21 |
+
import asyncpg
|
| 22 |
+
except ImportError:
|
| 23 |
+
print("Error: The 'asyncpg' library is required. Please install it with 'pip install asyncpg'.")
|
| 24 |
+
sys.exit(1)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class AccessControl:
|
| 28 |
+
"""
|
| 29 |
+
Asynchronous class for managing access control.
|
| 30 |
+
It builds directly on the functions of your secure database module.
|
| 31 |
+
This is the layer that uses the foundation without modifying it.
|
| 32 |
+
|
| 33 |
+
How to extend this class:
|
| 34 |
+
To add new functionality, simply create a new asynchronous method
|
| 35 |
+
within this class. This method can call the `db.execute_secured_query`
|
| 36 |
+
function to interact with the database. For example:
|
| 37 |
+
|
| 38 |
+
async def get_user_last_login(self):
|
| 39 |
+
sql = "SELECT last_login FROM users WHERE id = $1"
|
| 40 |
+
result = await db.execute_secured_query(sql, self.user_id, fetch_method='fetchrow')
|
| 41 |
+
return result['last_login'] if result else None
|
| 42 |
+
"""
|
| 43 |
+
def __init__(self, user_id: Optional[int] = None):
|
| 44 |
+
self.user_id = user_id
|
| 45 |
+
|
| 46 |
+
async def has_permission(self, permission_name: str) -> bool:
|
| 47 |
+
"""
|
| 48 |
+
Checks if the user has a specific permission.
|
| 49 |
+
Uses the secure execute_secured_query function from the foundation.
|
| 50 |
+
"""
|
| 51 |
+
if self.user_id is None:
|
| 52 |
+
return False
|
| 53 |
+
|
| 54 |
+
# SQL Query Explanation:
|
| 55 |
+
# This query counts the number of rows where the user (user_id)
|
| 56 |
+
# has a role (user_role_assignments) that is linked to a permission
|
| 57 |
+
# (role_permissions) which has the specified name (user_permissions.name).
|
| 58 |
+
# If the count is > 0, the user has the permission.
|
| 59 |
+
sql = """
|
| 60 |
+
SELECT COUNT(*) AS count
|
| 61 |
+
FROM user_role_assignments ura
|
| 62 |
+
JOIN role_permissions rp ON ura.role_id = rp.role_id
|
| 63 |
+
JOIN user_permissions up ON rp.permission_id = up.id
|
| 64 |
+
WHERE ura.user_id = $1 AND up.name = $2
|
| 65 |
+
"""
|
| 66 |
+
try:
|
| 67 |
+
result = await db.execute_secured_query(
|
| 68 |
+
sql,
|
| 69 |
+
self.user_id,
|
| 70 |
+
permission_name,
|
| 71 |
+
fetch_method='fetchrow'
|
| 72 |
+
)
|
| 73 |
+
return result['count'] > 0
|
| 74 |
+
except Exception as e:
|
| 75 |
+
# Error handling is managed by your postgresql module.
|
| 76 |
+
# We just re-raise the exception here to propagate it.
|
| 77 |
+
raise Exception(f'Failed to check permission: {e}')
|
| 78 |
+
|
| 79 |
+
async def get_user_permissions(self) -> List[Dict[str, Any]]:
|
| 80 |
+
"""Returns all permissions for a user."""
|
| 81 |
+
if self.user_id is None:
|
| 82 |
+
return []
|
| 83 |
+
|
| 84 |
+
# SQL Query Explanation:
|
| 85 |
+
# This query retrieves all distinct permission names and descriptions
|
| 86 |
+
# associated with the user's roles. `DISTINCT` ensures each permission
|
| 87 |
+
# is listed only once.
|
| 88 |
+
sql = """
|
| 89 |
+
SELECT DISTINCT up.name, up.description
|
| 90 |
+
FROM user_role_assignments ura
|
| 91 |
+
JOIN role_permissions rp ON ura.role_id = rp.role_id
|
| 92 |
+
JOIN user_permissions up ON rp.permission_id = up.id
|
| 93 |
+
WHERE ura.user_id = $1
|
| 94 |
+
ORDER BY up.name
|
| 95 |
+
"""
|
| 96 |
+
try:
|
| 97 |
+
return await db.execute_secured_query(sql, self.user_id)
|
| 98 |
+
except Exception as e:
|
| 99 |
+
raise Exception(f'Failed to get user permissions: {e}')
|
| 100 |
+
|
| 101 |
+
async def get_user_roles(self) -> List[Dict[str, Any]]:
|
| 102 |
+
"""Returns all roles for a user."""
|
| 103 |
+
if self.user_id is None:
|
| 104 |
+
return []
|
| 105 |
+
|
| 106 |
+
# SQL Query Explanation:
|
| 107 |
+
# This query selects the details (id, name, description) of the roles
|
| 108 |
+
# that are assigned to the specified user (user_id) in the
|
| 109 |
+
# `user_role_assignments` table.
|
| 110 |
+
sql = """
|
| 111 |
+
SELECT r.id, r.name, r.description
|
| 112 |
+
FROM user_role_assignments ura
|
| 113 |
+
JOIN user_roles r ON ura.role_id = r.id
|
| 114 |
+
WHERE ura.user_id = $1
|
| 115 |
+
ORDER BY r.name
|
| 116 |
+
"""
|
| 117 |
+
try:
|
| 118 |
+
return await db.execute_secured_query(sql, self.user_id)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
raise Exception(f'Failed to get user roles: {e}')
|
| 121 |
+
|
| 122 |
+
async def assign_role(self, role_id: int) -> None:
|
| 123 |
+
"""Assigns a role to a user."""
|
| 124 |
+
if self.user_id is None:
|
| 125 |
+
raise Exception('No user specified')
|
| 126 |
+
|
| 127 |
+
# SQL Query Explanation:
|
| 128 |
+
# Inserts a new row into the `user_role_assignments` table to create
|
| 129 |
+
# the relationship between a user and a role.
|
| 130 |
+
sql = "INSERT INTO user_role_assignments (user_id, role_id) VALUES ($1, $2)"
|
| 131 |
+
try:
|
| 132 |
+
await db.execute_secured_query(
|
| 133 |
+
sql,
|
| 134 |
+
self.user_id,
|
| 135 |
+
role_id,
|
| 136 |
+
fetch_method='execute'
|
| 137 |
+
)
|
| 138 |
+
except Exception as e:
|
| 139 |
+
raise Exception(f'Failed to assign role: {e}')
|
| 140 |
+
|
| 141 |
+
async def remove_role(self, role_id: int) -> None:
|
| 142 |
+
"""Removes a role from a user."""
|
| 143 |
+
if self.user_id is None:
|
| 144 |
+
raise Exception('No user specified')
|
| 145 |
+
|
| 146 |
+
# SQL Query Explanation:
|
| 147 |
+
# Deletes the row from the `user_role_assignments` table that matches
|
| 148 |
+
# the specified user ($1) and role ($2).
|
| 149 |
+
sql = "DELETE FROM user_role_assignments WHERE user_id = $1 AND role_id = $2"
|
| 150 |
+
try:
|
| 151 |
+
await db.execute_secured_query(
|
| 152 |
+
sql,
|
| 153 |
+
self.user_id,
|
| 154 |
+
role_id,
|
| 155 |
+
fetch_method='execute'
|
| 156 |
+
)
|
| 157 |
+
except Exception as e:
|
| 158 |
+
raise Exception(f'Failed to remove role: {e}')
|
| 159 |
+
|
| 160 |
+
async def get_all_roles(self) -> List[Dict[str, Any]]:
|
| 161 |
+
"""Returns all available roles."""
|
| 162 |
+
# SQL Query Explanation:
|
| 163 |
+
# Selects all roles from the `user_roles` table.
|
| 164 |
+
sql = "SELECT id, name, description FROM user_roles ORDER BY name"
|
| 165 |
+
try:
|
| 166 |
+
return await db.execute_secured_query(sql)
|
| 167 |
+
except Exception as e:
|
| 168 |
+
raise Exception(f'Failed to get roles: {e}')
|
| 169 |
+
|
| 170 |
+
async def get_all_permissions(self) -> List[Dict[str, Any]]:
|
| 171 |
+
"""Returns all available permissions."""
|
| 172 |
+
# SQL Query Explanation:
|
| 173 |
+
# Selects all permissions from the `user_permissions` table.
|
| 174 |
+
sql = "SELECT id, name, description FROM user_permissions ORDER BY name"
|
| 175 |
+
try:
|
| 176 |
+
return await db.execute_secured_query(sql)
|
| 177 |
+
except Exception as e:
|
| 178 |
+
raise Exception(f'Failed to get permissions: {e}')
|
| 179 |
+
|
| 180 |
+
async def create_role(self, name: str, description: str) -> int:
|
| 181 |
+
"""Creates a new role."""
|
| 182 |
+
# SQL Query Explanation:
|
| 183 |
+
# Inserts a new role into the `user_roles` table and returns the
|
| 184 |
+
# automatically generated ID of the new role (`RETURNING id`).
|
| 185 |
+
sql = "INSERT INTO user_roles (name, description) VALUES ($1, $2) RETURNING id"
|
| 186 |
+
try:
|
| 187 |
+
result = await db.execute_secured_query(
|
| 188 |
+
sql,
|
| 189 |
+
name,
|
| 190 |
+
description,
|
| 191 |
+
fetch_method='fetchrow'
|
| 192 |
+
)
|
| 193 |
+
return result['id']
|
| 194 |
+
except Exception as e:
|
| 195 |
+
raise Exception(f'Failed to create role: {e}')
|
| 196 |
+
|
| 197 |
+
async def update_role_permissions(self, role_id: int, permission_ids: List[int]) -> None:
|
| 198 |
+
"""Updates the permissions for a role."""
|
| 199 |
+
# IMPORTANT: Since your module does not handle transactions across multiple
|
| 200 |
+
# queries, we perform these actions sequentially. Query-level security
|
| 201 |
+
# is guaranteed by your module.
|
| 202 |
+
try:
|
| 203 |
+
# SQL Query Explanation:
|
| 204 |
+
# Deletes all existing permissions for the given role.
|
| 205 |
+
sql_delete = "DELETE FROM role_permissions WHERE role_id = $1"
|
| 206 |
+
await db.execute_secured_query(sql_delete, role_id, fetch_method='execute')
|
| 207 |
+
|
| 208 |
+
# SQL Query Explanation:
|
| 209 |
+
# Inserts a new row for each permission_id passed into the
|
| 210 |
+
# `role_permissions` table.
|
| 211 |
+
if permission_ids:
|
| 212 |
+
sql_insert = "INSERT INTO role_permissions (role_id, permission_id) VALUES ($1, $2)"
|
| 213 |
+
for permission_id in permission_ids:
|
| 214 |
+
await db.execute_secured_query(
|
| 215 |
+
sql_insert,
|
| 216 |
+
role_id,
|
| 217 |
+
permission_id,
|
| 218 |
+
fetch_method='execute'
|
| 219 |
+
)
|
| 220 |
+
except Exception as e:
|
| 221 |
+
# If an error occurs, the underlying foundation will log the issue.
|
| 222 |
+
# We re-raise the error here.
|
| 223 |
+
raise Exception(f'Failed to update role permissions: {e}')
|
| 224 |
+
|
| 225 |
+
async def get_role_permissions(self, role_id: int) -> List[Dict[str, Any]]:
|
| 226 |
+
"""Returns all permissions for a role."""
|
| 227 |
+
# SQL Query Explanation:
|
| 228 |
+
# Selects the details (id, name, description) of all permissions
|
| 229 |
+
# linked to the specified role.
|
| 230 |
+
sql = """
|
| 231 |
+
SELECT p.id, p.name, p.description
|
| 232 |
+
FROM role_permissions rp
|
| 233 |
+
JOIN user_permissions p ON rp.permission_id = p.id
|
| 234 |
+
WHERE rp.role_id = $1
|
| 235 |
+
ORDER BY p.name
|
| 236 |
+
"""
|
| 237 |
+
try:
|
| 238 |
+
return await db.execute_secured_query(sql, role_id)
|
| 239 |
+
except Exception as e:
|
| 240 |
+
raise Exception(f'Failed to get role permissions: {e}')
|
fundaments/config_handler.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# fundaments/config_handler.py
|
| 6 |
+
# This module loads environment variables without validation.
|
| 7 |
+
# Validation is handled by main.py based on required services.
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from typing import Optional, Dict, Any
|
| 12 |
+
|
| 13 |
+
class ConfigHandler:
|
| 14 |
+
"""
|
| 15 |
+
A universal configuration loader that reads all environment variables
|
| 16 |
+
without imposing requirements. This ensures the config handler never
|
| 17 |
+
needs updates regardless of what services are used.
|
| 18 |
+
|
| 19 |
+
Validation is delegated to main.py which knows what services are needed.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
def __init__(self):
|
| 23 |
+
"""
|
| 24 |
+
Loads all environment variables from .env file and system environment.
|
| 25 |
+
No validation is performed - that's main.py's responsibility.
|
| 26 |
+
"""
|
| 27 |
+
load_dotenv()
|
| 28 |
+
self.config = {}
|
| 29 |
+
self.load_all_config()
|
| 30 |
+
|
| 31 |
+
def load_all_config(self):
|
| 32 |
+
"""
|
| 33 |
+
Loads all available environment variables into the config dictionary.
|
| 34 |
+
No validation - main.py decides what's required based on enabled services.
|
| 35 |
+
"""
|
| 36 |
+
# Load all environment variables
|
| 37 |
+
for key, value in os.environ.items():
|
| 38 |
+
if value: # Only store non-empty values
|
| 39 |
+
self.config[key] = value
|
| 40 |
+
|
| 41 |
+
def get(self, key: str) -> Optional[str]:
|
| 42 |
+
"""
|
| 43 |
+
Retrieves a configuration value by key.
|
| 44 |
+
Returns None if the key doesn't exist.
|
| 45 |
+
|
| 46 |
+
Args:
|
| 47 |
+
key: The environment variable name
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
The value as string or None if not found
|
| 51 |
+
"""
|
| 52 |
+
return self.config.get(key)
|
| 53 |
+
|
| 54 |
+
def get_bool(self, key: str, default: bool = False) -> bool:
|
| 55 |
+
"""
|
| 56 |
+
Retrieves a boolean configuration value.
|
| 57 |
+
Recognizes: true, false, 1, 0, yes, no (case insensitive)
|
| 58 |
+
|
| 59 |
+
Args:
|
| 60 |
+
key: The environment variable name
|
| 61 |
+
default: Default value if key not found
|
| 62 |
+
|
| 63 |
+
Returns:
|
| 64 |
+
Boolean value
|
| 65 |
+
"""
|
| 66 |
+
value = self.get(key)
|
| 67 |
+
if value is None:
|
| 68 |
+
return default
|
| 69 |
+
|
| 70 |
+
return value.lower() in ('true', '1', 'yes', 'on')
|
| 71 |
+
|
| 72 |
+
def get_int(self, key: str, default: int = 0) -> int:
|
| 73 |
+
"""
|
| 74 |
+
Retrieves an integer configuration value.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
key: The environment variable name
|
| 78 |
+
default: Default value if key not found or invalid
|
| 79 |
+
|
| 80 |
+
Returns:
|
| 81 |
+
Integer value
|
| 82 |
+
"""
|
| 83 |
+
value = self.get(key)
|
| 84 |
+
if value is None:
|
| 85 |
+
return default
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
return int(value)
|
| 89 |
+
except ValueError:
|
| 90 |
+
return default
|
| 91 |
+
|
| 92 |
+
def has(self, key: str) -> bool:
|
| 93 |
+
"""
|
| 94 |
+
Checks if a configuration key exists and has a non-empty value.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
key: The environment variable name
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
True if key exists and has value, False otherwise
|
| 101 |
+
"""
|
| 102 |
+
return key in self.config and bool(self.config[key])
|
| 103 |
+
|
| 104 |
+
def get_all(self) -> Dict[str, str]:
|
| 105 |
+
"""
|
| 106 |
+
Returns all loaded configuration as a dictionary.
|
| 107 |
+
Useful for debugging or passing to other services.
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
Dictionary of all loaded environment variables
|
| 111 |
+
"""
|
| 112 |
+
return self.config.copy()
|
| 113 |
+
|
| 114 |
+
# Global singleton instance
|
| 115 |
+
config_service = ConfigHandler()
|
fundaments/debug.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import importlib.util
|
| 4 |
+
import datetime
|
| 5 |
+
import logging
|
| 6 |
+
from logging.handlers import RotatingFileHandler
|
| 7 |
+
|
| 8 |
+
class PyFundamentsDebug:
|
| 9 |
+
"""
|
| 10 |
+
Debug-Klasse für PyFundaments.
|
| 11 |
+
Liest ENV-Variablen und konfiguriert Logging.
|
| 12 |
+
Gibt Startup-Infos aus, wenn PYFUNDAMENTS_DEBUG=true.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
# Debug aktivieren mit ENV VAR (default: False)
|
| 17 |
+
self.enabled = os.getenv("PYFUNDAMENTS_DEBUG", "false").lower() == "true"
|
| 18 |
+
|
| 19 |
+
# Log-Level aus ENV lesen, default INFO
|
| 20 |
+
log_level_str = os.getenv("LOG_LEVEL", "INFO").upper()
|
| 21 |
+
self.log_level = getattr(logging, log_level_str, logging.INFO)
|
| 22 |
+
|
| 23 |
+
# Logs in /tmp oder stdout? (default False)
|
| 24 |
+
self.log_to_tmp = os.getenv("LOG_TO_TMP", "false").lower() == "true"
|
| 25 |
+
|
| 26 |
+
# Öffentliches Logging an/aus (default True)
|
| 27 |
+
self.enable_public_logs = os.getenv("ENABLE_PUBLIC_LOGS", "true").lower() == "true"
|
| 28 |
+
|
| 29 |
+
# Logger Setup
|
| 30 |
+
self.logger = logging.getLogger('pyfundaments_debug')
|
| 31 |
+
self._setup_logger()
|
| 32 |
+
|
| 33 |
+
def _setup_logger(self):
|
| 34 |
+
if not self.enable_public_logs:
|
| 35 |
+
# Nur kritische Fehler im Log, wenn deaktiviert
|
| 36 |
+
logging.basicConfig(level=logging.CRITICAL)
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
handlers = []
|
| 40 |
+
if self.log_to_tmp:
|
| 41 |
+
log_file = '/tmp/pyfundaments_debug.log'
|
| 42 |
+
file_handler = RotatingFileHandler(log_file, maxBytes=1024*1024, backupCount=3)
|
| 43 |
+
handlers.append(file_handler)
|
| 44 |
+
handlers.append(logging.StreamHandler(sys.stdout))
|
| 45 |
+
|
| 46 |
+
logging.basicConfig(
|
| 47 |
+
level=self.log_level,
|
| 48 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 49 |
+
handlers=handlers
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
def run(self):
|
| 53 |
+
if not self.enabled:
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
self.logger.info(f"==== PYFUNDAMENTS DEBUG STARTUP ({datetime.datetime.now()}) ====")
|
| 57 |
+
self.logger.info(f"Python version: {sys.version}")
|
| 58 |
+
self.logger.info(f"CWD: {os.getcwd()}")
|
| 59 |
+
self.logger.info(f"sys.path: {sys.path}")
|
| 60 |
+
|
| 61 |
+
fund_dir = "fundaments"
|
| 62 |
+
self.logger.info(f"fundaments exists: {os.path.isdir(fund_dir)}")
|
| 63 |
+
|
| 64 |
+
required_files = [
|
| 65 |
+
"__init__.py",
|
| 66 |
+
"access_control.py",
|
| 67 |
+
"postgresql.py",
|
| 68 |
+
"config_handler.py",
|
| 69 |
+
"security.py",
|
| 70 |
+
"user_handler.py",
|
| 71 |
+
"encryption.py"
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
for file in required_files:
|
| 75 |
+
path = os.path.join(fund_dir, file)
|
| 76 |
+
exists = os.path.isfile(path)
|
| 77 |
+
readable = os.access(path, os.R_OK)
|
| 78 |
+
self.logger.info(f"{file} exists: {exists} | readable: {readable}")
|
| 79 |
+
|
| 80 |
+
if os.path.isdir(fund_dir):
|
| 81 |
+
self.logger.info(f"Listing fundaments contents: {os.listdir(fund_dir)}")
|
| 82 |
+
else:
|
| 83 |
+
self.logger.warning("fundaments folder not found.")
|
| 84 |
+
|
| 85 |
+
spec = importlib.util.find_spec("fundaments")
|
| 86 |
+
self.logger.info(f"fundaments importable: {spec is not None}")
|
| 87 |
+
|
| 88 |
+
conflicts = [mod for mod in sys.modules if mod == "fundaments"]
|
| 89 |
+
self.logger.info(f"Name conflict in sys.modules: {conflicts}")
|
| 90 |
+
|
| 91 |
+
self.logger.info("==== PYFUNDAMENTS DEBUG END ====")
|
fundaments/encryption.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# encryption.py
|
| 6 |
+
# A secure and robust encryption module using the cryptography library.
|
| 7 |
+
# This module is designed as a core component for a CMS architecture.
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import base64
|
| 12 |
+
import binascii
|
| 13 |
+
from typing import Dict, Union, Optional
|
| 14 |
+
|
| 15 |
+
# IMPORTANT: The cryptography library is required for this module.
|
| 16 |
+
# Please install it with 'pip install cryptography'.
|
| 17 |
+
try:
|
| 18 |
+
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
| 19 |
+
from cryptography.hazmat.primitives import hashes
|
| 20 |
+
from cryptography.hazmat.backends import default_backend
|
| 21 |
+
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
| 22 |
+
from cryptography.exceptions import InvalidTag
|
| 23 |
+
except ImportError:
|
| 24 |
+
print("Error: The 'cryptography' library is required. Please install it with 'pip install cryptography'.")
|
| 25 |
+
sys.exit(1)
|
| 26 |
+
|
| 27 |
+
class Encryption:
|
| 28 |
+
"""
|
| 29 |
+
A class for symmetric encryption and decryption using AES-256-GCM.
|
| 30 |
+
It securely handles both string data and file streaming.
|
| 31 |
+
|
| 32 |
+
This version is designed as a reusable core component for a larger application.
|
| 33 |
+
"""
|
| 34 |
+
CIPHER_NAME = 'AES-256-GCM'
|
| 35 |
+
KEY_LENGTH = 32 # 256 bits
|
| 36 |
+
NONCE_LENGTH = 12 # 96 bits, standard for GCM
|
| 37 |
+
TAG_LENGTH = 16 # 128 bits
|
| 38 |
+
SALT_LENGTH = 16
|
| 39 |
+
|
| 40 |
+
@staticmethod
|
| 41 |
+
def generate_salt() -> str:
|
| 42 |
+
"""
|
| 43 |
+
Generates a new, cryptographically secure random salt for key derivation.
|
| 44 |
+
This should be done once during application setup and stored securely.
|
| 45 |
+
|
| 46 |
+
Returns:
|
| 47 |
+
A hex-encoded string of the salt.
|
| 48 |
+
"""
|
| 49 |
+
return binascii.hexlify(os.urandom(Encryption.SALT_LENGTH)).decode('utf-8')
|
| 50 |
+
|
| 51 |
+
def __init__(self, master_key: str, salt: str):
|
| 52 |
+
"""
|
| 53 |
+
Initializes the encryption class by deriving a secure key from a master key.
|
| 54 |
+
The provided master_key and a persistent salt are used for key derivation.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
master_key: The string to be used as the master key.
|
| 58 |
+
salt: The hex-encoded string of the persistent salt.
|
| 59 |
+
|
| 60 |
+
Raises:
|
| 61 |
+
ValueError: If the provided salt is not a valid hex string or has an incorrect length.
|
| 62 |
+
"""
|
| 63 |
+
try:
|
| 64 |
+
salt_bytes = binascii.unhexlify(salt)
|
| 65 |
+
except binascii.Error:
|
| 66 |
+
raise ValueError("Invalid salt format. Must be a hex-encoded string.")
|
| 67 |
+
|
| 68 |
+
if len(salt_bytes) != self.SALT_LENGTH:
|
| 69 |
+
raise ValueError(f"Invalid salt length. Expected {self.SALT_LENGTH} bytes, got {len(salt_bytes)}.")
|
| 70 |
+
|
| 71 |
+
kdf = PBKDF2HMAC(
|
| 72 |
+
algorithm=hashes.SHA256(),
|
| 73 |
+
length=self.KEY_LENGTH,
|
| 74 |
+
salt=salt_bytes,
|
| 75 |
+
iterations=480000, # Recommended value for 2023+
|
| 76 |
+
backend=default_backend()
|
| 77 |
+
)
|
| 78 |
+
self.key = kdf.derive(master_key.encode('utf-8'))
|
| 79 |
+
|
| 80 |
+
def encrypt(self, data: str) -> Dict[str, str]:
|
| 81 |
+
"""
|
| 82 |
+
Encrypts a string using AES-256-GCM.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
data: The string to be encrypted.
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
A dictionary containing the base64-encoded ciphertext, hex-encoded IV/nonce,
|
| 89 |
+
and hex-encoded authentication tag.
|
| 90 |
+
"""
|
| 91 |
+
nonce = os.urandom(self.NONCE_LENGTH)
|
| 92 |
+
|
| 93 |
+
aesgcm = Cipher(
|
| 94 |
+
algorithms.AES(self.key),
|
| 95 |
+
modes.GCM(nonce),
|
| 96 |
+
backend=default_backend()
|
| 97 |
+
).encryptor()
|
| 98 |
+
|
| 99 |
+
encrypted_data = aesgcm.update(data.encode('utf-8')) + aesgcm.finalize()
|
| 100 |
+
tag = aesgcm.tag
|
| 101 |
+
|
| 102 |
+
return {
|
| 103 |
+
'data': base64.b64encode(encrypted_data).decode('utf-8'),
|
| 104 |
+
'nonce': binascii.hexlify(nonce).decode('utf-8'),
|
| 105 |
+
'tag': binascii.hexlify(tag).decode('utf-8')
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
def decrypt(self, encrypted_data: str, nonce: str, tag: str) -> str:
|
| 109 |
+
"""
|
| 110 |
+
Decrypts an AES-256-GCM encrypted string.
|
| 111 |
+
|
| 112 |
+
Args:
|
| 113 |
+
encrypted_data: The base64-encoded ciphertext.
|
| 114 |
+
nonce: The hex-encoded nonce/IV.
|
| 115 |
+
tag: The hex-encoded authentication tag.
|
| 116 |
+
|
| 117 |
+
Returns:
|
| 118 |
+
The decrypted plaintext string.
|
| 119 |
+
|
| 120 |
+
Raises:
|
| 121 |
+
ValueError: If nonce, tag, or data format is invalid.
|
| 122 |
+
InvalidTag: If the authentication tag fails validation.
|
| 123 |
+
"""
|
| 124 |
+
try:
|
| 125 |
+
nonce_bytes = binascii.unhexlify(nonce)
|
| 126 |
+
tag_bytes = binascii.unhexlify(tag)
|
| 127 |
+
cipher_bytes = base64.b64decode(encrypted_data)
|
| 128 |
+
except (binascii.Error, ValueError) as e:
|
| 129 |
+
raise ValueError(f'Invalid data format: {e}')
|
| 130 |
+
|
| 131 |
+
aesgcm = Cipher(
|
| 132 |
+
algorithms.AES(self.key),
|
| 133 |
+
modes.GCM(nonce_bytes, tag_bytes),
|
| 134 |
+
backend=default_backend()
|
| 135 |
+
).decryptor()
|
| 136 |
+
|
| 137 |
+
try:
|
| 138 |
+
decrypted_data = aesgcm.update(cipher_bytes) + aesgcm.finalize()
|
| 139 |
+
return decrypted_data.decode('utf-8')
|
| 140 |
+
except InvalidTag:
|
| 141 |
+
raise InvalidTag("Authentication tag validation failed. Data may be corrupted or key is incorrect.")
|
| 142 |
+
|
| 143 |
+
def encrypt_file(self, source_path: str, destination_path: str) -> Dict[str, str]:
|
| 144 |
+
"""
|
| 145 |
+
Encrypts a file using AES-256-GCM with a streaming approach.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
source_path: Path to the file to be encrypted.
|
| 149 |
+
destination_path: Path where the encrypted file will be saved.
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
A dictionary containing the hex-encoded IV/nonce and authentication tag.
|
| 153 |
+
"""
|
| 154 |
+
nonce = os.urandom(self.NONCE_LENGTH)
|
| 155 |
+
|
| 156 |
+
encryptor = Cipher(
|
| 157 |
+
algorithms.AES(self.key),
|
| 158 |
+
modes.GCM(nonce),
|
| 159 |
+
backend=default_backend()
|
| 160 |
+
).encryptor()
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
with open(source_path, 'rb') as fp_source, open(destination_path, 'wb') as fp_dest:
|
| 164 |
+
fp_dest.write(nonce)
|
| 165 |
+
|
| 166 |
+
chunk_size = 8192
|
| 167 |
+
while True:
|
| 168 |
+
chunk = fp_source.read(chunk_size)
|
| 169 |
+
if not chunk:
|
| 170 |
+
break
|
| 171 |
+
encrypted_chunk = encryptor.update(chunk)
|
| 172 |
+
fp_dest.write(encrypted_chunk)
|
| 173 |
+
|
| 174 |
+
encryptor.finalize()
|
| 175 |
+
tag = encryptor.tag
|
| 176 |
+
fp_dest.write(tag)
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
'nonce': binascii.hexlify(nonce).decode('utf-8'),
|
| 180 |
+
'tag': binascii.hexlify(tag).decode('utf-8')
|
| 181 |
+
}
|
| 182 |
+
except FileNotFoundError as e:
|
| 183 |
+
raise ValueError(f"File not found: {e.filename}") from e
|
| 184 |
+
except Exception as e:
|
| 185 |
+
raise IOError(f"File encryption failed: {e}") from e
|
| 186 |
+
|
| 187 |
+
def decrypt_file(self, source_path: str, destination_path: str) -> None:
|
| 188 |
+
"""
|
| 189 |
+
Decrypts an AES-256-GCM encrypted file.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
source_path: Path to the encrypted file.
|
| 193 |
+
destination_path: Path where the decrypted file will be saved.
|
| 194 |
+
"""
|
| 195 |
+
try:
|
| 196 |
+
with open(source_path, 'rb') as fp_source, open(destination_path, 'wb') as fp_dest:
|
| 197 |
+
nonce = fp_source.read(self.NONCE_LENGTH)
|
| 198 |
+
if len(nonce) != self.NONCE_LENGTH:
|
| 199 |
+
raise ValueError("Incomplete or invalid file format: Nonce is missing.")
|
| 200 |
+
|
| 201 |
+
fp_source.seek(-self.TAG_LENGTH, os.SEEK_END)
|
| 202 |
+
tag = fp_source.read(self.TAG_LENGTH)
|
| 203 |
+
if len(tag) != self.TAG_LENGTH:
|
| 204 |
+
raise ValueError("Incomplete or invalid file format: Tag is missing.")
|
| 205 |
+
|
| 206 |
+
fp_source.seek(self.NONCE_LENGTH, os.SEEK_SET) # Rewind to the start of the ciphertext
|
| 207 |
+
|
| 208 |
+
decryptor = Cipher(
|
| 209 |
+
algorithms.AES(self.key),
|
| 210 |
+
modes.GCM(nonce, tag),
|
| 211 |
+
backend=default_backend()
|
| 212 |
+
).decryptor()
|
| 213 |
+
|
| 214 |
+
chunk_size = 8192
|
| 215 |
+
encrypted_file_size = os.path.getsize(source_path) - self.NONCE_LENGTH - self.TAG_LENGTH
|
| 216 |
+
|
| 217 |
+
bytes_read = 0
|
| 218 |
+
while bytes_read < encrypted_file_size:
|
| 219 |
+
chunk_to_read = min(chunk_size, encrypted_file_size - bytes_read)
|
| 220 |
+
chunk = fp_source.read(chunk_to_read)
|
| 221 |
+
|
| 222 |
+
decrypted_chunk = decryptor.update(chunk)
|
| 223 |
+
fp_dest.write(decrypted_chunk)
|
| 224 |
+
bytes_read += len(chunk)
|
| 225 |
+
|
| 226 |
+
decryptor.finalize()
|
| 227 |
+
|
| 228 |
+
except InvalidTag as e:
|
| 229 |
+
raise IOError(f"File decryption failed. The authentication tag is invalid, suggesting the file was corrupted or tampered with. Error: {e}") from e
|
| 230 |
+
except FileNotFoundError as e:
|
| 231 |
+
raise ValueError(f"File not found: {e.filename}") from e
|
| 232 |
+
except Exception as e:
|
| 233 |
+
raise IOError(f"File decryption failed due to an unexpected error: {e}") from e
|
fundaments/postgresql.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# fundaments/postgresql.py
|
| 6 |
+
import os
|
| 7 |
+
import logging
|
| 8 |
+
import asyncpg
|
| 9 |
+
import ssl
|
| 10 |
+
from urllib.parse import urlparse, urlencode, parse_qs, urlunparse
|
| 11 |
+
from typing import Optional
|
| 12 |
+
|
| 13 |
+
logging.basicConfig(
|
| 14 |
+
level=logging.INFO,
|
| 15 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 16 |
+
)
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_db_pool: Optional[asyncpg.Pool] = None
|
| 20 |
+
|
| 21 |
+
def enforce_cloud_security(dsn_url: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Enforces security settings for cloud environments.
|
| 24 |
+
- Ensures SSL mode is at least 'require'
|
| 25 |
+
- Removes unsupported options for cloud providers (e.g. statement_timeout for Neon)
|
| 26 |
+
- Sets connect_timeout and keepalives_idle defaults
|
| 27 |
+
"""
|
| 28 |
+
parsed = urlparse(dsn_url)
|
| 29 |
+
query_params = parse_qs(parsed.query)
|
| 30 |
+
|
| 31 |
+
# Enforce SSL (at least 'require')
|
| 32 |
+
sslmode = query_params.get('sslmode', ['prefer'])[0].lower()
|
| 33 |
+
if sslmode not in ['require', 'verify-ca', 'verify-full']:
|
| 34 |
+
query_params['sslmode'] = ['require']
|
| 35 |
+
|
| 36 |
+
# Set timeouts and keep-alives if not present
|
| 37 |
+
if 'connect_timeout' not in query_params:
|
| 38 |
+
query_params['connect_timeout'] = ['5']
|
| 39 |
+
if 'keepalives_idle' not in query_params:
|
| 40 |
+
query_params['keepalives_idle'] = ['60']
|
| 41 |
+
|
| 42 |
+
# Remove statement_timeout option for Neon
|
| 43 |
+
if 'neon.tech' in parsed.netloc:
|
| 44 |
+
if 'options' in query_params:
|
| 45 |
+
options_clean = []
|
| 46 |
+
for opt in query_params['options']:
|
| 47 |
+
if 'statement_timeout' not in opt:
|
| 48 |
+
options_clean.append(opt)
|
| 49 |
+
if options_clean:
|
| 50 |
+
query_params['options'] = options_clean
|
| 51 |
+
else:
|
| 52 |
+
query_params.pop('options')
|
| 53 |
+
logger.info("Removed unsupported 'statement_timeout' option for Neon.tech.")
|
| 54 |
+
# Optionally, set a supported option for Neon (usually none)
|
| 55 |
+
|
| 56 |
+
# TODO: Extend here for further providers...
|
| 57 |
+
|
| 58 |
+
# Rebuild DSN
|
| 59 |
+
new_query = urlencode(query_params, doseq=True)
|
| 60 |
+
new_url = parsed._replace(query=new_query)
|
| 61 |
+
return urlunparse(new_url)
|
| 62 |
+
|
| 63 |
+
def mask_dsn(dsn_url: str) -> str:
|
| 64 |
+
"""
|
| 65 |
+
Masks username/password from DSN so they are not exposed in logs.
|
| 66 |
+
"""
|
| 67 |
+
parsed = urlparse(dsn_url)
|
| 68 |
+
safe_netloc = f"{parsed.hostname}:{parsed.port}" if parsed.port else parsed.hostname
|
| 69 |
+
return parsed._replace(netloc=safe_netloc).geturl()
|
| 70 |
+
|
| 71 |
+
async def ssl_runtime_check(conn: asyncpg.Connection):
|
| 72 |
+
"""
|
| 73 |
+
Performs a cloud-aware SSL runtime check on an active connection.
|
| 74 |
+
For Neon/Supabase (or unknown cloud) only log a warning if pg_stat_ssl is unavailable.
|
| 75 |
+
"""
|
| 76 |
+
dsn = os.getenv("DATABASE_URL", "")
|
| 77 |
+
try:
|
| 78 |
+
ssl_status = await conn.fetchval("""
|
| 79 |
+
SELECT CASE WHEN ssl THEN 'active' ELSE 'INACTIVE' END
|
| 80 |
+
FROM pg_stat_ssl WHERE pid = pg_backend_pid()
|
| 81 |
+
""")
|
| 82 |
+
if ssl_status != 'active':
|
| 83 |
+
logger.critical("CRITICAL ERROR: SSL connection is not active!")
|
| 84 |
+
raise RuntimeError("SSL connection failed")
|
| 85 |
+
logger.info("SSL connection is active.")
|
| 86 |
+
except Exception as e:
|
| 87 |
+
# Cloud: If pg_stat_ssl is not available, don't fail hard.
|
| 88 |
+
if "neon.tech" in dsn or "supabase" in dsn:
|
| 89 |
+
logger.warning("SSL check via pg_stat_ssl not possible (cloud restriction). Assuming SSL is active due to sslmode=require.")
|
| 90 |
+
else:
|
| 91 |
+
logger.critical(f"SSL runtime check failed: {e}")
|
| 92 |
+
raise
|
| 93 |
+
|
| 94 |
+
async def init_db_pool(dsn_url: Optional[str] = None) -> Optional[asyncpg.Pool]:
|
| 95 |
+
"""Initializes the asynchronous database connection pool."""
|
| 96 |
+
global _db_pool
|
| 97 |
+
if _db_pool:
|
| 98 |
+
return _db_pool
|
| 99 |
+
|
| 100 |
+
if not dsn_url:
|
| 101 |
+
dsn_url = os.getenv("DATABASE_URL") or os.getenv("PG_DSN")
|
| 102 |
+
if not dsn_url:
|
| 103 |
+
logger.warning("No DATABASE_URL or PG_DSN found. Skipping DB pool initialization.")
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
# Enforce cloud security and remove unsupported options
|
| 107 |
+
secured_dsn = enforce_cloud_security(dsn_url)
|
| 108 |
+
|
| 109 |
+
# ⚠ WARNING: This logs full credentials — keep only for secure DEV debugging
|
| 110 |
+
logger.debug(f"[DEV ONLY] Full DSN used for DB connection: {secured_dsn}")
|
| 111 |
+
|
| 112 |
+
# Always log a masked DSN for production safety
|
| 113 |
+
logger.info(f"DSN used for DB connection (masked): {mask_dsn(secured_dsn)}")
|
| 114 |
+
|
| 115 |
+
ssl_context = None
|
| 116 |
+
if 'sslmode=verify-full' in secured_dsn:
|
| 117 |
+
ssl_context = ssl.create_default_context()
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
logger.info("Initializing secure database pool...")
|
| 121 |
+
_db_pool = await asyncpg.create_pool(
|
| 122 |
+
dsn=secured_dsn,
|
| 123 |
+
min_size=1,
|
| 124 |
+
max_size=10,
|
| 125 |
+
timeout=5,
|
| 126 |
+
command_timeout=30,
|
| 127 |
+
ssl=ssl_context
|
| 128 |
+
)
|
| 129 |
+
# Post-init checks
|
| 130 |
+
async with _db_pool.acquire() as conn:
|
| 131 |
+
await ssl_runtime_check(conn)
|
| 132 |
+
logger.info("Secure database pool initialized.")
|
| 133 |
+
return _db_pool
|
| 134 |
+
except Exception as e:
|
| 135 |
+
logger.critical(f"Pool initialization failed: {str(e)}")
|
| 136 |
+
_db_pool = None
|
| 137 |
+
return None # Fallback: allow app to run without DB
|
| 138 |
+
|
| 139 |
+
async def close_db_pool():
|
| 140 |
+
"""Gracefully closes the database connection pool."""
|
| 141 |
+
global _db_pool
|
| 142 |
+
if _db_pool:
|
| 143 |
+
await _db_pool.close()
|
| 144 |
+
_db_pool = None
|
| 145 |
+
logger.info("Database pool closed successfully.")
|
| 146 |
+
|
| 147 |
+
async def execute_secured_query(query: str, *params, fetch_method='fetch'):
|
| 148 |
+
"""
|
| 149 |
+
Executes a parameterized query with integrated security checks.
|
| 150 |
+
"""
|
| 151 |
+
global _db_pool
|
| 152 |
+
if not _db_pool:
|
| 153 |
+
raise RuntimeError("Database pool not initialized")
|
| 154 |
+
|
| 155 |
+
try:
|
| 156 |
+
async with _db_pool.acquire() as conn:
|
| 157 |
+
if fetch_method == 'fetch':
|
| 158 |
+
return await conn.fetch(query, *params)
|
| 159 |
+
elif fetch_method == 'fetchrow':
|
| 160 |
+
return await conn.fetchrow(query, *params)
|
| 161 |
+
elif fetch_method == 'execute':
|
| 162 |
+
return await conn.execute(query, *params)
|
| 163 |
+
else:
|
| 164 |
+
raise ValueError("Invalid fetch_method")
|
| 165 |
+
except asyncpg.PostgresError as e:
|
| 166 |
+
error_type = "Security violation" if getattr(e, 'sqlstate', None) == '42501' else "Database error"
|
| 167 |
+
|
| 168 |
+
if os.getenv('APP_ENV') == 'production':
|
| 169 |
+
logger.error(f"{error_type} [Code: {getattr(e, 'sqlstate', '?')}]")
|
| 170 |
+
else:
|
| 171 |
+
logger.error(f"{error_type}: {e}")
|
| 172 |
+
|
| 173 |
+
# Neon: Reconnect if connection terminated (optional)
|
| 174 |
+
if getattr(e, 'sqlstate', None) == '08006' and 'neon.tech' in (os.getenv("DATABASE_URL") or ''):
|
| 175 |
+
logger.warning("Neon.tech connection terminated. Restarting pool...")
|
| 176 |
+
await close_db_pool()
|
| 177 |
+
await init_db_pool(os.getenv("DATABASE_URL"))
|
| 178 |
+
|
| 179 |
+
raise
|
fundaments/security.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# fundaments/security.py
|
| 6 |
+
# A central security manager that orchestrates core security functions.
|
| 7 |
+
# This class acts as a single, trusted interface for the application to
|
| 8 |
+
# interact with all underlying security fundamentals.
|
| 9 |
+
|
| 10 |
+
import logging
|
| 11 |
+
from typing import Dict, Any, Optional
|
| 12 |
+
|
| 13 |
+
# VORHER: Diese direkten Imports sind überflüssig,
|
| 14 |
+
# da die Instanzen der Klassen über den 'services'-Dictionary
|
| 15 |
+
# im __init__-Konstruktor übergeben werden.
|
| 16 |
+
# Sie sind auch nicht für die Typisierung notwendig,
|
| 17 |
+
# da wir 'Optional[ClassName]' verwenden, was ausreicht.
|
| 18 |
+
# from fundaments.encryption import Encryption
|
| 19 |
+
# from fundaments.access_control import AccessControl
|
| 20 |
+
# from fundaments.postgresql import execute_secured_query
|
| 21 |
+
# from fundaments.user_handler import UserHandler
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger('security')
|
| 24 |
+
|
| 25 |
+
class Security:
|
| 26 |
+
def __init__(self, services: Dict[str, Any]):
|
| 27 |
+
# VORHER: Hier werden die Instanzen aus dem übergebenen Dictionary entnommen.
|
| 28 |
+
self.user_handler: Optional[UserHandler] = services.get("user_handler")
|
| 29 |
+
self.access_control: Optional[AccessControl] = services.get("access_control")
|
| 30 |
+
self.encryption: Optional[Encryption] = services.get("encryption")
|
| 31 |
+
|
| 32 |
+
# VORHER: Bei fehlenden kritischen Diensten wird die Anwendung sofort gestoppt.
|
| 33 |
+
# Dies ist eine strikte, aber unflexible Regel.
|
| 34 |
+
if not self.user_handler:
|
| 35 |
+
logger.critical("Security manager init failed: UserHandler service missing.")
|
| 36 |
+
raise RuntimeError("UserHandler service missing")
|
| 37 |
+
|
| 38 |
+
if not self.access_control:
|
| 39 |
+
logger.critical("Security manager init failed: AccessControl service missing.")
|
| 40 |
+
raise RuntimeError("AccessControl service missing")
|
| 41 |
+
|
| 42 |
+
# VORHER: Bei einem nicht-kritischen Dienst wird nur eine Warnung ausgegeben.
|
| 43 |
+
# Die Anwendung läuft weiter.
|
| 44 |
+
if not self.encryption:
|
| 45 |
+
logger.warning("Encryption service not available. Encryption/decryption features will be disabled.")
|
| 46 |
+
|
| 47 |
+
logger.info("Security manager initialized and ready.")
|
| 48 |
+
|
| 49 |
+
# VORHER: Hier fehlt die Überprüfung, ob 'self.user_handler' None ist.
|
| 50 |
+
# Wenn der Dienst nicht initialisiert wurde (wie oben), würde dies einen AttributeError auslösen.
|
| 51 |
+
async def user_login(self, username: str, password: str, request_data: dict) -> bool:
|
| 52 |
+
logger.info(f"Attempting login for user: {username}")
|
| 53 |
+
if await self.user_handler.login(username, password, request_data):
|
| 54 |
+
return await self.user_handler.validate_session(request_data)
|
| 55 |
+
return False
|
| 56 |
+
|
| 57 |
+
# VORHER: Hier fehlt die Überprüfung, ob 'self.access_control' None ist.
|
| 58 |
+
async def check_permission(self, user_id: int, permission_name: str) -> bool:
|
| 59 |
+
logger.debug(f"Checking permission '{permission_name}' for user ID {user_id}")
|
| 60 |
+
return await self.access_control.has_permission(user_id, permission_name)
|
| 61 |
+
|
| 62 |
+
# VORHER: Hier wird geprüft, ob 'self.encryption' None ist, was korrekt ist.
|
| 63 |
+
def encrypt_data(self, data: str) -> Dict[str, str]:
|
| 64 |
+
if not self.encryption:
|
| 65 |
+
raise RuntimeError("Encryption service not initialized.")
|
| 66 |
+
logger.debug("Encrypting data.")
|
| 67 |
+
return self.encryption.encrypt(data)
|
| 68 |
+
|
| 69 |
+
# VORHER: Hier wird geprüft, ob 'self.encryption' None ist, was korrekt ist.
|
| 70 |
+
def decrypt_data(self, encrypted_data: str, nonce: str, tag: str) -> Optional[str]:
|
| 71 |
+
if not self.encryption:
|
| 72 |
+
logger.error("Encryption service not initialized. Cannot decrypt data.")
|
| 73 |
+
return None
|
| 74 |
+
logger.debug("Decrypting data.")
|
| 75 |
+
try:
|
| 76 |
+
return self.encryption.decrypt(encrypted_data, nonce, tag)
|
| 77 |
+
except Exception as e:
|
| 78 |
+
logger.error(f"Decryption failed: {e}")
|
| 79 |
+
return None
|
fundaments/user_handler.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# user_handler.py
|
| 6 |
+
# A Python module for handling user authentication and session management.
|
| 7 |
+
|
| 8 |
+
import sqlite3
|
| 9 |
+
import uuid
|
| 10 |
+
from datetime import datetime, timedelta
|
| 11 |
+
from passlib.hash import pbkdf2_sha256
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
class Database:
|
| 15 |
+
"""
|
| 16 |
+
A simple placeholder class to simulate a database connection.
|
| 17 |
+
In a real application, you would use a proper ORM like SQLAlchemy
|
| 18 |
+
or a specific database driver.
|
| 19 |
+
"""
|
| 20 |
+
def __init__(self, db_name="cms_database.db"):
|
| 21 |
+
self.conn = sqlite3.connect(db_name)
|
| 22 |
+
self.cursor = self.conn.cursor()
|
| 23 |
+
|
| 24 |
+
def execute(self, query, params=None):
|
| 25 |
+
if params is None:
|
| 26 |
+
params = []
|
| 27 |
+
self.cursor.execute(query, params)
|
| 28 |
+
self.conn.commit()
|
| 29 |
+
|
| 30 |
+
def fetchone(self, query, params=None):
|
| 31 |
+
if params is None:
|
| 32 |
+
params = []
|
| 33 |
+
self.cursor.execute(query, params)
|
| 34 |
+
return self.cursor.fetchone()
|
| 35 |
+
|
| 36 |
+
def fetchall(self, query, params=None):
|
| 37 |
+
if params is None:
|
| 38 |
+
params = []
|
| 39 |
+
self.cursor.execute(query, params)
|
| 40 |
+
return self.cursor.fetchall()
|
| 41 |
+
|
| 42 |
+
def close(self):
|
| 43 |
+
self.conn.close()
|
| 44 |
+
|
| 45 |
+
def setup_tables(self):
|
| 46 |
+
"""
|
| 47 |
+
Creates the necessary tables for users and sessions.
|
| 48 |
+
"""
|
| 49 |
+
self.execute("""
|
| 50 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 51 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 52 |
+
username TEXT UNIQUE NOT NULL,
|
| 53 |
+
password TEXT NOT NULL,
|
| 54 |
+
is_admin INTEGER NOT NULL DEFAULT 0,
|
| 55 |
+
account_locked INTEGER NOT NULL DEFAULT 0,
|
| 56 |
+
failed_login_attempts INTEGER NOT NULL DEFAULT 0
|
| 57 |
+
)
|
| 58 |
+
""")
|
| 59 |
+
self.execute("""
|
| 60 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 61 |
+
id TEXT PRIMARY KEY,
|
| 62 |
+
user_id INTEGER NOT NULL,
|
| 63 |
+
ip_address TEXT,
|
| 64 |
+
user_agent TEXT,
|
| 65 |
+
last_activity TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 66 |
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
| 67 |
+
)
|
| 68 |
+
""")
|
| 69 |
+
|
| 70 |
+
class Security:
|
| 71 |
+
"""
|
| 72 |
+
Handles secure password hashing and session regeneration.
|
| 73 |
+
Using passlib for robust and secure password management.
|
| 74 |
+
"""
|
| 75 |
+
@staticmethod
|
| 76 |
+
def hash_password(password: str) -> str:
|
| 77 |
+
"""Hashes a password using PBKDF2 with SHA256."""
|
| 78 |
+
return pbkdf2_sha256.hash(password)
|
| 79 |
+
|
| 80 |
+
@staticmethod
|
| 81 |
+
def verify_password(password: str, hashed_password: str) -> bool:
|
| 82 |
+
"""Verifies a password against a stored hash."""
|
| 83 |
+
return pbkdf2_sha256.verify(password, hashed_password)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def regenerate_session(session_id: str):
|
| 87 |
+
"""
|
| 88 |
+
Simulates regenerating a session ID to prevent session fixation.
|
| 89 |
+
In a real web framework, this would be a framework-specific function.
|
| 90 |
+
"""
|
| 91 |
+
print(f"Session regenerated. Old ID: {session_id}")
|
| 92 |
+
new_session_id = str(uuid.uuid4())
|
| 93 |
+
print(f"New ID: {new_session_id}")
|
| 94 |
+
return new_session_id
|
| 95 |
+
|
| 96 |
+
class UserHandler:
|
| 97 |
+
"""
|
| 98 |
+
Handles user login, logout, and session validation.
|
| 99 |
+
This class mirrors the logic from the user's PHP User class.
|
| 100 |
+
"""
|
| 101 |
+
def __init__(self, db: Database):
|
| 102 |
+
self.db = db
|
| 103 |
+
# A simple in-memory session store for this example
|
| 104 |
+
self._session = {}
|
| 105 |
+
|
| 106 |
+
def login(self, username: str, password: str, request_data: dict) -> bool:
|
| 107 |
+
"""
|
| 108 |
+
Logs in the user by verifying credentials and storing a new session.
|
| 109 |
+
:param username: The user's username.
|
| 110 |
+
:param password: The user's plain-text password.
|
| 111 |
+
:param request_data: A dictionary containing 'ip_address' and 'user_agent'.
|
| 112 |
+
:return: True if login is successful, False otherwise.
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
# Step 1: Find the user in the database
|
| 116 |
+
user_data = self.db.fetchone("SELECT id, username, password, is_admin, account_locked, failed_login_attempts FROM users WHERE username = ?", (username,))
|
| 117 |
+
if user_data is None:
|
| 118 |
+
print(f"Login failed: Username '{username}' not found.")
|
| 119 |
+
return False
|
| 120 |
+
|
| 121 |
+
user = {
|
| 122 |
+
'id': user_data[0],
|
| 123 |
+
'username': user_data[1],
|
| 124 |
+
'password': user_data[2],
|
| 125 |
+
'is_admin': user_data[3],
|
| 126 |
+
'account_locked': user_data[4],
|
| 127 |
+
'failed_login_attempts': user_data[5]
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
# Check if account is locked
|
| 131 |
+
if user['account_locked'] == 1:
|
| 132 |
+
print(f"Login failed: Account for '{username}' is locked.")
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
# Step 2: Verify the password
|
| 136 |
+
if Security.verify_password(password, user['password']):
|
| 137 |
+
print(f"Login successful for user: '{username}'")
|
| 138 |
+
|
| 139 |
+
# Reset failed login attempts on success
|
| 140 |
+
self.reset_failed_attempts(username)
|
| 141 |
+
|
| 142 |
+
# Step 3: Create a new session record in the database
|
| 143 |
+
session_id = str(uuid.uuid4())
|
| 144 |
+
ip_address = request_data.get('ip_address', 'unknown')
|
| 145 |
+
user_agent = request_data.get('user_agent', 'unknown')
|
| 146 |
+
|
| 147 |
+
self.db.execute(
|
| 148 |
+
"INSERT INTO sessions (id, user_id, ip_address, user_agent) VALUES (?, ?, ?, ?)",
|
| 149 |
+
(session_id, user['id'], ip_address, user_agent)
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
# Step 4: Store session data in the in-memory session (or a session store)
|
| 153 |
+
self._session = {
|
| 154 |
+
'session_id': session_id,
|
| 155 |
+
'user_id': user['id'],
|
| 156 |
+
'username': user['username'],
|
| 157 |
+
'is_admin': user['is_admin']
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
# Security: Regenerate session ID
|
| 161 |
+
self._session['session_id'] = Security.regenerate_session(session_id)
|
| 162 |
+
return True
|
| 163 |
+
else:
|
| 164 |
+
print(f"Login failed: Incorrect password for user '{username}'.")
|
| 165 |
+
# Increment failed login attempts
|
| 166 |
+
self.increment_failed_attempts(username)
|
| 167 |
+
return False
|
| 168 |
+
|
| 169 |
+
except sqlite3.Error as e:
|
| 170 |
+
print(f"Database error during login: {e}")
|
| 171 |
+
return False
|
| 172 |
+
|
| 173 |
+
def logout(self) -> bool:
|
| 174 |
+
"""
|
| 175 |
+
Logs out the current user by deleting the session from the database.
|
| 176 |
+
:return: True if logout is successful, False otherwise.
|
| 177 |
+
"""
|
| 178 |
+
if 'user_id' not in self._session:
|
| 179 |
+
print("No active session to log out.")
|
| 180 |
+
return False
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
# Step 1: Delete the session from the database
|
| 184 |
+
self.db.execute("DELETE FROM sessions WHERE user_id = ?", (self._session['user_id'],))
|
| 185 |
+
|
| 186 |
+
# Step 2: Clear the in-memory session data
|
| 187 |
+
self._session.clear()
|
| 188 |
+
print("User logged out successfully.")
|
| 189 |
+
return True
|
| 190 |
+
except sqlite3.Error as e:
|
| 191 |
+
print(f"Database error during logout: {e}")
|
| 192 |
+
return False
|
| 193 |
+
|
| 194 |
+
def is_logged_in(self) -> bool:
|
| 195 |
+
"""
|
| 196 |
+
Checks if the current user is logged in.
|
| 197 |
+
:return: True if a valid session exists, False otherwise.
|
| 198 |
+
"""
|
| 199 |
+
if 'user_id' not in self._session:
|
| 200 |
+
return False
|
| 201 |
+
|
| 202 |
+
try:
|
| 203 |
+
# Check for the session in the database
|
| 204 |
+
session_data = self.db.fetchone(
|
| 205 |
+
"SELECT * FROM sessions WHERE id = ? AND user_id = ?",
|
| 206 |
+
(self._session['session_id'], self._session['user_id'])
|
| 207 |
+
)
|
| 208 |
+
return session_data is not None
|
| 209 |
+
except sqlite3.Error as e:
|
| 210 |
+
print(f"Database error during is_logged_in check: {e}")
|
| 211 |
+
return False
|
| 212 |
+
|
| 213 |
+
def is_admin(self) -> bool:
|
| 214 |
+
"""
|
| 215 |
+
Checks if the logged-in user is an admin.
|
| 216 |
+
:return: True if the user is an admin, False otherwise.
|
| 217 |
+
"""
|
| 218 |
+
return self._session.get('is_admin', 0) == 1
|
| 219 |
+
|
| 220 |
+
def validate_session(self, request_data: dict) -> bool:
|
| 221 |
+
"""
|
| 222 |
+
Validates the current session against IP address and user agent.
|
| 223 |
+
:param request_data: A dictionary containing 'ip_address' and 'user_agent'.
|
| 224 |
+
:return: True if the session is valid, False otherwise.
|
| 225 |
+
"""
|
| 226 |
+
if not self.is_logged_in():
|
| 227 |
+
return False
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
ip_address = request_data.get('ip_address', 'unknown')
|
| 231 |
+
user_agent = request_data.get('user_agent', 'unknown')
|
| 232 |
+
|
| 233 |
+
session_data = self.db.fetchone(
|
| 234 |
+
"SELECT * FROM sessions WHERE id = ? AND user_id = ? AND ip_address = ? AND user_agent = ?",
|
| 235 |
+
(self._session['session_id'], self._session['user_id'], ip_address, user_agent)
|
| 236 |
+
)
|
| 237 |
+
return session_data is not None
|
| 238 |
+
except sqlite3.Error as e:
|
| 239 |
+
print(f"Database error during session validation: {e}")
|
| 240 |
+
return False
|
| 241 |
+
|
| 242 |
+
def lock_account(self, username: str):
|
| 243 |
+
"""
|
| 244 |
+
Locks a user account.
|
| 245 |
+
:param username: The username of the account to lock.
|
| 246 |
+
"""
|
| 247 |
+
try:
|
| 248 |
+
self.db.execute("UPDATE users SET account_locked = 1 WHERE username = ?", (username,))
|
| 249 |
+
print(f"Account for '{username}' has been locked.")
|
| 250 |
+
except sqlite3.Error as e:
|
| 251 |
+
print(f"Database error while locking account: {e}")
|
| 252 |
+
|
| 253 |
+
def reset_failed_attempts(self, username: str):
|
| 254 |
+
"""
|
| 255 |
+
Resets failed login attempts for a user.
|
| 256 |
+
:param username: The username of the account.
|
| 257 |
+
"""
|
| 258 |
+
try:
|
| 259 |
+
self.db.execute("UPDATE users SET failed_login_attempts = 0 WHERE username = ?", (username,))
|
| 260 |
+
except sqlite3.Error as e:
|
| 261 |
+
print(f"Database error while resetting failed attempts: {e}")
|
| 262 |
+
|
| 263 |
+
def increment_failed_attempts(self, username: str):
|
| 264 |
+
"""
|
| 265 |
+
Increments failed login attempts and locks the account if a threshold is met.
|
| 266 |
+
:param username: The username of the account.
|
| 267 |
+
"""
|
| 268 |
+
try:
|
| 269 |
+
# Get the current failed attempts
|
| 270 |
+
user_data = self.db.fetchone("SELECT failed_login_attempts FROM users WHERE username = ?", (username,))
|
| 271 |
+
if user_data:
|
| 272 |
+
attempts = user_data[0] + 1
|
| 273 |
+
self.db.execute(
|
| 274 |
+
"UPDATE users SET failed_login_attempts = ? WHERE username = ?",
|
| 275 |
+
(attempts, username)
|
| 276 |
+
)
|
| 277 |
+
print(f"Failed login attempts for '{username}': {attempts}")
|
| 278 |
+
|
| 279 |
+
# Check for threshold (e.g., 5 attempts)
|
| 280 |
+
if attempts >= 5:
|
| 281 |
+
self.lock_account(username)
|
| 282 |
+
|
| 283 |
+
except sqlite3.Error as e:
|
| 284 |
+
print(f"Database error while incrementing failed attempts: {e}")
|
| 285 |
+
|
| 286 |
+
# --- Example Usage ---
|
| 287 |
+
if __name__ == "__main__":
|
| 288 |
+
db = Database()
|
| 289 |
+
db.setup_tables()
|
| 290 |
+
user_handler = UserHandler(db)
|
| 291 |
+
|
| 292 |
+
# Clean up old test data if it exists
|
| 293 |
+
db.execute("DELETE FROM users WHERE username IN (?, ?)", ("testuser", "adminuser"))
|
| 294 |
+
db.execute("DELETE FROM sessions")
|
| 295 |
+
|
| 296 |
+
# 1. Register a new user and an admin user
|
| 297 |
+
hashed_password = Security.hash_password("secure_password_123")
|
| 298 |
+
db.execute("INSERT INTO users (username, password) VALUES (?, ?)", ("testuser", hashed_password))
|
| 299 |
+
db.execute("INSERT INTO users (username, password, is_admin) VALUES (?, ?, 1)", ("adminuser", hashed_password))
|
| 300 |
+
|
| 301 |
+
print("--- Test 1: Successful Login ---")
|
| 302 |
+
# Simulate a web request
|
| 303 |
+
request_data = {
|
| 304 |
+
'ip_address': '192.168.1.100',
|
| 305 |
+
'user_agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
login_success = user_handler.login("testuser", "secure_password_123", request_data)
|
| 309 |
+
print(f"Login attempt status: {login_success}")
|
| 310 |
+
print(f"Is user logged in? {user_handler.is_logged_in()}")
|
| 311 |
+
print(f"Is user an admin? {user_handler.is_admin()}")
|
| 312 |
+
print(f"Is session valid? {user_handler.validate_session(request_data)}")
|
| 313 |
+
print("-" * 20)
|
| 314 |
+
|
| 315 |
+
# 2. Simulate a logout
|
| 316 |
+
print("--- Test 2: Logout ---")
|
| 317 |
+
user_handler.logout()
|
| 318 |
+
print(f"Is user logged in after logout? {user_handler.is_logged_in()}")
|
| 319 |
+
print("-" * 20)
|
| 320 |
+
|
| 321 |
+
# 3. Simulate a failed login and account lock
|
| 322 |
+
print("--- Test 3: Failed Login and Account Lock ---")
|
| 323 |
+
# Log in with the wrong password multiple times
|
| 324 |
+
for i in range(6):
|
| 325 |
+
user_handler.login("testuser", "wrong_password", request_data)
|
| 326 |
+
|
| 327 |
+
# Now, try to log in with the correct password. It should fail because the account is locked.
|
| 328 |
+
print("\nAttempting to log in with correct password after lock:")
|
| 329 |
+
login_attempt_after_lock = user_handler.login("testuser", "secure_password_123", request_data)
|
| 330 |
+
print(f"Login attempt status: {login_attempt_after_lock}")
|
| 331 |
+
print("-" * 20)
|
| 332 |
+
|
| 333 |
+
# 4. Reset failed attempts for a new login
|
| 334 |
+
print("--- Test 4: Resetting failed attempts ---")
|
| 335 |
+
user_handler.reset_failed_attempts("testuser")
|
| 336 |
+
login_attempt_after_reset = user_handler.login("testuser", "secure_password_123", request_data)
|
| 337 |
+
print(f"Login attempt status after reset: {login_attempt_after_reset}")
|
| 338 |
+
|
| 339 |
+
db.close()
|
| 340 |
+
|
| 341 |
+
# Optional: Clean up the database file after the run
|
| 342 |
+
# os.remove("cms_database.db")
|
main.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments: A Secure Python Architecture
|
| 2 |
+
# Copyright 2008-2025 - Volkan Kücükbudak
|
| 3 |
+
# Apache License V. 2
|
| 4 |
+
# Repo: https://github.com/VolkanSah/PyFundaments
|
| 5 |
+
# main.py
|
| 6 |
+
# This is the main entry point of the application.
|
| 7 |
+
# It now handles asynchronous initialization of the fundament modules.
|
| 8 |
+
import sys
|
| 9 |
+
import logging
|
| 10 |
+
import asyncio
|
| 11 |
+
import os
|
| 12 |
+
from typing import Dict, Any, Optional
|
| 13 |
+
|
| 14 |
+
import importlib.util
|
| 15 |
+
import datetime
|
| 16 |
+
|
| 17 |
+
if 'fundaments' in sys.modules:
|
| 18 |
+
del sys.modules['fundaments']
|
| 19 |
+
|
| 20 |
+
# We import our core modules from the "fundaments" directory.
|
| 21 |
+
try:
|
| 22 |
+
from fundaments.config_handler import config_service
|
| 23 |
+
from fundaments.postgresql import init_db_pool, close_db_pool
|
| 24 |
+
from fundaments.encryption import Encryption
|
| 25 |
+
from fundaments.access_control import AccessControl
|
| 26 |
+
from fundaments.user_handler import UserHandler
|
| 27 |
+
from fundaments.security import Security
|
| 28 |
+
from fundaments.debug import PyFundamentsDebug
|
| 29 |
+
except ImportError as e:
|
| 30 |
+
print(f"Error: Failed to import a fundament module: {e}")
|
| 31 |
+
print("Please ensure the modules and dependencies are present.")
|
| 32 |
+
sys.exit(1)
|
| 33 |
+
# Debug run
|
| 34 |
+
debug = PyFundamentsDebug()
|
| 35 |
+
debug.run()
|
| 36 |
+
|
| 37 |
+
# Logger configuration - conditional based on ENV
|
| 38 |
+
log_level = os.getenv('LOG_LEVEL', 'INFO').upper()
|
| 39 |
+
log_to_tmp = os.getenv('LOG_TO_TMP', 'false').lower() == 'true'
|
| 40 |
+
enable_public_logs = os.getenv('ENABLE_PUBLIC_LOGS', 'true').lower() == 'true'
|
| 41 |
+
|
| 42 |
+
if enable_public_logs:
|
| 43 |
+
if log_to_tmp:
|
| 44 |
+
log_file = '/tmp/pyfundaments.log'
|
| 45 |
+
logging.basicConfig(
|
| 46 |
+
level=getattr(logging, log_level),
|
| 47 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
| 48 |
+
handlers=[
|
| 49 |
+
logging.FileHandler(log_file),
|
| 50 |
+
logging.StreamHandler()
|
| 51 |
+
]
|
| 52 |
+
)
|
| 53 |
+
else:
|
| 54 |
+
logging.basicConfig(
|
| 55 |
+
level=getattr(logging, log_level),
|
| 56 |
+
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
| 57 |
+
)
|
| 58 |
+
else:
|
| 59 |
+
# Silent mode - only critical errors
|
| 60 |
+
logging.basicConfig(level=logging.CRITICAL)
|
| 61 |
+
|
| 62 |
+
logger = logging.getLogger('main_app_loader')
|
| 63 |
+
|
| 64 |
+
async def initialize_fundaments() -> Dict[str, Any]:
|
| 65 |
+
"""
|
| 66 |
+
Initializes core application services conditionally based on available ENV variables.
|
| 67 |
+
Only loads services for which the required configuration is present.
|
| 68 |
+
"""
|
| 69 |
+
logger.info("Starting conditional initialization of fundament modules...")
|
| 70 |
+
|
| 71 |
+
fundaments = {
|
| 72 |
+
"config": config_service
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
# --- Database Initialization (PostgreSQL) ---
|
| 76 |
+
# Only initialize if DATABASE_URL is available
|
| 77 |
+
database_url = config_service.get("DATABASE_URL")
|
| 78 |
+
if database_url and database_url != "your_database_dsn_here":
|
| 79 |
+
try:
|
| 80 |
+
db_service = await init_db_pool(database_url)
|
| 81 |
+
fundaments["db"] = db_service
|
| 82 |
+
logger.info("Database service initialized.")
|
| 83 |
+
except Exception as e:
|
| 84 |
+
logger.warning(f"Database initialization failed, continuing without DB: {e}")
|
| 85 |
+
fundaments["db"] = None
|
| 86 |
+
else:
|
| 87 |
+
logger.info("No valid DATABASE_URL found, skipping database initialization.")
|
| 88 |
+
fundaments["db"] = None
|
| 89 |
+
|
| 90 |
+
# --- Encryption Initialization ---
|
| 91 |
+
# Only initialize if both encryption keys are available
|
| 92 |
+
master_key = config_service.get("MASTER_ENCRYPTION_KEY")
|
| 93 |
+
persistent_salt = config_service.get("PERSISTENT_ENCRYPTION_SALT")
|
| 94 |
+
|
| 95 |
+
if master_key and persistent_salt and master_key != "your_256_bit_key_here":
|
| 96 |
+
try:
|
| 97 |
+
encryption_service = Encryption(master_key=master_key, salt=persistent_salt)
|
| 98 |
+
fundaments["encryption"] = encryption_service
|
| 99 |
+
logger.info("Encryption service initialized.")
|
| 100 |
+
except Exception as e:
|
| 101 |
+
logger.warning(f"Encryption initialization failed, continuing without encryption: {e}")
|
| 102 |
+
fundaments["encryption"] = None
|
| 103 |
+
else:
|
| 104 |
+
logger.info("Encryption keys not found or using defaults, skipping encryption initialization.")
|
| 105 |
+
fundaments["encryption"] = None
|
| 106 |
+
|
| 107 |
+
# --- Access Control Initialization ---
|
| 108 |
+
# Only initialize if we have a database connection
|
| 109 |
+
if fundaments["db"] is not None:
|
| 110 |
+
try:
|
| 111 |
+
access_control_service = AccessControl()
|
| 112 |
+
fundaments["access_control"] = access_control_service
|
| 113 |
+
logger.info("Access Control service initialized.")
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.warning(f"Access Control initialization failed: {e}")
|
| 116 |
+
fundaments["access_control"] = None
|
| 117 |
+
else:
|
| 118 |
+
logger.info("No database available, skipping Access Control initialization.")
|
| 119 |
+
fundaments["access_control"] = None
|
| 120 |
+
|
| 121 |
+
# --- User Handler Initialization ---
|
| 122 |
+
# Only initialize if we have a database connection
|
| 123 |
+
if fundaments["db"] is not None:
|
| 124 |
+
try:
|
| 125 |
+
user_handler_service = UserHandler(fundaments["db"])
|
| 126 |
+
fundaments["user_handler"] = user_handler_service
|
| 127 |
+
logger.info("User Handler service initialized.")
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.warning(f"User Handler initialization failed: {e}")
|
| 130 |
+
fundaments["user_handler"] = None
|
| 131 |
+
else:
|
| 132 |
+
logger.info("No database available, skipping User Handler initialization.")
|
| 133 |
+
fundaments["user_handler"] = None
|
| 134 |
+
|
| 135 |
+
# --- Security Manager Initialization ---
|
| 136 |
+
# Only initialize if we have the required sub-services
|
| 137 |
+
available_services = {k: v for k, v in fundaments.items() if v is not None and k != "config"}
|
| 138 |
+
|
| 139 |
+
if len(available_services) >= 1: # At least one service beyond config
|
| 140 |
+
try:
|
| 141 |
+
# Filter out None services for Security manager
|
| 142 |
+
fundament_services = {
|
| 143 |
+
k: v for k, v in {
|
| 144 |
+
"user_handler": fundaments.get("user_handler"),
|
| 145 |
+
"access_control": fundaments.get("access_control"),
|
| 146 |
+
"encryption": fundaments.get("encryption")
|
| 147 |
+
}.items() if v is not None
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if fundament_services: # Only if we have actual services
|
| 151 |
+
security_service = Security(fundament_services)
|
| 152 |
+
fundaments["security"] = security_service
|
| 153 |
+
logger.info("Security manager initialized.")
|
| 154 |
+
else:
|
| 155 |
+
logger.info("No services available for Security manager, skipping initialization.")
|
| 156 |
+
fundaments["security"] = None
|
| 157 |
+
except Exception as e:
|
| 158 |
+
logger.warning(f"Security manager initialization failed: {e}")
|
| 159 |
+
fundaments["security"] = None
|
| 160 |
+
else:
|
| 161 |
+
logger.info("Insufficient services for Security manager, skipping initialization.")
|
| 162 |
+
fundaments["security"] = None
|
| 163 |
+
|
| 164 |
+
# Log what was actually initialized
|
| 165 |
+
initialized_services = [k for k, v in fundaments.items() if v is not None]
|
| 166 |
+
logger.info(f"Successfully initialized services: {', '.join(initialized_services)}")
|
| 167 |
+
|
| 168 |
+
return fundaments
|
| 169 |
+
|
| 170 |
+
async def main():
|
| 171 |
+
"""
|
| 172 |
+
The main asynchronous function of the application.
|
| 173 |
+
"""
|
| 174 |
+
logger.info("Starting main.py...")
|
| 175 |
+
|
| 176 |
+
fundaments = await initialize_fundaments()
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
# Load the actual app logic here.
|
| 180 |
+
# This is where your 'app/app.py' would be imported and run.
|
| 181 |
+
logger.info("Fundament modules are ready for the app logic.")
|
| 182 |
+
# Example:
|
| 183 |
+
from app.app import start_application
|
| 184 |
+
await start_application(fundaments)
|
| 185 |
+
|
| 186 |
+
finally:
|
| 187 |
+
# Ensure the database pool is closed gracefully on exit (if it was initialized)
|
| 188 |
+
if fundaments.get("db") is not None:
|
| 189 |
+
await close_db_pool()
|
| 190 |
+
logger.info("Database pool closed.")
|
| 191 |
+
logger.info("Application shut down.")
|
| 192 |
+
|
| 193 |
+
if __name__ == "__main__":
|
| 194 |
+
asyncio.run(main())
|
requirements.txt
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# PyFundaments Core Dependencies (always needed)
|
| 2 |
+
asyncpg
|
| 3 |
+
python-dotenv
|
| 4 |
+
passlib
|
| 5 |
+
cryptography
|
| 6 |
+
|
| 7 |
+
# MCP Hub Dependencies (needed for app/mcp.py)
|
| 8 |
+
fastmcp
|
| 9 |
+
httpx
|
| 10 |
+
|
| 11 |
+
# Additional dependencies for specific app types:
|
| 12 |
+
# Uncomment what your application needs
|
| 13 |
+
|
| 14 |
+
# File operations (ML, data processing, file uploads)
|
| 15 |
+
#aiofiles
|
| 16 |
+
|
| 17 |
+
# Web applications (Flask-based apps, APIs)
|
| 18 |
+
#Flask[async]
|
| 19 |
+
|
| 20 |
+
# HTTP client operations (API calls, webhooks)
|
| 21 |
+
#requests
|
| 22 |
+
|
| 23 |
+
# Discord bots
|
| 24 |
+
#discord.py
|
| 25 |
+
#PyNaCl
|
| 26 |
+
|
| 27 |
+
# Alternative PostgreSQL driver (if not using asyncpg)
|
| 28 |
+
#psycopg2-binary
|
| 29 |
+
|
| 30 |
+
# Production web server
|
| 31 |
+
#waitress
|