Refactor tenant-aware configuration system: remove tenant management code and associated templates
- Removed tenant configuration management code including TenantConfigManager and related functions. - Deleted tenant resolver and guards for module access control. - Eliminated tenant-aware error handling and associated HTML templates. - Cleaned up main application file by removing tenant-related imports and context processors. - Removed tenant configuration JSON file and related unit tests. Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
@@ -1,471 +0,0 @@
|
||||
# Tenant-Aware Configuration System
|
||||
|
||||
This document explains how to use the tenant-aware configuration system for module and feature management in the multi-tenant application.
|
||||
|
||||
## Overview
|
||||
|
||||
The tenant-aware configuration system allows each tenant (organization/school) to control which modules and features are available to their users. The system supports:
|
||||
|
||||
- **Global defaults**: Base configuration applied to all tenants
|
||||
- **Per-tenant overrides**: Tenant-specific module enables/disables
|
||||
- **Request-aware checks**: Module availability determined at runtime based on the active request tenant
|
||||
- **Safe fallbacks**: Missing configurations fail safely to sensible defaults
|
||||
- **Server-side enforcement**: UI elements and routes protected from unauthorized access
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **`tenant_resolver.py`**: Resolves the active tenant from incoming requests
|
||||
2. **`tenant_config.py`**: Manages configuration loading and module availability checks
|
||||
3. **`tenant_guards.py`**: Decorators and guards for protecting routes
|
||||
4. **`tenant_templates.py`**: Jinja2 helpers for template-based module checking
|
||||
5. **`tenants.json`**: Configuration file with global defaults and per-tenant settings
|
||||
|
||||
### Request Flow
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
↓
|
||||
resolve_tenant_context() [before_request]
|
||||
↓
|
||||
g.tenant_id = <resolved tenant>
|
||||
↓
|
||||
Route Handler / Template Rendering
|
||||
↓
|
||||
module_enabled() / @require_module() checks
|
||||
↓
|
||||
Allow or Deny access
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Configuration File: `tenants.json`
|
||||
|
||||
Located in the Website directory alongside `main.py`.
|
||||
|
||||
#### Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"module_name": true,
|
||||
"another_module": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"tenant_id": {
|
||||
"modules": {
|
||||
"module_name": false,
|
||||
"another_module": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Interpretation Rules
|
||||
|
||||
1. **Global defaults** (`defaults.modules`): Applied to all tenants unless overridden
|
||||
2. **Tenant overrides**: If a tenant specifies a module, that value is used instead
|
||||
3. **Missing modules**: If a tenant doesn't specify a module, the global default is used
|
||||
4. **Safe defaults**: Modules not configured anywhere default to `false` (disabled)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": true,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"school1": {
|
||||
"modules": {
|
||||
"appointments": true,
|
||||
"chat": true,
|
||||
"invoices": true
|
||||
}
|
||||
},
|
||||
"school2": {
|
||||
"modules": {
|
||||
"appointments": true,
|
||||
"blog": false,
|
||||
"chat": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In this example:
|
||||
- **School 1**: Has appointments, chat, and invoices enabled (plus blog from defaults). No tickets.
|
||||
- **School 2**: Has appointments enabled (plus inventarsystem and blog from defaults). No chat or tickets.
|
||||
|
||||
## Tenant Resolution
|
||||
|
||||
### Resolution Order
|
||||
|
||||
The system tries to resolve the tenant in this order:
|
||||
|
||||
1. **X-Tenant-ID header**: For internal APIs and testing
|
||||
```bash
|
||||
curl -H "X-Tenant-ID: school1" https://api.example.com/chat
|
||||
```
|
||||
|
||||
2. **Subdomain**: Based on the Host header (e.g., `school1.example.com` → `school1`)
|
||||
```
|
||||
school1.example.com/chat → tenant_id = "school1"
|
||||
www.example.com/chat → tenant_id = "default"
|
||||
```
|
||||
|
||||
3. **Fallback**: Uses `"default"` if no tenant can be resolved
|
||||
|
||||
### Configuration
|
||||
|
||||
The parent domain for subdomain extraction is set via environment variable:
|
||||
|
||||
```bash
|
||||
export INSTANCE_PARENT_DOMAIN="example.com"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### In Route Handlers
|
||||
|
||||
#### Basic Module Check
|
||||
|
||||
```python
|
||||
from flask import g
|
||||
from tenant_config import is_module_enabled
|
||||
|
||||
@app.route('/chat')
|
||||
def chat_page():
|
||||
if not is_module_enabled(g.tenant_id, 'chat'):
|
||||
abort(403)
|
||||
return render_template('chat.html')
|
||||
```
|
||||
|
||||
#### Using Decorators
|
||||
|
||||
```python
|
||||
from tenant_guards import require_module, require_admin
|
||||
|
||||
@app.route('/chat')
|
||||
@require_module('chat')
|
||||
def chat_page():
|
||||
return render_template('chat.html')
|
||||
|
||||
@app.route('/admin/dashboard')
|
||||
@require_admin()
|
||||
def admin_dashboard():
|
||||
return render_template('admin_dashboard.html')
|
||||
```
|
||||
|
||||
#### Getting Enabled Modules
|
||||
|
||||
```python
|
||||
from tenant_config import get_enabled_modules
|
||||
|
||||
@app.route('/api/features')
|
||||
def get_features():
|
||||
modules = get_enabled_modules(g.tenant_id)
|
||||
return jsonify({'enabled_modules': list(modules)})
|
||||
```
|
||||
|
||||
### In Templates
|
||||
|
||||
Use the context variables injected by `inject_tenant_context()`:
|
||||
|
||||
#### Check Single Module
|
||||
|
||||
```jinja2
|
||||
{% if module_enabled('chat') %}
|
||||
<li><a href="/chat">Chat</a></li>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
#### Show Multiple Navigation Items
|
||||
|
||||
```jinja2
|
||||
<nav>
|
||||
{% if module_enabled('blog') %}
|
||||
<li><a href="/blog">Blog</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('tickets') %}
|
||||
<li><a href="/tickets">Support</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('invoices') %}
|
||||
<li><a href="/my/invoices">Rechnungen</a></li>
|
||||
{% endif %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
#### Get All Enabled Modules
|
||||
|
||||
```jinja2
|
||||
<div class="features">
|
||||
{% for module in enabled_modules %}
|
||||
<span class="badge">{{ module }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Display Current Tenant
|
||||
|
||||
```jinja2
|
||||
<footer>
|
||||
Tenant: {{ tenant_id }}
|
||||
</footer>
|
||||
```
|
||||
|
||||
### In API Endpoints
|
||||
|
||||
```python
|
||||
@app.route('/api/features', methods=['GET'])
|
||||
def api_features():
|
||||
tenant_id = g.tenant_id
|
||||
|
||||
if request.accept_mimetypes.best_match(['application/json']) != 'application/json':
|
||||
abort(406)
|
||||
|
||||
# Check module availability for JSON response
|
||||
if not is_module_enabled(tenant_id, 'chat'):
|
||||
return jsonify({
|
||||
'error': 'Module not available',
|
||||
'message': 'The chat module is not available for your organization.'
|
||||
}), 403
|
||||
|
||||
return jsonify({'status': 'ok'})
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Existing Routes to Update
|
||||
|
||||
To complete the integration, routes for disabled modules should check availability:
|
||||
|
||||
1. **`/chat`** - Add `@require_module('chat')`
|
||||
2. **`/tickets`** - Add `@require_module('tickets')`
|
||||
3. **`/admin/invoices`** - Add `@require_module('invoices')` + `@require_admin()`
|
||||
4. **`/blog`** - Add `@require_module('blog')`
|
||||
5. **`/appointments`** - Add `@require_module('appointments')`
|
||||
6. **`/admin/chats`** - Add `@require_module('chat')` + `@require_admin()`
|
||||
7. **`/admin/tickets`** - Add `@require_module('tickets')` + `@require_admin()`
|
||||
|
||||
### Templates to Update
|
||||
|
||||
Navigation templates (`base.html`, `admin_dashboard.html`, etc.) should use:
|
||||
|
||||
```jinja2
|
||||
{% if module_enabled('module_name') %}
|
||||
<!-- Show UI element -->
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Runtime Behavior
|
||||
|
||||
### Disabled Module Access
|
||||
|
||||
When a user tries to access a disabled module:
|
||||
|
||||
#### HTML Requests
|
||||
- Route returns HTTP 403 Forbidden
|
||||
- User sees `error_403.html` template
|
||||
|
||||
#### JSON Requests
|
||||
- Route returns HTTP 403 with JSON error response:
|
||||
```json
|
||||
{
|
||||
"error": "Module not available",
|
||||
"message": "The chat module is not available for your organization.",
|
||||
"module": "chat"
|
||||
}
|
||||
```
|
||||
|
||||
### Direct URL Access
|
||||
|
||||
Protected routes reject direct access when modules are disabled, preventing workarounds.
|
||||
|
||||
### Safe Defaults
|
||||
|
||||
- Missing tenant configurations inherit global defaults
|
||||
- Invalid tenant IDs fall back to 'default' tenant
|
||||
- Modules not configured anywhere default to disabled
|
||||
- The system remains stable even if `tenants.json` is missing
|
||||
|
||||
## Adding New Modules
|
||||
|
||||
### Step 1: Add to Configuration
|
||||
|
||||
Edit `tenants.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"new_feature": true
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"school1": {
|
||||
"modules": {
|
||||
"new_feature": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Add Route Guard
|
||||
|
||||
```python
|
||||
@app.route('/new-feature')
|
||||
@require_module('new_feature')
|
||||
def new_feature():
|
||||
return render_template('new_feature.html')
|
||||
```
|
||||
|
||||
### Step 3: Update Templates
|
||||
|
||||
```jinja2
|
||||
{% if module_enabled('new_feature') %}
|
||||
<li><a href="/new-feature">New Feature</a></li>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Reloading Configuration
|
||||
|
||||
The configuration is loaded at application startup. To reload without restarting:
|
||||
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
|
||||
manager = get_config_manager()
|
||||
manager.reload()
|
||||
```
|
||||
|
||||
### Accessing Configuration Programmatically
|
||||
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
|
||||
manager = get_config_manager()
|
||||
|
||||
# Get complete tenant config (merged with defaults)
|
||||
config = manager.get_tenant_config('school1')
|
||||
|
||||
# Get all enabled modules for a tenant
|
||||
modules = manager.get_enabled_modules('school1')
|
||||
|
||||
# Get a specific config value
|
||||
value = manager.get_config_value('school1', 'custom_setting', default='fallback')
|
||||
|
||||
# Get all modules defined in configuration
|
||||
all_modules = manager.get_all_modules()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Testing with curl
|
||||
|
||||
```bash
|
||||
# Test without tenant header (uses default)
|
||||
curl http://example.com/chat
|
||||
|
||||
# Test with X-Tenant-ID header
|
||||
curl -H "X-Tenant-ID: school1" http://example.com/chat
|
||||
|
||||
# Test with subdomain
|
||||
curl http://school1.example.com/chat
|
||||
|
||||
# Check features endpoint
|
||||
curl -H "X-Tenant-ID: school1" http://example.com/api/features
|
||||
```
|
||||
|
||||
### Testing in Python
|
||||
|
||||
```python
|
||||
from tenant_resolver import resolve_tenant, extract_subdomain
|
||||
from tenant_config import is_module_enabled, get_enabled_modules
|
||||
|
||||
# Test subdomain extraction
|
||||
assert extract_subdomain("school1.example.com", "example.com") == "school1"
|
||||
assert extract_subdomain("www.example.com", "example.com") == "www"
|
||||
assert extract_subdomain("example.com", "example.com") is None
|
||||
|
||||
# Test module availability
|
||||
assert is_module_enabled("school1", "chat") == True
|
||||
assert is_module_enabled("school2", "chat") == False
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system handles these error cases gracefully:
|
||||
|
||||
1. **Missing `tenants.json`**: Uses empty configuration with safe defaults
|
||||
2. **Malformed JSON**: Logs error, uses empty configuration
|
||||
3. **Invalid tenant ID**: Falls back to 'default' tenant
|
||||
4. **Missing module config**: Defaults to disabled (safe default)
|
||||
5. **Missing tenant section**: Inherits all global defaults
|
||||
6. **Invalid module values**: Treated as disabled
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Module checks are **enforced server-side** on every request
|
||||
- UI elements are hidden, but routes are protected
|
||||
- Users cannot bypass disabled modules through direct URL access
|
||||
- API endpoints return consistent 403 responses for disabled modules
|
||||
- Tenant resolution supports both internal headers and public subdomains
|
||||
- Configuration is read-only at runtime (safe from modification)
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use decorators** for route protection
|
||||
2. **Hide UI elements** in templates when modules are disabled
|
||||
3. **Return consistent errors** for disabled modules
|
||||
4. **Test tenant switching** to ensure proper isolation
|
||||
5. **Keep defaults conservative** and override with caution
|
||||
6. **Document custom modules** in `tenants.json` comments
|
||||
7. **Monitor module availability** in user-facing error messages
|
||||
8. **Version control `tenants.json`** for production deployments
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Module appears disabled but shouldn't be
|
||||
|
||||
1. Check `tenants.json` for correct tenant ID spelling
|
||||
2. Verify `INSTANCE_PARENT_DOMAIN` environment variable
|
||||
3. Confirm tenant is resolving correctly (check `g.tenant_id`)
|
||||
4. Reload configuration if recently changed
|
||||
|
||||
### Tenant not resolving correctly
|
||||
|
||||
1. Check X-Tenant-ID header format
|
||||
2. Verify subdomain matches parent domain
|
||||
3. Confirm no port conflicts in Host header
|
||||
4. Check request logs for resolution details
|
||||
|
||||
### Configuration changes not taking effect
|
||||
|
||||
1. Config is loaded at startup only
|
||||
2. Restart application to reload `tenants.json`
|
||||
3. Or call `get_config_manager().reload()`
|
||||
|
||||
## Example: Complete Integration
|
||||
|
||||
See the default configuration in `tenants.json` for a working example with:
|
||||
- Global defaults for common modules
|
||||
- School tenant with full features
|
||||
- Partner organization with limited features
|
||||
@@ -1,592 +0,0 @@
|
||||
# Tenant Configuration Examples
|
||||
|
||||
This document provides practical examples of tenant configurations for different scenarios.
|
||||
|
||||
## Example 1: Education Provider with Multiple Schools
|
||||
|
||||
Each school gets different features based on their subscription level.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": true,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"gymnasium-berlin": {
|
||||
"description": "Premium school - all features",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"realschule-munich": {
|
||||
"description": "Standard school - core features",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": true,
|
||||
"invoices": false,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"grundschule-hamburg": {
|
||||
"description": "Basic school - inventory only",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": false,
|
||||
"admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Gymnasium Berlin: Full access to all features
|
||||
- Realschule Munich: Inventory, appointments, tickets, team management
|
||||
- Grundschule Hamburg: Inventory system only (read-only or restricted admin)
|
||||
|
||||
---
|
||||
|
||||
## Example 2: SaaS Multi-Tenant Platform
|
||||
|
||||
Supporting different customer types with appropriate feature sets.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"startup-acme": {
|
||||
"description": "Startup plan - minimal features",
|
||||
"modules": {
|
||||
"invoices": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"enterprise-bigcorp": {
|
||||
"description": "Enterprise plan - all features",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"agency-webdesign": {
|
||||
"description": "Agency plan - client-facing features",
|
||||
"modules": {
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"appointments": true,
|
||||
"invoices": true,
|
||||
"tickets": true,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Startup: Only invoicing and basic admin
|
||||
- Enterprise: Complete platform access
|
||||
- Agency: Client-facing + internal management
|
||||
|
||||
---
|
||||
|
||||
## Example 3: Regional Service Provider
|
||||
|
||||
Different regions/branches get different feature access.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": false,
|
||||
"appointments": true,
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"branch-north": {
|
||||
"description": "Northern branch - full service",
|
||||
"modules": {
|
||||
"admin": true,
|
||||
"invoices": true
|
||||
}
|
||||
},
|
||||
"branch-south": {
|
||||
"description": "Southern branch - no invoicing yet",
|
||||
"modules": {
|
||||
"admin": true,
|
||||
"invoices": false
|
||||
}
|
||||
},
|
||||
"branch-east": {
|
||||
"description": "Eastern branch - new, limited services",
|
||||
"modules": {
|
||||
"appointments": false,
|
||||
"tickets": false,
|
||||
"chat": false,
|
||||
"invoices": false,
|
||||
"admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- North: Full platform with invoicing
|
||||
- South: Full platform except invoicing
|
||||
- East: Limited to blog, services, projects, team, contact
|
||||
|
||||
---
|
||||
|
||||
## Example 4: Progressive Rollout
|
||||
|
||||
Enable features gradually for tenants during pilot periods.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": true,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"pilot-tenant-1": {
|
||||
"description": "Pilot for chat feature",
|
||||
"modules": {
|
||||
"chat": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"pilot-tenant-2": {
|
||||
"description": "Pilot for tickets and invoices",
|
||||
"modules": {
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"general-users": {
|
||||
"description": "Not in any pilot - uses defaults",
|
||||
"modules": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Pilot Tenant 1: Testing chat feature + admin
|
||||
- Pilot Tenant 2: Testing tickets and invoicing
|
||||
- General Users: Standard feature set (defaults)
|
||||
|
||||
---
|
||||
|
||||
## Example 5: Partner/Vendor Access
|
||||
|
||||
External partners with limited, specific access.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": false,
|
||||
"appointments": false,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"partner-logistics": {
|
||||
"description": "Logistics partner - can manage appointments",
|
||||
"modules": {
|
||||
"appointments": true,
|
||||
"chat": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"partner-support": {
|
||||
"description": "Support partner - can manage tickets",
|
||||
"modules": {
|
||||
"tickets": true,
|
||||
"chat": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"partner-billing": {
|
||||
"description": "Billing partner - can manage invoices",
|
||||
"modules": {
|
||||
"invoices": true,
|
||||
"chat": true,
|
||||
"admin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Logistics Partner: Appointments, chat, limited admin
|
||||
- Support Partner: Tickets, chat, limited admin
|
||||
- Billing Partner: Invoices, chat, limited admin
|
||||
|
||||
---
|
||||
|
||||
## Example 6: Feature Flags for A/B Testing
|
||||
|
||||
Use tenant config as feature flags for experiments.
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": true,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false,
|
||||
"new_dashboard": false,
|
||||
"advanced_reporting": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"test-group-a": {
|
||||
"description": "A/B test group A - new UI enabled",
|
||||
"modules": {
|
||||
"new_dashboard": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"test-group-b": {
|
||||
"description": "A/B test group B - advanced reporting",
|
||||
"modules": {
|
||||
"advanced_reporting": true,
|
||||
"admin": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:**
|
||||
- Test Group A: Can see and test new dashboard
|
||||
- Test Group B: Can see and test advanced reporting
|
||||
- Default: Standard interface
|
||||
|
||||
---
|
||||
|
||||
## Configuration Patterns
|
||||
|
||||
### Pattern 1: Additive (Start Minimal, Add Features)
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": { "all": false }
|
||||
},
|
||||
"tenants": {
|
||||
"customer1": {
|
||||
"modules": {
|
||||
"feature_x": true,
|
||||
"feature_y": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Best for:** Platforms where most customers get limited features
|
||||
|
||||
---
|
||||
|
||||
### Pattern 2: Subtractive (Start Full, Remove Features)
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": { "all": true }
|
||||
},
|
||||
"tenants": {
|
||||
"limited_customer": {
|
||||
"modules": {
|
||||
"premium_feature": false,
|
||||
"advanced_reporting": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Best for:** Platforms where most customers get full access
|
||||
|
||||
---
|
||||
|
||||
### Pattern 3: Hybrid (Different Defaults by Context)
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"core_feature": true,
|
||||
"premium_feature": false,
|
||||
"beta_feature": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"premium_customer": {
|
||||
"modules": {
|
||||
"premium_feature": true,
|
||||
"beta_feature": true
|
||||
}
|
||||
},
|
||||
"basic_customer": {
|
||||
"modules": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Best for:** Most platforms with mixed customer types
|
||||
|
||||
---
|
||||
|
||||
## Testing Configurations
|
||||
|
||||
### Quick Test Setup
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"test-all-enabled": {
|
||||
"modules": {}
|
||||
},
|
||||
"test-all-disabled": {
|
||||
"modules": {
|
||||
"inventarsystem": false,
|
||||
"appointments": false,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Load Testing with Multiple Tenants
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": { "inventarsystem": true }
|
||||
},
|
||||
"tenants": {
|
||||
"load-tenant-1": {},
|
||||
"load-tenant-2": {},
|
||||
"load-tenant-3": {},
|
||||
"load-tenant-4": {},
|
||||
"load-tenant-5": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Examples
|
||||
|
||||
### From Hardcoded Checks to Config
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
def chat():
|
||||
if current_user.organization_id != "school1":
|
||||
abort(403)
|
||||
return render_template('chat.html')
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
@app.route('/chat')
|
||||
@require_module('chat')
|
||||
def chat():
|
||||
return render_template('chat.html')
|
||||
```
|
||||
|
||||
Config in `tenants.json`:
|
||||
```json
|
||||
{
|
||||
"tenants": {
|
||||
"school1": {
|
||||
"modules": { "chat": true }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices for Configuration
|
||||
|
||||
1. **Start conservative**: Disable modules by default, enable per tenant
|
||||
2. **Document decisions**: Add `"description"` fields explaining why
|
||||
3. **Use consistent naming**: Module names should match route/feature names
|
||||
4. **Version your config**: Keep `tenants.json` in version control
|
||||
5. **Test thoroughly**: Use test tenants for new features
|
||||
6. **Monitor usage**: Track which modules are actually used per tenant
|
||||
7. **Plan migrations**: Have a strategy for upgrading tenants
|
||||
8. **Document changes**: Comment when enabling/disabling modules
|
||||
|
||||
---
|
||||
|
||||
## Accessing Configuration Programmatically
|
||||
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
|
||||
manager = get_config_manager()
|
||||
|
||||
# Print all enabled modules for school1
|
||||
config = manager.get_tenant_config('school1')
|
||||
print(config['modules'])
|
||||
|
||||
# Check if specific module is enabled
|
||||
if manager.is_module_enabled('school1', 'chat'):
|
||||
print("Chat is enabled for school1")
|
||||
|
||||
# Get all tenants with chat enabled
|
||||
all_modules = manager.get_all_modules()
|
||||
for module in all_modules:
|
||||
print(f"Module: {module}")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Configuration Issues
|
||||
|
||||
```bash
|
||||
# Print current configuration
|
||||
python3 -c "
|
||||
from tenant_config import get_config_manager
|
||||
import json
|
||||
|
||||
manager = get_config_manager()
|
||||
print('school1 config:')
|
||||
print(json.dumps(manager.get_tenant_config('school1'), indent=2))
|
||||
|
||||
print('Enabled modules:')
|
||||
print(manager.get_enabled_modules('school1'))
|
||||
|
||||
print('All modules in config:')
|
||||
print(manager.get_all_modules())
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Configuration is loaded once at startup
|
||||
- Module checks are O(1) dictionary lookups
|
||||
- Safe to call frequently in route handlers
|
||||
- Consider caching results if checking many tenants in a loop
|
||||
|
||||
For production deployments, monitor configuration reload times and cache
|
||||
appropriately if configuration changes frequently.
|
||||
@@ -1,346 +0,0 @@
|
||||
# Tenant-Aware Configuration System - Implementation Summary
|
||||
|
||||
This document provides a high-level overview of what has been implemented and what needs to be done to complete the integration.
|
||||
|
||||
## What Has Been Implemented ✅
|
||||
|
||||
### 1. Core Modules
|
||||
|
||||
#### `tenant_resolver.py` ✅
|
||||
- Extracts tenant ID from incoming requests
|
||||
- Supports X-Tenant-ID header (for APIs and testing)
|
||||
- Supports subdomain-based resolution (e.g., school1.example.com)
|
||||
- Falls back to 'default' tenant when not resolvable
|
||||
- Fully configurable parent domain via environment variable
|
||||
|
||||
#### `tenant_config.py` ✅
|
||||
- `TenantConfigManager` class for managing configurations
|
||||
- Loads `tenants.json` at startup
|
||||
- Merges global defaults with per-tenant overrides
|
||||
- Module availability checking with safe defaults
|
||||
- Configuration caching and reloading
|
||||
- Comprehensive error handling for missing/invalid configs
|
||||
|
||||
#### `tenant_guards.py` ✅
|
||||
- `@require_module(module_name)` decorator for route protection
|
||||
- `@require_admin()` decorator for admin-only routes
|
||||
- Module availability checking in route handlers
|
||||
- Consistent error responses for JSON and HTML requests
|
||||
- Helper class for error generation
|
||||
|
||||
#### `tenant_templates.py` ✅
|
||||
- `inject_tenant_context()` Jinja2 context processor
|
||||
- `module_enabled()` function for templates
|
||||
- `enabled_modules` set available to all templates
|
||||
- `tenant_id` variable for debugging
|
||||
|
||||
### 2. Configuration
|
||||
|
||||
#### `tenants.json` ✅
|
||||
- Global defaults section with all modules
|
||||
- Per-tenant overrides with example configurations
|
||||
- Three example tenants showing different feature sets
|
||||
- Well-documented structure
|
||||
|
||||
#### `main.py` - Integration ✅
|
||||
- Imported all tenant modules
|
||||
- Added `before_request` handler to resolve tenant
|
||||
- Registered Jinja2 context processor
|
||||
- Added 403 error handler with custom responses
|
||||
- Ready for decorator usage on routes
|
||||
|
||||
### 3. Templates
|
||||
|
||||
#### `templates/error_403.html` ✅
|
||||
- User-friendly 403 error page
|
||||
- Shows tenant information
|
||||
- Responsive design
|
||||
- Links back to home page
|
||||
|
||||
### 4. Testing & Documentation
|
||||
|
||||
#### `test_tenant_system.py` ✅
|
||||
- Comprehensive unit tests for all components
|
||||
- Tests for edge cases and error handling
|
||||
- Fixtures for test data
|
||||
- Ready to run with pytest
|
||||
|
||||
#### Documentation ✅
|
||||
- `TENANT_CONFIG.md` - Complete system documentation
|
||||
- `TENANT_INTEGRATION_GUIDE.md` - Step-by-step integration instructions
|
||||
- `TENANT_CONFIG_EXAMPLES.md` - Real-world configuration examples
|
||||
- This summary document
|
||||
|
||||
## What Still Needs To Be Done ⚠️
|
||||
|
||||
### 1. Add Route Decorators (Priority: HIGH)
|
||||
|
||||
Add `@require_module()` and/or `@require_admin()` decorators to these routes in `main.py`:
|
||||
|
||||
**Feature Routes (add `@require_module('module_name')`)**
|
||||
- [ ] `/blog` (line ~2480)
|
||||
- [ ] `/blog/<post_id>` (line ~2498)
|
||||
- [ ] `/chat` (line ~2577)
|
||||
- [ ] `/tickets` (line ~2617)
|
||||
- [ ] `/appointments` (line ~1742)
|
||||
- [ ] `/appointments/book-option` (line ~1783)
|
||||
- [ ] `/my/invoices` (line ~2523)
|
||||
|
||||
**Admin Feature Routes (add `@require_admin()` + `@require_module('module_name')`)**
|
||||
- [ ] `/admin/blog` (line ~2402)
|
||||
- [ ] `/admin/chats` (line ~2815)
|
||||
- [ ] `/admin/tickets` (line ~2866)
|
||||
- [ ] `/admin/appointments/block-day` (line ~2308)
|
||||
- [ ] `/admin/appointment/<appointment_id>` (line ~2357)
|
||||
- [ ] `/admin/invoices` (line ~2913)
|
||||
|
||||
**Admin Core Routes (add `@require_admin()`)**
|
||||
- [ ] `/admin/dashboard` (line ~1825)
|
||||
- [ ] `/admin/instances` (line ~1869)
|
||||
- [ ] `/admin/instances/stats` (line ~2103)
|
||||
- [ ] `/admin/system` (line ~2110)
|
||||
- [ ] `/admin/system/stats` (line ~2191)
|
||||
- [ ] `/admin/system/logs/live` (line ~2198)
|
||||
- [ ] `/admin/system/logs/core` (line ~2204)
|
||||
- [ ] `/admin/system/logs/instance/<subdomain>` (line ~2221)
|
||||
- [ ] `/admin/system/backup/export/<subdomain>` (line ~2239)
|
||||
- [ ] `/admin/system/backup/import/<subdomain>` (line ~2263)
|
||||
- [ ] `/admin/users` (line ~2666)
|
||||
- [ ] `/admin/team` (line ~2698)
|
||||
|
||||
See `TENANT_INTEGRATION_GUIDE.md` for exact code snippets.
|
||||
|
||||
### 2. Update Templates (Priority: HIGH)
|
||||
|
||||
Add module visibility checks to these templates:
|
||||
|
||||
**Navigation Templates**
|
||||
- [ ] `templates/base.html` - Add `{% if module_enabled('module_name') %}` checks to navigation
|
||||
- [ ] `templates/admin_dashboard.html` - Add checks to admin menu items
|
||||
|
||||
**Feature Templates**
|
||||
- [ ] `templates/blog.html`
|
||||
- [ ] `templates/chat.html`
|
||||
- [ ] `templates/tickets.html`
|
||||
- [ ] `templates/appointments.html`
|
||||
- [ ] `templates/my_invoices.html`
|
||||
|
||||
See `TENANT_CONFIG.md` for template examples.
|
||||
|
||||
### 3. Configure Environment (Priority: MEDIUM)
|
||||
|
||||
Set up environment variables:
|
||||
|
||||
```bash
|
||||
# Set the parent domain for subdomain extraction
|
||||
export INSTANCE_PARENT_DOMAIN="your-domain.com"
|
||||
|
||||
# Other existing variables remain unchanged
|
||||
```
|
||||
|
||||
### 4. Testing (Priority: MEDIUM)
|
||||
|
||||
- [ ] Run unit tests: `pytest test_tenant_system.py -v`
|
||||
- [ ] Test with curl/Postman:
|
||||
- [ ] Test default tenant access
|
||||
- [ ] Test X-Tenant-ID header
|
||||
- [ ] Test subdomain resolution
|
||||
- [ ] Test 403 responses for disabled modules
|
||||
- [ ] Test UI with different tenants
|
||||
- [ ] Verify JSON API responses
|
||||
|
||||
### 5. Customize Configuration (Priority: LOW)
|
||||
|
||||
- [ ] Review `tenants.json` and adjust for your needs
|
||||
- [ ] Add/remove modules as needed
|
||||
- [ ] Configure specific tenants
|
||||
- [ ] Add custom tenant-specific settings (optional)
|
||||
|
||||
## Quick Start Checklist
|
||||
|
||||
To get the system working in 30 minutes:
|
||||
|
||||
```bash
|
||||
# 1. Verify files are in place
|
||||
ls Website/tenant_*.py
|
||||
ls Website/tenants.json
|
||||
ls Website/templates/error_403.html
|
||||
|
||||
# 2. Set environment variable
|
||||
export INSTANCE_PARENT_DOMAIN="meine-domain"
|
||||
|
||||
# 3. Run tests
|
||||
cd Website
|
||||
pytest test_tenant_system.py -v
|
||||
|
||||
# 4. Start Flask app
|
||||
python main.py
|
||||
|
||||
# 5. Test in another terminal
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
```
|
||||
|
||||
## Implementation Order (Recommended)
|
||||
|
||||
1. **Test the system first** (20 minutes)
|
||||
- Run unit tests
|
||||
- Verify tenant resolution
|
||||
|
||||
2. **Update one feature** (15 minutes)
|
||||
- Pick one feature (e.g., chat)
|
||||
- Add decorator to route
|
||||
- Add checks to template
|
||||
- Test manually
|
||||
|
||||
3. **Update remaining features** (varies)
|
||||
- Add decorators to all routes
|
||||
- Update all templates
|
||||
- Test each feature
|
||||
|
||||
4. **Deploy and monitor**
|
||||
- Deploy to production
|
||||
- Monitor for 403 errors
|
||||
- Watch module access logs
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Request
|
||||
↓
|
||||
[Tenant Resolution] ← resolve_tenant_context()
|
||||
- Check X-Tenant-ID header
|
||||
- Check subdomain
|
||||
- Fall back to 'default'
|
||||
↓
|
||||
g.tenant_id = 'school1'
|
||||
↓
|
||||
[Route Handler]
|
||||
- @require_module('chat') decorator checks config
|
||||
- tenant_config.is_module_enabled('school1', 'chat')
|
||||
- Allows or denies access
|
||||
↓
|
||||
[Response]
|
||||
- 200 OK if allowed
|
||||
- 403 Forbidden if denied
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
✅ **Tenant Resolution**: Auto-detect tenant from subdomain or header
|
||||
✅ **Centralized Config**: All settings in one `tenants.json` file
|
||||
✅ **Per-Tenant Overrides**: Each tenant can customize features
|
||||
✅ **Safe Defaults**: Missing configs fail safely
|
||||
✅ **Runtime Checks**: Module availability determined per-request
|
||||
✅ **Server-Side Enforcement**: Routes and APIs protected
|
||||
✅ **UI Integration**: Templates can show/hide elements
|
||||
✅ **Error Handling**: Consistent 403 responses
|
||||
✅ **Testing**: Comprehensive test suite included
|
||||
✅ **Documentation**: Complete guides and examples
|
||||
|
||||
## File Inventory
|
||||
|
||||
```
|
||||
Website/
|
||||
├── tenant_resolver.py ✅ (New)
|
||||
├── tenant_config.py ✅ (New)
|
||||
├── tenant_guards.py ✅ (New)
|
||||
├── tenant_templates.py ✅ (New)
|
||||
├── tenants.json ✅ (New)
|
||||
├── test_tenant_system.py ✅ (New)
|
||||
├── main.py ✅ (Updated)
|
||||
└── templates/
|
||||
└── error_403.html ✅ (New)
|
||||
|
||||
Project Root/
|
||||
├── TENANT_CONFIG.md ✅ (New - Main documentation)
|
||||
├── TENANT_INTEGRATION_GUIDE.md ✅ (New - Step-by-step guide)
|
||||
├── TENANT_CONFIG_EXAMPLES.md ✅ (New - Real-world examples)
|
||||
└── TENANT_IMPLEMENTATION_SUMMARY.md ✅ (This file)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Configuration loaded once at startup (~10ms)
|
||||
- Module checks are O(1) dictionary lookups (~<1ms)
|
||||
- Context processor runs for every request (~<1ms)
|
||||
- Minimal overhead added to request handling
|
||||
|
||||
## Security Considerations
|
||||
|
||||
✅ Tenant resolution robust against injection attacks
|
||||
✅ Server-side enforcement prevents UI bypass
|
||||
✅ Module checks before any data access
|
||||
✅ Configuration immutable at runtime
|
||||
✅ Error responses don't leak sensitive info
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Review** the documentation in `TENANT_CONFIG.md`
|
||||
2. **Test** the unit tests to verify everything works
|
||||
3. **Update routes** following `TENANT_INTEGRATION_GUIDE.md`
|
||||
4. **Update templates** to show/hide UI elements
|
||||
5. **Configure** `tenants.json` for your tenants
|
||||
6. **Deploy** and monitor for issues
|
||||
|
||||
## Support & Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Q: Module not working even though it's enabled?**
|
||||
A: Verify the route has the decorator and the module name in `tenants.json` matches exactly.
|
||||
|
||||
**Q: Tenant not resolving from subdomain?**
|
||||
A: Check `INSTANCE_PARENT_DOMAIN` environment variable matches your domain.
|
||||
|
||||
**Q: Configuration changes not taking effect?**
|
||||
A: Configuration is loaded at startup. Either restart the app or call `manager.reload()`.
|
||||
|
||||
**Q: Getting 403 when should have access?**
|
||||
A: Check tenant resolution is working correctly (add debug logging), verify `tenants.json` config.
|
||||
|
||||
### Debug Mode
|
||||
|
||||
Add this to `main.py` for debugging:
|
||||
|
||||
```python
|
||||
@app.before_request
|
||||
def debug_tenant():
|
||||
print(f"DEBUG: Tenant={g.tenant_id}, Host={request.host}")
|
||||
```
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
- Custom tenant detection logic
|
||||
- Dynamic configuration loading (without restart)
|
||||
- Multi-level feature hierarchies
|
||||
- Tenant-specific rate limiting
|
||||
- Audit logging per tenant
|
||||
- Feature analytics
|
||||
|
||||
See `TENANT_CONFIG.md` for advanced usage examples.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**What you have:**
|
||||
- ✅ Complete tenant-aware configuration system
|
||||
- ✅ All core modules implemented and tested
|
||||
- ✅ Integration with Flask
|
||||
- ✅ Template support
|
||||
- ✅ Comprehensive documentation
|
||||
|
||||
**What you need to do:**
|
||||
- ⚠️ Add decorators to ~25 routes
|
||||
- ⚠️ Update templates to show/hide elements
|
||||
- ⚠️ Test and validate
|
||||
- ⚠️ Deploy to production
|
||||
|
||||
**Estimated effort:**
|
||||
- Routes: 30 minutes
|
||||
- Templates: 30 minutes
|
||||
- Testing: 30 minutes
|
||||
- Total: ~90 minutes for full integration
|
||||
|
||||
**Next action:**
|
||||
Start with the implementation guide: `TENANT_INTEGRATION_GUIDE.md`
|
||||
@@ -1,392 +0,0 @@
|
||||
# Integration Guide: Adding Tenant Module Guards to Existing Routes
|
||||
|
||||
This guide shows exactly where and how to add module guards to existing routes in `main.py`.
|
||||
|
||||
## Quick Summary
|
||||
|
||||
The system is now partially integrated into `main.py`:
|
||||
- ✅ Tenant resolution in `before_request` middleware
|
||||
- ✅ Template context injection
|
||||
- ✅ 403 error handling
|
||||
- ⚠️ Routes still need explicit module guards
|
||||
|
||||
This document guides you through adding guards to specific routes.
|
||||
|
||||
## Module-to-Route Mapping
|
||||
|
||||
| Module | Routes | Admin Routes |
|
||||
|--------|--------|--------------|
|
||||
| `blog` | `/blog`, `/blog/<post_id>` | `/admin/blog` |
|
||||
| `chat` | `/chat` | `/admin/chats` |
|
||||
| `tickets` | `/tickets` | `/admin/tickets` |
|
||||
| `appointments` | `/appointments`, `/appointments/book-option` | `/admin/appointments/block-day`, `/admin/appointment/<id>` |
|
||||
| `invoices` | `/my/invoices` | `/admin/invoices` |
|
||||
| `inventarsystem` | `/inventarsystem` | - |
|
||||
| `admin` | - | `/admin/*` (all admin routes) |
|
||||
|
||||
## Step-by-Step Integration
|
||||
|
||||
### 1. Import the Guards
|
||||
|
||||
This is **already done** in the updated `main.py`:
|
||||
|
||||
```python
|
||||
from tenant_guards import require_module, require_admin, module_enabled_in_context
|
||||
```
|
||||
|
||||
### 2. Add Route Guards
|
||||
|
||||
Find each route in `main.py` and add the appropriate decorator.
|
||||
|
||||
#### Example: Blog Module
|
||||
|
||||
**Current code** (around line 2402):
|
||||
```python
|
||||
@app.route('/blog', methods=['GET', 'POST'])
|
||||
def blog():
|
||||
# ...
|
||||
```
|
||||
|
||||
**Updated code**:
|
||||
```python
|
||||
@app.route('/blog', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('blog')
|
||||
def blog():
|
||||
# ...
|
||||
```
|
||||
|
||||
#### Example: Chat Module
|
||||
|
||||
**Current code** (around line 2577):
|
||||
```python
|
||||
@app.route('/chat', methods=['GET', 'POST'])
|
||||
def chat():
|
||||
# ...
|
||||
```
|
||||
|
||||
**Updated code**:
|
||||
```python
|
||||
@app.route('/chat', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('chat')
|
||||
def chat():
|
||||
# ...
|
||||
```
|
||||
|
||||
### 3. Pattern for All Routes
|
||||
|
||||
For each route, add the appropriate decorator(s):
|
||||
|
||||
```python
|
||||
# Public feature route
|
||||
@app.route('/feature-path')
|
||||
@tenant_guards.require_module('feature_name')
|
||||
def feature_handler():
|
||||
pass
|
||||
|
||||
# Admin feature route (requires both admin and specific module)
|
||||
@app.route('/admin/feature-path')
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('feature_name')
|
||||
def admin_feature_handler():
|
||||
pass
|
||||
```
|
||||
|
||||
## Specific Route Updates
|
||||
|
||||
### Blog Routes
|
||||
|
||||
**Line ~2402**: Admin blog
|
||||
```python
|
||||
@app.route('/admin/blog', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('blog')
|
||||
def admin_blog():
|
||||
```
|
||||
|
||||
**Line ~2480**: Public blog
|
||||
```python
|
||||
@app.route('/blog')
|
||||
@tenant_guards.require_module('blog')
|
||||
def blog():
|
||||
```
|
||||
|
||||
**Line ~2498**: Blog post detail
|
||||
```python
|
||||
@app.route('/blog/<post_id>')
|
||||
@tenant_guards.require_module('blog')
|
||||
def blog_post(post_id):
|
||||
```
|
||||
|
||||
### Chat Routes
|
||||
|
||||
**Line ~2577**: Chat page
|
||||
```python
|
||||
@app.route('/chat', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('chat')
|
||||
def chat():
|
||||
```
|
||||
|
||||
**Line ~2815**: Admin chats
|
||||
```python
|
||||
@app.route('/admin/chats', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('chat')
|
||||
def admin_chats():
|
||||
```
|
||||
|
||||
### Tickets Routes
|
||||
|
||||
**Line ~2617**: Tickets
|
||||
```python
|
||||
@app.route('/tickets', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('tickets')
|
||||
def tickets():
|
||||
```
|
||||
|
||||
**Line ~2866**: Admin tickets
|
||||
```python
|
||||
@app.route('/admin/tickets', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('tickets')
|
||||
def admin_tickets():
|
||||
```
|
||||
|
||||
### Appointments Routes
|
||||
|
||||
**Line ~1742**: Appointments list
|
||||
```python
|
||||
@app.route('/appointments', methods=['GET'])
|
||||
@tenant_guards.require_module('appointments')
|
||||
def appointments():
|
||||
```
|
||||
|
||||
**Line ~1783**: Book appointment option
|
||||
```python
|
||||
@app.route('/appointments/book-option', methods=['POST'])
|
||||
@tenant_guards.require_module('appointments')
|
||||
def book_appointment_option():
|
||||
```
|
||||
|
||||
**Line ~2308**: Admin block day
|
||||
```python
|
||||
@app.route('/admin/appointments/block-day', methods=['POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('appointments')
|
||||
def admin_block_day():
|
||||
```
|
||||
|
||||
**Line ~2357**: Admin manage appointment
|
||||
```python
|
||||
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('appointments')
|
||||
def admin_appointment(appointment_id):
|
||||
```
|
||||
|
||||
### Invoices Routes
|
||||
|
||||
**Line ~2523**: My invoices
|
||||
```python
|
||||
@app.route('/my/invoices')
|
||||
@tenant_guards.require_module('invoices')
|
||||
def my_invoices():
|
||||
```
|
||||
|
||||
**Line ~2913**: Admin invoices
|
||||
```python
|
||||
@app.route('/admin/invoices', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('invoices')
|
||||
def admin_invoices():
|
||||
```
|
||||
|
||||
### Admin Routes (ALL require admin module)
|
||||
|
||||
All routes under `/admin/*` should have `@tenant_guards.require_admin()`:
|
||||
|
||||
- **Line ~1825**: `/admin/dashboard`
|
||||
- **Line ~1869**: `/admin/instances`
|
||||
- **Line ~2103**: `/admin/instances/stats`
|
||||
- **Line ~2110**: `/admin/system`
|
||||
- **Line ~2191**: `/admin/system/stats`
|
||||
- **Line ~2198**: `/admin/system/logs/live`
|
||||
- **Line ~2204**: `/admin/system/logs/core`
|
||||
- **Line ~2221**: `/admin/system/logs/instance/<subdomain>`
|
||||
- **Line ~2239**: `/admin/system/backup/export/<subdomain>`
|
||||
- **Line ~2263**: `/admin/system/backup/import/<subdomain>`
|
||||
- **Line ~2666**: `/admin/users`
|
||||
- **Line ~2698**: `/admin/team`
|
||||
|
||||
Example:
|
||||
```python
|
||||
@app.route('/admin/dashboard')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_dashboard():
|
||||
```
|
||||
|
||||
## Template Updates
|
||||
|
||||
Update `templates/base.html` or your main navigation template:
|
||||
|
||||
### Before
|
||||
```jinja2
|
||||
<nav>
|
||||
<a href="/blog">Blog</a>
|
||||
<a href="/tickets">Tickets</a>
|
||||
<a href="/chat">Chat</a>
|
||||
<a href="/my/invoices">Invoices</a>
|
||||
</nav>
|
||||
```
|
||||
|
||||
### After
|
||||
```jinja2
|
||||
<nav>
|
||||
{% if module_enabled('blog') %}
|
||||
<a href="/blog">Blog</a>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('tickets') %}
|
||||
<a href="/tickets">Tickets</a>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('chat') %}
|
||||
<a href="/chat">Chat</a>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('invoices') %}
|
||||
<a href="/my/invoices">Invoices</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
```
|
||||
|
||||
### Admin Navigation
|
||||
|
||||
In admin templates or dashboard, wrap admin sections:
|
||||
|
||||
```jinja2
|
||||
{% if module_enabled('blog') %}
|
||||
<li><a href="/admin/blog">Blog Management</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('chat') %}
|
||||
<li><a href="/admin/chats">Chats</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('tickets') %}
|
||||
<li><a href="/admin/tickets">Support Tickets</a></li>
|
||||
{% endif %}
|
||||
|
||||
{% if module_enabled('invoices') %}
|
||||
<li><a href="/admin/invoices">Invoices</a></li>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Testing the Integration
|
||||
|
||||
### Test 1: Verify tenant resolution
|
||||
|
||||
```bash
|
||||
# Should resolve to 'default' tenant
|
||||
curl http://localhost:4999/blog
|
||||
|
||||
# Should resolve to 'school1' tenant
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
|
||||
# Access denied for school2 chat (disabled)
|
||||
curl -H "X-Tenant-ID: school2" http://localhost:4999/chat
|
||||
# Expected: 403 Forbidden
|
||||
```
|
||||
|
||||
### Test 2: Verify route guards
|
||||
|
||||
```bash
|
||||
# Blog is enabled for default, should work
|
||||
curl http://localhost:4999/blog
|
||||
# Expected: 200 OK or redirect to login
|
||||
|
||||
# Chat is disabled for default, should be forbidden
|
||||
curl http://localhost:4999/chat
|
||||
# Expected: 403 Forbidden
|
||||
|
||||
# Chat is enabled for school1, should work
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
# Expected: 200 OK or redirect to login
|
||||
```
|
||||
|
||||
### Test 3: JSON API responses
|
||||
|
||||
```bash
|
||||
# With valid module
|
||||
curl -H "X-Tenant-ID: school1" \
|
||||
-H "Accept: application/json" \
|
||||
http://localhost:4999/chat
|
||||
# Expected: 200 OK with JSON or 302 redirect
|
||||
|
||||
# With disabled module
|
||||
curl -H "X-Tenant-ID: school2" \
|
||||
-H "Accept: application/json" \
|
||||
http://localhost:4999/chat
|
||||
# Expected: 403 with JSON error
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [ ] All decorators added to feature routes
|
||||
- [ ] All decorators added to admin routes
|
||||
- [ ] Templates updated with `module_enabled()` checks
|
||||
- [ ] Tested with `curl` or browser
|
||||
- [ ] Tested 403 error handling
|
||||
- [ ] Tested JSON API responses
|
||||
- [ ] Configuration in `tenants.json` verified
|
||||
- [ ] Environment variable `INSTANCE_PARENT_DOMAIN` set correctly
|
||||
|
||||
## Automated Integration (Optional)
|
||||
|
||||
If you want to add guards to all routes programmatically, you could:
|
||||
|
||||
1. Use a mapping dictionary of module requirements per route
|
||||
2. Create a loop to add decorators
|
||||
3. Or manually add as shown in this guide
|
||||
|
||||
For now, manual addition ensures you're aware of what each route does and can make informed decisions about module assignments.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Decorator not working
|
||||
|
||||
```python
|
||||
# Make sure to use the full path if not imported at module level
|
||||
from tenant_guards import require_module
|
||||
|
||||
@app.route('/chat')
|
||||
@require_module('chat')
|
||||
def chat():
|
||||
pass
|
||||
```
|
||||
|
||||
### 403 template not found
|
||||
|
||||
Ensure `templates/error_403.html` exists. If not, create it from `TENANT_CONFIG.md`.
|
||||
|
||||
### Module check in template not working
|
||||
|
||||
1. Verify `tenant_templates.inject_tenant_context()` is registered as context processor
|
||||
2. This is **already done** in the updated `main.py`
|
||||
3. Restart the Flask app to pick up changes
|
||||
|
||||
### Routes still accessible when disabled
|
||||
|
||||
1. Ensure `@require_module()` decorator is added
|
||||
2. Check the module name matches in `tenants.json`
|
||||
3. Verify `INSTANCE_PARENT_DOMAIN` is set correctly for subdomain resolution
|
||||
4. Check the tenant is resolving correctly: add debug logging to `resolve_tenant_context()`
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Add decorators to routes following this guide
|
||||
2. Update templates to show/hide UI elements
|
||||
3. Test with multiple tenants
|
||||
4. Configure `tenants.json` for your specific needs
|
||||
5. Deploy to production
|
||||
|
||||
For detailed information, see [TENANT_CONFIG.md](TENANT_CONFIG.md).
|
||||
@@ -1,360 +0,0 @@
|
||||
# Exact Line Numbers for Route Updates in main.py
|
||||
|
||||
This document provides the exact line numbers where you need to add decorators.
|
||||
|
||||
## Finding the Routes
|
||||
|
||||
Use Ctrl+G (Go to Line) in VS Code or search for the route path.
|
||||
|
||||
## Routes Requiring Decorators
|
||||
|
||||
### FEATURE ROUTES - Add @tenant_guards.require_module('module_name')
|
||||
|
||||
#### Blog Feature
|
||||
|
||||
**Line ~2402 - Admin blog**
|
||||
```python
|
||||
@app.route('/admin/blog', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('blog')
|
||||
def admin_blog():
|
||||
```
|
||||
|
||||
**Line ~2480 - Public blog**
|
||||
```python
|
||||
@app.route('/blog')
|
||||
@tenant_guards.require_module('blog')
|
||||
def blog():
|
||||
```
|
||||
|
||||
**Line ~2498 - Blog post detail**
|
||||
```python
|
||||
@app.route('/blog/<post_id>')
|
||||
@tenant_guards.require_module('blog')
|
||||
def blog_post(post_id):
|
||||
```
|
||||
|
||||
#### Chat Feature
|
||||
|
||||
**Line ~2577 - Chat**
|
||||
```python
|
||||
@app.route('/chat', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('chat')
|
||||
def chat():
|
||||
```
|
||||
|
||||
**Line ~2815 - Admin chats**
|
||||
```python
|
||||
@app.route('/admin/chats', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('chat')
|
||||
def admin_chats():
|
||||
```
|
||||
|
||||
#### Tickets Feature
|
||||
|
||||
**Line ~2617 - Tickets**
|
||||
```python
|
||||
@app.route('/tickets', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_module('tickets')
|
||||
def tickets():
|
||||
```
|
||||
|
||||
**Line ~2866 - Admin tickets**
|
||||
```python
|
||||
@app.route('/admin/tickets', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('tickets')
|
||||
def admin_tickets():
|
||||
```
|
||||
|
||||
#### Appointments Feature
|
||||
|
||||
**Line ~1742 - Appointments list**
|
||||
```python
|
||||
@app.route('/appointments', methods=['GET'])
|
||||
@tenant_guards.require_module('appointments')
|
||||
def appointments():
|
||||
```
|
||||
|
||||
**Line ~1783 - Book appointment option**
|
||||
```python
|
||||
@app.route('/appointments/book-option', methods=['POST'])
|
||||
@tenant_guards.require_module('appointments')
|
||||
def book_appointment_option():
|
||||
```
|
||||
|
||||
**Line ~2308 - Admin block day**
|
||||
```python
|
||||
@app.route('/admin/appointments/block-day', methods=['POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('appointments')
|
||||
def admin_block_day():
|
||||
```
|
||||
|
||||
**Line ~2357 - Admin manage appointment**
|
||||
```python
|
||||
@app.route('/admin/appointment/<appointment_id>', methods=['POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('appointments')
|
||||
def admin_appointment(appointment_id):
|
||||
```
|
||||
|
||||
#### Invoices Feature
|
||||
|
||||
**Line ~2523 - My invoices**
|
||||
```python
|
||||
@app.route('/my/invoices')
|
||||
@tenant_guards.require_module('invoices')
|
||||
def my_invoices():
|
||||
```
|
||||
|
||||
**Line ~2913 - Admin invoices**
|
||||
```python
|
||||
@app.route('/admin/invoices', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('invoices')
|
||||
def admin_invoices():
|
||||
```
|
||||
|
||||
### ADMIN ROUTES - Add @tenant_guards.require_admin()
|
||||
|
||||
These routes should have the admin decorator added (they manage admin-level functionality):
|
||||
|
||||
**Line ~1825 - Admin dashboard**
|
||||
```python
|
||||
@app.route('/admin/dashboard')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_dashboard():
|
||||
```
|
||||
|
||||
**Line ~1869 - Admin instances**
|
||||
```python
|
||||
@app.route('/admin/instances', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
def admin_instances():
|
||||
```
|
||||
|
||||
**Line ~2103 - Admin instances stats**
|
||||
```python
|
||||
@app.route('/admin/instances/stats')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_instances_stats():
|
||||
```
|
||||
|
||||
**Line ~2110 - Admin system**
|
||||
```python
|
||||
@app.route('/admin/system', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system():
|
||||
```
|
||||
|
||||
**Line ~2191 - Admin system stats**
|
||||
```python
|
||||
@app.route('/admin/system/stats')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_stats():
|
||||
```
|
||||
|
||||
**Line ~2198 - Admin system logs live**
|
||||
```python
|
||||
@app.route('/admin/system/logs/live')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_logs_live():
|
||||
```
|
||||
|
||||
**Line ~2204 - Admin system logs core**
|
||||
```python
|
||||
@app.route('/admin/system/logs/core')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_logs_core():
|
||||
```
|
||||
|
||||
**Line ~2221 - Admin system logs instance**
|
||||
```python
|
||||
@app.route('/admin/system/logs/instance/<subdomain>')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_logs_instance(subdomain):
|
||||
```
|
||||
|
||||
**Line ~2239 - Admin system backup export**
|
||||
```python
|
||||
@app.route('/admin/system/backup/export/<subdomain>')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_backup_export(subdomain):
|
||||
```
|
||||
|
||||
**Line ~2263 - Admin system backup import**
|
||||
```python
|
||||
@app.route('/admin/system/backup/import/<subdomain>', methods=['POST'])
|
||||
@tenant_guards.require_admin()
|
||||
def admin_system_backup_import(subdomain):
|
||||
```
|
||||
|
||||
**Line ~2666 - Admin users**
|
||||
```python
|
||||
@app.route('/admin/users', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
def admin_users():
|
||||
```
|
||||
|
||||
**Line ~2698 - Admin team**
|
||||
```python
|
||||
@app.route('/admin/team', methods=['GET', 'POST'])
|
||||
@tenant_guards.require_admin()
|
||||
def admin_team():
|
||||
```
|
||||
|
||||
## Summary Table
|
||||
|
||||
| Route | Line | Decorator(s) |
|
||||
|-------|------|-------------|
|
||||
| /blog | ~2480 | @require_module('blog') |
|
||||
| /blog/<post_id> | ~2498 | @require_module('blog') |
|
||||
| /admin/blog | ~2402 | @require_admin() + @require_module('blog') |
|
||||
| /chat | ~2577 | @require_module('chat') |
|
||||
| /admin/chats | ~2815 | @require_admin() + @require_module('chat') |
|
||||
| /tickets | ~2617 | @require_module('tickets') |
|
||||
| /admin/tickets | ~2866 | @require_admin() + @require_module('tickets') |
|
||||
| /appointments | ~1742 | @require_module('appointments') |
|
||||
| /appointments/book-option | ~1783 | @require_module('appointments') |
|
||||
| /admin/appointments/block-day | ~2308 | @require_admin() + @require_module('appointments') |
|
||||
| /admin/appointment/<id> | ~2357 | @require_admin() + @require_module('appointments') |
|
||||
| /my/invoices | ~2523 | @require_module('invoices') |
|
||||
| /admin/invoices | ~2913 | @require_admin() + @require_module('invoices') |
|
||||
| /admin/dashboard | ~1825 | @require_admin() |
|
||||
| /admin/instances | ~1869 | @require_admin() |
|
||||
| /admin/instances/stats | ~2103 | @require_admin() |
|
||||
| /admin/system | ~2110 | @require_admin() |
|
||||
| /admin/system/stats | ~2191 | @require_admin() |
|
||||
| /admin/system/logs/live | ~2198 | @require_admin() |
|
||||
| /admin/system/logs/core | ~2204 | @require_admin() |
|
||||
| /admin/system/logs/instance/<subdomain> | ~2221 | @require_admin() |
|
||||
| /admin/system/backup/export/<subdomain> | ~2239 | @require_admin() |
|
||||
| /admin/system/backup/import/<subdomain> | ~2263 | @require_admin() |
|
||||
| /admin/users | ~2666 | @require_admin() |
|
||||
| /admin/team | ~2698 | @require_admin() |
|
||||
|
||||
## How to Apply Updates
|
||||
|
||||
### Using VS Code
|
||||
|
||||
1. Press Ctrl+G
|
||||
2. Type the line number (e.g., "2480")
|
||||
3. Press Enter
|
||||
4. Add the decorator(s) above the @app.route() line
|
||||
|
||||
### Pattern to Follow
|
||||
|
||||
**Before:**
|
||||
```python
|
||||
@app.route('/path')
|
||||
def handler():
|
||||
```
|
||||
|
||||
**After:**
|
||||
```python
|
||||
@app.route('/path')
|
||||
@tenant_guards.require_module('module_name')
|
||||
def handler():
|
||||
```
|
||||
|
||||
**For admin routes:**
|
||||
```python
|
||||
@app.route('/admin/path')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_handler():
|
||||
```
|
||||
|
||||
**For admin feature routes:**
|
||||
```python
|
||||
@app.route('/admin/feature/path')
|
||||
@tenant_guards.require_admin()
|
||||
@tenant_guards.require_module('feature')
|
||||
def admin_feature_handler():
|
||||
```
|
||||
|
||||
### Automated Update (Optional)
|
||||
|
||||
Create a script to add decorators programmatically:
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
# Read the file
|
||||
with open('main.py', 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Mapping of line numbers to decorators
|
||||
updates = {
|
||||
2480: "@tenant_guards.require_module('blog')\n",
|
||||
2498: "@tenant_guards.require_module('blog')\n",
|
||||
# ... etc
|
||||
}
|
||||
|
||||
# Apply updates (in reverse order to preserve line numbers)
|
||||
for line_num in sorted(updates.keys(), reverse=True):
|
||||
decorator = updates[line_num]
|
||||
lines.insert(line_num - 1, decorator)
|
||||
|
||||
# Write back
|
||||
with open('main.py', 'w') as f:
|
||||
f.writelines(lines)
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After adding decorators, verify they work:
|
||||
|
||||
```bash
|
||||
# Test that decorator is applied
|
||||
grep -n "@require_module\|@require_admin" main.py | wc -l
|
||||
# Should show ~25+ decorators
|
||||
|
||||
# Test syntax
|
||||
python -m py_compile main.py
|
||||
|
||||
# Run tests
|
||||
pytest test_tenant_system.py -v
|
||||
|
||||
# Start the app
|
||||
python main.py
|
||||
```
|
||||
|
||||
## What NOT to Update
|
||||
|
||||
These routes should NOT have module guards added:
|
||||
|
||||
- `/` (home page)
|
||||
- `/login`
|
||||
- `/register`
|
||||
- `/logout`
|
||||
- `/dienstleistungen`
|
||||
- `/projekte`
|
||||
- `/team`
|
||||
- `/kontakt`
|
||||
- `/datenschutz`
|
||||
- `/impressum`
|
||||
- `/nutzungsbedingungen`
|
||||
- `/my/instance` (user instance management - may want to add guards)
|
||||
|
||||
These are public or public-facing pages that should be accessible regardless of tenant module configuration.
|
||||
|
||||
## Line Numbers May Vary
|
||||
|
||||
The line numbers (~2480, ~2498, etc.) are approximate. Search for the route path instead:
|
||||
|
||||
- Search for `@app.route('/blog')`
|
||||
- Add the decorator above it
|
||||
- Repeat for each route
|
||||
|
||||
## Double-Check Your Work
|
||||
|
||||
After updating, verify:
|
||||
|
||||
1. ✅ Decorator is on the line ABOVE @app.route()
|
||||
2. ✅ Module name matches module in tenants.json
|
||||
3. ✅ No typos in decorator name
|
||||
4. ✅ Both @require_admin() and @require_module() for admin features
|
||||
5. ✅ Only @require_admin() for admin-only routes
|
||||
6. ✅ Only @require_module() for feature-specific routes
|
||||
@@ -1,406 +0,0 @@
|
||||
# Tenant-Aware Configuration System - Master Index
|
||||
|
||||
## Overview
|
||||
|
||||
A complete, production-ready tenant-aware configuration system has been implemented for your multi-tenant Flask application. This document serves as the master index for all system files and documentation.
|
||||
|
||||
## System Files Created
|
||||
|
||||
### Core Implementation Files (in `/Website/`)
|
||||
|
||||
| File | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| `tenant_resolver.py` | Tenant detection from requests | ✅ Complete |
|
||||
| `tenant_config.py` | Configuration management | ✅ Complete |
|
||||
| `tenant_guards.py` | Route decorators and guards | ✅ Complete |
|
||||
| `tenant_templates.py` | Jinja2 template helpers | ✅ Complete |
|
||||
| `tenants.json` | Tenant configuration file | ✅ Complete |
|
||||
| `test_tenant_system.py` | Unit tests | ✅ Complete |
|
||||
| `templates/error_403.html` | 403 error page | ✅ Complete |
|
||||
| `main.py` | **PARTIALLY updated** ⚠️ | ⚠️ Needs route decorators |
|
||||
|
||||
### Documentation Files (in project root)
|
||||
|
||||
| File | Purpose | Audience |
|
||||
|------|---------|----------|
|
||||
| `TENANT_CONFIG.md` | Complete system documentation | Architects, DevOps |
|
||||
| `TENANT_INTEGRATION_GUIDE.md` | Step-by-step integration | Developers |
|
||||
| `TENANT_CONFIG_EXAMPLES.md` | Real-world configuration examples | Operations, Config managers |
|
||||
| `TENANT_QUICK_REFERENCE.md` | Developer quick reference | Developers |
|
||||
| `TENANT_LINE_NUMBERS.md` | Exact line numbers for updates | Developers |
|
||||
| `TENANT_VERIFICATION.md` | Testing and verification checklist | QA, Developers |
|
||||
| `TENANT_IMPLEMENTATION_SUMMARY.md` | High-level overview | Project managers, Leads |
|
||||
| This file (`TENANT_MASTER_INDEX.md`) | Master index and navigation | Everyone |
|
||||
|
||||
## Quick Navigation
|
||||
|
||||
### For First-Time Users
|
||||
|
||||
1. Start here: [TENANT_IMPLEMENTATION_SUMMARY.md](TENANT_IMPLEMENTATION_SUMMARY.md)
|
||||
2. Then read: [TENANT_QUICK_REFERENCE.md](TENANT_QUICK_REFERENCE.md)
|
||||
3. For testing: [TENANT_VERIFICATION.md](TENANT_VERIFICATION.md)
|
||||
|
||||
### For Developers Implementing Routes
|
||||
|
||||
1. [TENANT_INTEGRATION_GUIDE.md](TENANT_INTEGRATION_GUIDE.md) - Step-by-step instructions
|
||||
2. [TENANT_LINE_NUMBERS.md](TENANT_LINE_NUMBERS.md) - Exact line numbers and code
|
||||
3. [TENANT_QUICK_REFERENCE.md](TENANT_QUICK_REFERENCE.md) - Code snippets
|
||||
|
||||
### For Operations/Configuration
|
||||
|
||||
1. [TENANT_CONFIG.md](TENANT_CONFIG.md) - Complete reference
|
||||
2. [TENANT_CONFIG_EXAMPLES.md](TENANT_CONFIG_EXAMPLES.md) - Real-world examples
|
||||
3. [tenants.json](Website/tenants.json) - Configuration file
|
||||
|
||||
### For Testing/QA
|
||||
|
||||
1. [TENANT_VERIFICATION.md](TENANT_VERIFICATION.md) - Testing checklist
|
||||
2. [test_tenant_system.py](Website/test_tenant_system.py) - Unit tests
|
||||
3. [TENANT_QUICK_REFERENCE.md](TENANT_QUICK_REFERENCE.md) - Testing section
|
||||
|
||||
## What's Implemented
|
||||
|
||||
### ✅ Complete (Ready to Use)
|
||||
|
||||
- [x] Tenant resolution from requests
|
||||
- [x] Configuration loading and management
|
||||
- [x] Module availability checking
|
||||
- [x] Route protection decorators
|
||||
- [x] Template context injection
|
||||
- [x] Error handling (403 responses)
|
||||
- [x] Unit tests (30+ tests)
|
||||
- [x] Complete documentation
|
||||
- [x] Flask integration (partial - see below)
|
||||
- [x] Sample configuration with 3 example tenants
|
||||
- [x] Safe defaults and fallbacks
|
||||
|
||||
### ⚠️ Partial (Needs Completion)
|
||||
|
||||
- [ ] Route decorators added to `main.py` routes
|
||||
- [ ] Template checks added to HTML templates
|
||||
- [ ] Environment variables configured
|
||||
|
||||
## Implementation Status
|
||||
|
||||
```
|
||||
System Component Status Action
|
||||
─────────────────────────────────────────────────────
|
||||
Tenant resolver ✅ Done None
|
||||
Config manager ✅ Done None
|
||||
Route guards ✅ Done None
|
||||
Template helpers ✅ Done None
|
||||
Configuration file ✅ Done Review & customize
|
||||
Flask integration ⚠️ Partial Add decorators (~25 routes)
|
||||
Templates ⚠️ Pending Add module_enabled() checks
|
||||
Environment setup ⚠️ Pending Set INSTANCE_PARENT_DOMAIN
|
||||
Unit tests ✅ Done Run: pytest test_tenant_system.py
|
||||
Documentation ✅ Done Read the docs!
|
||||
```
|
||||
|
||||
## 30-Minute Quick Start
|
||||
|
||||
### 1. Test the System (5 min)
|
||||
```bash
|
||||
cd Website
|
||||
pytest test_tenant_system.py -v
|
||||
```
|
||||
|
||||
### 2. Start Flask (5 min)
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
### 3. Test in Another Terminal (5 min)
|
||||
```bash
|
||||
# Should return 200 (blog enabled for default)
|
||||
curl http://localhost:4999/blog
|
||||
|
||||
# Should return 403 (chat disabled for default)
|
||||
curl http://localhost:4999/chat
|
||||
|
||||
# Should allow with header
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
```
|
||||
|
||||
### 4. Review Configuration (5 min)
|
||||
```bash
|
||||
cat Website/tenants.json
|
||||
# Understand how modules are configured per tenant
|
||||
```
|
||||
|
||||
### 5. Read Quick Reference (5 min)
|
||||
```bash
|
||||
cat TENANT_QUICK_REFERENCE.md
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Tenant Resolution Order
|
||||
|
||||
1. **X-Tenant-ID header** (for APIs and testing)
|
||||
2. **Subdomain** (e.g., school1.example.com)
|
||||
3. **Default** (fallback)
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"feature": true
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"tenant_id": {
|
||||
"modules": {
|
||||
"feature": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Module Availability
|
||||
|
||||
- **Enabled globally** = available unless tenant overrides to false
|
||||
- **Disabled globally** = unavailable unless tenant overrides to true
|
||||
- **Not configured** = inherited from global defaults
|
||||
- **Missing tenant** = uses all global defaults
|
||||
|
||||
## Files Created Summary
|
||||
|
||||
### Python Modules (4 files)
|
||||
|
||||
```
|
||||
tenant_resolver.py ~100 lines - Tenant detection
|
||||
tenant_config.py ~300 lines - Config management
|
||||
tenant_guards.py ~150 lines - Route decorators
|
||||
tenant_templates.py ~50 lines - Template helpers
|
||||
```
|
||||
|
||||
Total: ~600 lines of well-documented production code
|
||||
|
||||
### Configuration & Tests (3 files)
|
||||
|
||||
```
|
||||
tenants.json ~60 lines - Configuration with 3 example tenants
|
||||
test_tenant_system.py ~400 lines - Comprehensive unit tests
|
||||
error_403.html ~90 lines - User-friendly error page
|
||||
```
|
||||
|
||||
### Documentation (8 files)
|
||||
|
||||
```
|
||||
TENANT_CONFIG.md - Complete reference (350+ lines)
|
||||
TENANT_INTEGRATION_GUIDE.md - Step-by-step guide (200+ lines)
|
||||
TENANT_CONFIG_EXAMPLES.md - Real examples (300+ lines)
|
||||
TENANT_QUICK_REFERENCE.md - Quick ref (150+ lines)
|
||||
TENANT_LINE_NUMBERS.md - Line numbers (200+ lines)
|
||||
TENANT_VERIFICATION.md - Testing guide (300+ lines)
|
||||
TENANT_IMPLEMENTATION_SUMMARY.md - Overview (200+ lines)
|
||||
TENANT_MASTER_INDEX.md - This file
|
||||
```
|
||||
|
||||
Total documentation: ~1,500+ lines
|
||||
|
||||
## Architecture Diagram
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
│
|
||||
├─→ [before_request]
|
||||
│ └─→ resolve_tenant_context()
|
||||
│ ├─ Check X-Tenant-ID header
|
||||
│ ├─ Check subdomain
|
||||
│ └─ Fall back to 'default'
|
||||
│ └─→ g.tenant_id = 'school1'
|
||||
│
|
||||
├─→ [Route Handler]
|
||||
│ └─→ @require_module('chat')
|
||||
│ └─→ is_module_enabled(g.tenant_id, 'chat')
|
||||
│
|
||||
├─→ Response
|
||||
│ ├─ 200 OK (if allowed)
|
||||
│ ├─ 403 Forbidden (if denied)
|
||||
│ └─ JSON or HTML (based on Accept header)
|
||||
│
|
||||
├─→ [Template Rendering]
|
||||
│ └─→ {{ module_enabled('chat') }}
|
||||
│ └─→ Show/hide UI based on module status
|
||||
│
|
||||
└─→ Client
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
| Feature | Status | Details |
|
||||
|---------|--------|---------|
|
||||
| Tenant Resolution | ✅ | Subdomain + Header + Fallback |
|
||||
| Per-Tenant Config | ✅ | Override defaults or inherit |
|
||||
| Module Guards | ✅ | @require_module() decorator |
|
||||
| Admin Guards | ✅ | @require_admin() decorator |
|
||||
| Safe Defaults | ✅ | Fail-safe when config missing |
|
||||
| Error Handling | ✅ | JSON + HTML responses |
|
||||
| Template Support | ✅ | Jinja2 context injection |
|
||||
| Unit Tests | ✅ | 30+ comprehensive tests |
|
||||
| Documentation | ✅ | 1,500+ lines of docs |
|
||||
|
||||
## What You Need To Do
|
||||
|
||||
### Phase 1: Testing (10 min)
|
||||
- [ ] Run unit tests
|
||||
- [ ] Start Flask app
|
||||
- [ ] Test with curl
|
||||
|
||||
### Phase 2: Implement (60 min)
|
||||
- [ ] Add @require_module() to 7 feature routes
|
||||
- [ ] Add @require_admin() to 18 admin routes
|
||||
- [ ] Add module_enabled() checks to templates
|
||||
|
||||
### Phase 3: Verify (20 min)
|
||||
- [ ] Test each protected route
|
||||
- [ ] Verify template checks work
|
||||
- [ ] Test different tenants
|
||||
|
||||
### Phase 4: Deploy (varies)
|
||||
- [ ] Set INSTANCE_PARENT_DOMAIN
|
||||
- [ ] Customize tenants.json
|
||||
- [ ] Deploy to production
|
||||
- [ ] Monitor for issues
|
||||
|
||||
Total estimated effort: **90 minutes**
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Protecting a Route
|
||||
|
||||
```python
|
||||
@app.route('/chat')
|
||||
@tenant_guards.require_module('chat')
|
||||
def chat():
|
||||
return render_template('chat.html')
|
||||
```
|
||||
|
||||
### Template Check
|
||||
|
||||
```jinja2
|
||||
{% if module_enabled('chat') %}
|
||||
<a href="/chat">Chat</a>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### Programmatic Check
|
||||
|
||||
```python
|
||||
from flask import g
|
||||
from tenant_config import is_module_enabled
|
||||
|
||||
if is_module_enabled(g.tenant_id, 'chat'):
|
||||
# Module is available
|
||||
```
|
||||
|
||||
### Test with curl
|
||||
|
||||
```bash
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
```
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
/home/max/Dokumente/repos/Key-service-Server/
|
||||
├── Website/ # Python app directory
|
||||
│ ├── main.py ✅ (Updated - partially)
|
||||
│ ├── tenant_resolver.py ✅ (New)
|
||||
│ ├── tenant_config.py ✅ (New)
|
||||
│ ├── tenant_guards.py ✅ (New)
|
||||
│ ├── tenant_templates.py ✅ (New)
|
||||
│ ├── tenants.json ✅ (New)
|
||||
│ ├── test_tenant_system.py ✅ (New)
|
||||
│ ├── templates/
|
||||
│ │ ├── error_403.html ✅ (New)
|
||||
│ │ ├── base.html ⚠️ (Needs updates)
|
||||
│ │ └── ...other templates ⚠️ (Need updates)
|
||||
│ └── ...other files
|
||||
│
|
||||
├── TENANT_CONFIG.md ✅ (New)
|
||||
├── TENANT_INTEGRATION_GUIDE.md ✅ (New)
|
||||
├── TENANT_CONFIG_EXAMPLES.md ✅ (New)
|
||||
├── TENANT_QUICK_REFERENCE.md ✅ (New)
|
||||
├── TENANT_LINE_NUMBERS.md ✅ (New)
|
||||
├── TENANT_VERIFICATION.md ✅ (New)
|
||||
├── TENANT_IMPLEMENTATION_SUMMARY.md ✅ (New)
|
||||
└── TENANT_MASTER_INDEX.md ✅ (This file)
|
||||
```
|
||||
|
||||
## Checklist for Success
|
||||
|
||||
- [ ] Read TENANT_IMPLEMENTATION_SUMMARY.md (5 min)
|
||||
- [ ] Run tests: `pytest test_tenant_system.py` (2 min)
|
||||
- [ ] Review configuration: `cat Website/tenants.json` (3 min)
|
||||
- [ ] Read TENANT_INTEGRATION_GUIDE.md (10 min)
|
||||
- [ ] Add decorators to routes (30 min)
|
||||
- [ ] Update templates with module_enabled() (15 min)
|
||||
- [ ] Test everything with TENANT_VERIFICATION.md (20 min)
|
||||
- [ ] Deploy to production (varies)
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
- **Configuration load time**: ~10ms
|
||||
- **Module check time**: <1ms per check
|
||||
- **Request overhead**: <1ms per request
|
||||
- **Memory usage**: <100KB for typical config
|
||||
- **Throughput impact**: Negligible (<1%)
|
||||
|
||||
## Security Features
|
||||
|
||||
✅ Tenant resolution robust against injection
|
||||
✅ Server-side enforcement (no UI bypass)
|
||||
✅ Configuration immutable at runtime
|
||||
✅ Consistent error responses
|
||||
✅ Safe defaults (fail-safe disabled)
|
||||
|
||||
## Support & Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| Module not working | Check decorator is added and module name matches |
|
||||
| Tenant not resolving | Verify INSTANCE_PARENT_DOMAIN env var |
|
||||
| 403 when should work | Check tenants.json config for tenant |
|
||||
| Config changes not working | Config loads at startup - restart app or call reload() |
|
||||
| Import errors | Ensure you're in Website directory, files exist |
|
||||
|
||||
See TENANT_VERIFICATION.md for detailed troubleshooting.
|
||||
|
||||
## Next Actions (in Order)
|
||||
|
||||
1. **Read**: [TENANT_IMPLEMENTATION_SUMMARY.md](TENANT_IMPLEMENTATION_SUMMARY.md)
|
||||
2. **Test**: Run `pytest test_tenant_system.py -v` in Website directory
|
||||
3. **Review**: Look at [TENANT_QUICK_REFERENCE.md](TENANT_QUICK_REFERENCE.md)
|
||||
4. **Implement**: Follow [TENANT_INTEGRATION_GUIDE.md](TENANT_INTEGRATION_GUIDE.md)
|
||||
5. **Update**: Add decorators using [TENANT_LINE_NUMBERS.md](TENANT_LINE_NUMBERS.md)
|
||||
6. **Verify**: Run checks from [TENANT_VERIFICATION.md](TENANT_VERIFICATION.md)
|
||||
7. **Deploy**: Set up environment and deploy to production
|
||||
|
||||
## Contact & Questions
|
||||
|
||||
If you have questions:
|
||||
|
||||
1. Check the relevant documentation
|
||||
2. See TENANT_QUICK_REFERENCE.md for common tasks
|
||||
3. Run tests to verify system works
|
||||
4. Review TENANT_CONFIG_EXAMPLES.md for examples
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0** (Current) - Initial implementation with 4 core modules, comprehensive tests, and documentation
|
||||
|
||||
## License & Attribution
|
||||
|
||||
This tenant-aware configuration system was designed for your multi-tenant Flask application and is ready for production use.
|
||||
|
||||
---
|
||||
|
||||
**Start here**: [TENANT_IMPLEMENTATION_SUMMARY.md](TENANT_IMPLEMENTATION_SUMMARY.md)
|
||||
@@ -1,257 +0,0 @@
|
||||
# Tenant System - Quick Reference Card
|
||||
|
||||
## For Developers: How to Protect a Route
|
||||
|
||||
### Option 1: Require Specific Module
|
||||
```python
|
||||
@app.route('/chat')
|
||||
@tenant_guards.require_module('chat')
|
||||
def chat():
|
||||
return render_template('chat.html')
|
||||
```
|
||||
|
||||
### Option 2: Require Admin Access
|
||||
```python
|
||||
@app.route('/admin/dashboard')
|
||||
@tenant_guards.require_admin()
|
||||
def admin_dashboard():
|
||||
return render_template('admin_dashboard.html')
|
||||
```
|
||||
|
||||
### Option 3: Manual Check in Handler
|
||||
```python
|
||||
from flask import g
|
||||
from tenant_config import is_module_enabled
|
||||
|
||||
@app.route('/feature')
|
||||
def feature():
|
||||
if not is_module_enabled(g.tenant_id, 'feature'):
|
||||
abort(403)
|
||||
return render_template('feature.html')
|
||||
```
|
||||
|
||||
## For Template Authors: Show/Hide UI
|
||||
|
||||
```jinja2
|
||||
<!-- Check single module -->
|
||||
{% if module_enabled('chat') %}
|
||||
<a href="/chat">Chat</a>
|
||||
{% endif %}
|
||||
|
||||
<!-- Check multiple -->
|
||||
{% if module_enabled('blog') or module_enabled('news') %}
|
||||
<section>News & Updates</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Loop through enabled -->
|
||||
{% for module in enabled_modules %}
|
||||
{{ module }}
|
||||
{% endfor %}
|
||||
|
||||
<!-- Get current tenant -->
|
||||
Current tenant: {{ tenant_id }}
|
||||
```
|
||||
|
||||
## For Configuration: tenants.json
|
||||
|
||||
```json
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"chat": true,
|
||||
"blog": false
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"school1": {
|
||||
"modules": {
|
||||
"chat": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Global `defaults.modules` applies to all tenants
|
||||
- Tenant can override any module
|
||||
- If tenant doesn't specify a module, it inherits the default
|
||||
- Missing everywhere = disabled (safe default)
|
||||
|
||||
## Tenant Resolution
|
||||
|
||||
1. **X-Tenant-ID header** (highest priority)
|
||||
```bash
|
||||
curl -H "X-Tenant-ID: school1" http://example.com/api
|
||||
```
|
||||
|
||||
2. **Subdomain** (from Host header)
|
||||
```
|
||||
school1.example.com → tenant_id = "school1"
|
||||
```
|
||||
|
||||
3. **Fallback** (lowest priority)
|
||||
```
|
||||
example.com → tenant_id = "default"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test with specific tenant
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/chat
|
||||
|
||||
# Test forbidden response
|
||||
curl -H "X-Tenant-ID: school2" http://localhost:4999/chat
|
||||
# Returns: 403 Forbidden
|
||||
|
||||
# Test JSON API
|
||||
curl -H "X-Tenant-ID: school1" \
|
||||
-H "Accept: application/json" \
|
||||
http://localhost:4999/chat
|
||||
# Returns: 200 OK (if allowed) or 403 with JSON error
|
||||
```
|
||||
|
||||
## Configuration Access in Code
|
||||
|
||||
```python
|
||||
from tenant_config import (
|
||||
is_module_enabled,
|
||||
get_enabled_modules,
|
||||
get_tenant_config,
|
||||
get_config_manager
|
||||
)
|
||||
from flask import g
|
||||
|
||||
# Check single module
|
||||
if is_module_enabled(g.tenant_id, 'chat'):
|
||||
# Module is enabled
|
||||
|
||||
# Get all enabled modules
|
||||
modules = get_enabled_modules(g.tenant_id) # Returns set
|
||||
|
||||
# Get full config
|
||||
config = get_tenant_config(g.tenant_id) # Returns dict
|
||||
|
||||
# Advanced: use manager directly
|
||||
manager = get_config_manager()
|
||||
manager.reload() # Reload from disk
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Set parent domain for subdomain extraction
|
||||
export INSTANCE_PARENT_DOMAIN="example.com"
|
||||
```
|
||||
|
||||
## Common Module Names
|
||||
|
||||
- `inventarsystem` - Inventory management
|
||||
- `appointments` - Appointment booking
|
||||
- `blog` - Blog/news system
|
||||
- `chat` - Chat/messaging
|
||||
- `tickets` - Support tickets
|
||||
- `invoices` - Invoice management
|
||||
- `admin` - Admin panel
|
||||
- `dienstleistungen` - Services
|
||||
- `projekte` - Projects
|
||||
- `team` - Team management
|
||||
- `kontakt` - Contact forms
|
||||
|
||||
## Error Responses
|
||||
|
||||
**HTML Response (403):**
|
||||
```
|
||||
HTTP/1.1 403 Forbidden
|
||||
Content-Type: text/html
|
||||
|
||||
[Rendered error_403.html template]
|
||||
```
|
||||
|
||||
**JSON Response (403):**
|
||||
```json
|
||||
{
|
||||
"error": "Module not available",
|
||||
"message": "The chat module is not available for your organization.",
|
||||
"module": "chat"
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Checklist
|
||||
|
||||
- [ ] Import tenant modules at top of main.py
|
||||
- [ ] Add @require_module() decorators to feature routes
|
||||
- [ ] Add @require_admin() to admin routes
|
||||
- [ ] Update templates with module_enabled() checks
|
||||
- [ ] Set INSTANCE_PARENT_DOMAIN environment variable
|
||||
- [ ] Test with curl/browser
|
||||
- [ ] Run pytest test_tenant_system.py
|
||||
- [ ] Review tenants.json configuration
|
||||
|
||||
## Debugging
|
||||
|
||||
Add to main.py for debug output:
|
||||
```python
|
||||
@app.before_request
|
||||
def debug_request():
|
||||
print(f"Tenant: {g.tenant_id}")
|
||||
print(f"Host: {request.host}")
|
||||
print(f"Headers: {dict(request.headers)}")
|
||||
```
|
||||
|
||||
Check if module is working:
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
manager = get_config_manager()
|
||||
config = manager.get_tenant_config('school1')
|
||||
print(config)
|
||||
```
|
||||
|
||||
## Key Files
|
||||
|
||||
- **`tenant_resolver.py`** - Tenant detection
|
||||
- **`tenant_config.py`** - Configuration management
|
||||
- **`tenant_guards.py`** - Route decorators
|
||||
- **`tenant_templates.py`** - Template helpers
|
||||
- **`tenants.json`** - Configuration file
|
||||
- **`main.py`** - Integration point (partially done)
|
||||
- **`test_tenant_system.py`** - Unit tests
|
||||
|
||||
## What's Automatic
|
||||
|
||||
✅ Tenant resolution from request
|
||||
✅ 403 error handling
|
||||
✅ Template context injection
|
||||
✅ Configuration loading
|
||||
|
||||
## What You Need To Do
|
||||
|
||||
⚠️ Add @require_module() to routes
|
||||
⚠️ Add module_enabled() checks to templates
|
||||
⚠️ Configure tenants.json
|
||||
|
||||
## Documentation
|
||||
|
||||
- **`TENANT_CONFIG.md`** - Complete reference
|
||||
- **`TENANT_INTEGRATION_GUIDE.md`** - Step-by-step
|
||||
- **`TENANT_CONFIG_EXAMPLES.md`** - Real examples
|
||||
- **`TENANT_IMPLEMENTATION_SUMMARY.md`** - Overview
|
||||
- **This file** - Quick reference
|
||||
|
||||
## Support
|
||||
|
||||
**Q: How do I add a new module?**
|
||||
A: Add to tenants.json defaults, add decorator to route, add check to template.
|
||||
|
||||
**Q: Can I change configuration without restarting?**
|
||||
A: Call `get_config_manager().reload()` in production.
|
||||
|
||||
**Q: How do I test different tenants?**
|
||||
A: Use X-Tenant-ID header or different subdomains.
|
||||
|
||||
**Q: Is this enforced server-side?**
|
||||
A: Yes, routes reject requests before rendering templates.
|
||||
|
||||
**Q: Can users bypass disabled modules?**
|
||||
A: No, they get 403 on direct URL access.
|
||||
@@ -1,506 +0,0 @@
|
||||
# Tenant System Verification Checklist
|
||||
|
||||
Use this checklist to verify that the tenant-aware configuration system is working correctly.
|
||||
|
||||
## Phase 1: Pre-Integration Testing (5 minutes)
|
||||
|
||||
### ✅ Check Files Exist
|
||||
|
||||
```bash
|
||||
cd Website
|
||||
ls -la tenant_resolver.py
|
||||
ls -la tenant_config.py
|
||||
ls -la tenant_guards.py
|
||||
ls -la tenant_templates.py
|
||||
ls -la tenants.json
|
||||
ls -la test_tenant_system.py
|
||||
ls -la templates/error_403.html
|
||||
```
|
||||
|
||||
**Expected:** All files should exist and be readable
|
||||
|
||||
### ✅ Check Syntax
|
||||
|
||||
```bash
|
||||
python -m py_compile tenant_resolver.py
|
||||
python -m py_compile tenant_config.py
|
||||
python -m py_compile tenant_guards.py
|
||||
python -m py_compile tenant_templates.py
|
||||
```
|
||||
|
||||
**Expected:** No errors
|
||||
|
||||
### ✅ Run Unit Tests
|
||||
|
||||
```bash
|
||||
pip install pytest # If not already installed
|
||||
pytest test_tenant_system.py -v
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
test_tenant_system.py::TestTenantResolver::test_extract_subdomain_valid PASSED
|
||||
test_tenant_system.py::TestTenantResolver::test_extract_subdomain_with_port PASSED
|
||||
...
|
||||
====== 30+ passed in 0.5s ======
|
||||
```
|
||||
|
||||
### ✅ Verify Configuration Loading
|
||||
|
||||
```python
|
||||
python -c "
|
||||
from tenant_config import get_config_manager
|
||||
import json
|
||||
|
||||
manager = get_config_manager()
|
||||
print('Configuration loaded successfully!')
|
||||
print(json.dumps(manager._config, indent=2))
|
||||
"
|
||||
```
|
||||
|
||||
**Expected:** Configuration is printed without errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Flask Integration Testing (10 minutes)
|
||||
|
||||
### ✅ Check main.py Integration
|
||||
|
||||
```bash
|
||||
grep -n "import tenant_" main.py
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
27: import tenant_resolver
|
||||
28: import tenant_config
|
||||
29: import tenant_guards
|
||||
30: import tenant_templates
|
||||
```
|
||||
|
||||
### ✅ Verify Middleware is Registered
|
||||
|
||||
```bash
|
||||
grep -n "resolve_tenant_context\|inject_tenant_context" main.py
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
39: app.context_processor(tenant_templates.inject_tenant_context)
|
||||
43: def resolve_tenant_context():
|
||||
```
|
||||
|
||||
### ✅ Check Error Handler
|
||||
|
||||
```bash
|
||||
grep -n "@app.errorhandler(403)" main.py
|
||||
```
|
||||
|
||||
**Expected:** Should find the error handler registered
|
||||
|
||||
### ✅ Test Flask App Starts
|
||||
|
||||
```bash
|
||||
# In one terminal
|
||||
python main.py
|
||||
|
||||
# Wait for startup...
|
||||
# Look for: Running on http://0.0.0.0:4999
|
||||
```
|
||||
|
||||
**Expected:** App starts without errors
|
||||
|
||||
If there are import errors, check:
|
||||
1. All tenant*.py files are in the Website directory
|
||||
2. Python path is correct
|
||||
3. Dependencies are installed (Flask, etc.)
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Manual Testing (15 minutes)
|
||||
|
||||
Keep Flask running and test in another terminal.
|
||||
|
||||
### ✅ Test 1: Default Tenant Access
|
||||
|
||||
```bash
|
||||
# Test a public page (should work)
|
||||
curl -I http://localhost:4999/
|
||||
# Expected: 200 OK or 302 redirect
|
||||
|
||||
# Test a disabled module (should fail with 403)
|
||||
curl -I http://localhost:4999/chat
|
||||
# Expected: 403 Forbidden (for default tenant with chat disabled)
|
||||
```
|
||||
|
||||
### ✅ Test 2: Tenant Resolution with Header
|
||||
|
||||
```bash
|
||||
# Test with school1 tenant (chat should be enabled)
|
||||
curl -H "X-Tenant-ID: school1" -I http://localhost:4999/chat
|
||||
# Expected: 200 OK or redirect (module is enabled for school1)
|
||||
|
||||
# Test with school2 tenant (chat should be disabled)
|
||||
curl -H "X-Tenant-ID: school2" -I http://localhost:4999/chat
|
||||
# Expected: 403 Forbidden (module is disabled for school2)
|
||||
```
|
||||
|
||||
### ✅ Test 3: JSON Error Response
|
||||
|
||||
```bash
|
||||
# Request JSON response for disabled module
|
||||
curl -H "Accept: application/json" \
|
||||
http://localhost:4999/chat
|
||||
# Expected response:
|
||||
# {
|
||||
# "error": "Module not available",
|
||||
# "message": "The chat module is not available for your organization.",
|
||||
# "module": "chat"
|
||||
# }
|
||||
```
|
||||
|
||||
### ✅ Test 4: 403 Error Page
|
||||
|
||||
```bash
|
||||
# Open in browser and see error page
|
||||
curl http://localhost:4999/chat | head -20
|
||||
# Should contain HTML with "403" and "Zugriff verweigert"
|
||||
```
|
||||
|
||||
### ✅ Test 5: Check Template Context
|
||||
|
||||
Add temporary debug route to main.py:
|
||||
|
||||
```python
|
||||
@app.route('/debug/config')
|
||||
def debug_config():
|
||||
from tenant_config import get_enabled_modules
|
||||
enabled = get_enabled_modules(g.tenant_id)
|
||||
return {
|
||||
'tenant': g.tenant_id,
|
||||
'enabled_modules': list(enabled)
|
||||
}
|
||||
```
|
||||
|
||||
Test it:
|
||||
```bash
|
||||
curl http://localhost:4999/debug/config
|
||||
# Should show JSON with tenant and modules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Configuration Testing (5 minutes)
|
||||
|
||||
### ✅ Test Different Tenant Configs
|
||||
|
||||
```bash
|
||||
# Check what's enabled for default
|
||||
curl -H "X-Tenant-ID: default" http://localhost:4999/debug/config | python -m json.tool
|
||||
|
||||
# Check what's enabled for school1
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/debug/config | python -m json.tool
|
||||
|
||||
# Check what's enabled for partner-org
|
||||
curl -H "X-Tenant-ID: partner-org" http://localhost:4999/debug/config | python -m json.tool
|
||||
```
|
||||
|
||||
**Expected:** Each tenant shows different enabled modules matching tenants.json
|
||||
|
||||
### ✅ Test Config Reload
|
||||
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
|
||||
manager = get_config_manager()
|
||||
print(manager.is_module_enabled('school1', 'chat'))
|
||||
|
||||
# Reload configuration
|
||||
manager.reload()
|
||||
print(manager.is_module_enabled('school1', 'chat'))
|
||||
```
|
||||
|
||||
**Expected:** Should print True (twice) or False (twice) without errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Post-Decorator Testing (30 minutes)
|
||||
|
||||
After you've added decorators to routes:
|
||||
|
||||
### ✅ Verify Decorators Added
|
||||
|
||||
```bash
|
||||
grep -c "@tenant_guards.require" main.py
|
||||
# Should be >= 25
|
||||
```
|
||||
|
||||
### ✅ Test Protected Routes
|
||||
|
||||
For each protected route, test both allowed and denied access:
|
||||
|
||||
```bash
|
||||
# Blog - should be enabled for default
|
||||
curl -I http://localhost:4999/blog
|
||||
# Expected: 200 or redirect (allowed)
|
||||
|
||||
# Tickets - should be disabled for default
|
||||
curl -I http://localhost:4999/tickets
|
||||
# Expected: 403 (denied)
|
||||
|
||||
# Admin - should be disabled for default
|
||||
curl -I http://localhost:4999/admin/dashboard
|
||||
# Expected: 403 (denied)
|
||||
```
|
||||
|
||||
### ✅ Test Admin Routes
|
||||
|
||||
```bash
|
||||
# Admin should be disabled for default
|
||||
curl -I http://localhost:4999/admin/users
|
||||
# Expected: 403
|
||||
|
||||
# Admin should be enabled for school1
|
||||
curl -H "X-Tenant-ID: school1" -I http://localhost:4999/admin/users
|
||||
# Expected: 200 or redirect to login (module exists, just needs auth)
|
||||
```
|
||||
|
||||
### ✅ Test Feature Combinations
|
||||
|
||||
Test that combining decorators works:
|
||||
|
||||
```bash
|
||||
# Admin + module check
|
||||
curl -I http://localhost:4999/admin/invoices
|
||||
# Expected: 403 (admin disabled for default)
|
||||
|
||||
curl -H "X-Tenant-ID: school1" -I http://localhost:4999/admin/invoices
|
||||
# Expected: 200 or redirect (both admin and invoices enabled for school1)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Template Testing (10 minutes)
|
||||
|
||||
### ✅ Check Template Functions Available
|
||||
|
||||
Create a test template:
|
||||
|
||||
```jinja2
|
||||
<!-- test_tenant.html -->
|
||||
Current Tenant: {{ tenant_id }}
|
||||
Chat Enabled: {{ module_enabled('chat') }}
|
||||
Blog Enabled: {{ module_enabled('blog') }}
|
||||
|
||||
Enabled Modules:
|
||||
{% for module in enabled_modules %}
|
||||
- {{ module }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
Add test route:
|
||||
|
||||
```python
|
||||
@app.route('/test/tenant')
|
||||
def test_tenant():
|
||||
return render_template('test_tenant.html')
|
||||
```
|
||||
|
||||
Test it:
|
||||
|
||||
```bash
|
||||
curl http://localhost:4999/test/tenant
|
||||
# Should show: Current Tenant: default, Chat Enabled: True, etc.
|
||||
|
||||
curl -H "X-Tenant-ID: school1" http://localhost:4999/test/tenant
|
||||
# Should show: Current Tenant: school1, Chat Enabled: False, etc.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Edge Case Testing (10 minutes)
|
||||
|
||||
### ✅ Invalid Tenant ID
|
||||
|
||||
```bash
|
||||
curl -H "X-Tenant-ID: invalid@tenant#id" http://localhost:4999/debug/config
|
||||
# Should fall back to default tenant
|
||||
```
|
||||
|
||||
### ✅ Missing Module Config
|
||||
|
||||
Add a new module to a route but not to tenants.json:
|
||||
|
||||
```python
|
||||
@app.route('/test/new-module')
|
||||
@tenant_guards.require_module('nonexistent')
|
||||
def test_new():
|
||||
return "OK"
|
||||
```
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
curl -I http://localhost:4999/test/new-module
|
||||
# Expected: 403 (module not configured, defaults to disabled)
|
||||
```
|
||||
|
||||
### ✅ Malformed JSON Response
|
||||
|
||||
```bash
|
||||
curl -H "Accept: application/json" \
|
||||
http://localhost:4999/test/new-module
|
||||
# Should return valid JSON with error
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Summary
|
||||
|
||||
### Quick Test Script
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
echo "Testing tenant system..."
|
||||
echo
|
||||
|
||||
# Test 1: Configuration
|
||||
echo "✓ Testing configuration loading..."
|
||||
python -c "from tenant_config import get_config_manager; get_config_manager()" || exit 1
|
||||
|
||||
# Test 2: Unit tests
|
||||
echo "✓ Running unit tests..."
|
||||
pytest test_tenant_system.py -q || exit 1
|
||||
|
||||
# Test 3: Flask startup
|
||||
echo "✓ Testing Flask app..."
|
||||
python -c "import main" || exit 1
|
||||
|
||||
echo
|
||||
echo "✅ All tests passed!"
|
||||
```
|
||||
|
||||
Save as `test_tenant.sh`, run with `bash test_tenant.sh`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Import errors
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check Python path
|
||||
python -c "import sys; print(sys.path)"
|
||||
|
||||
# Ensure you're in Website directory
|
||||
cd Website
|
||||
|
||||
# Try importing directly
|
||||
python -c "import tenant_resolver"
|
||||
```
|
||||
|
||||
### Issue: Configuration not loading
|
||||
|
||||
**Fix:**
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
manager = get_config_manager()
|
||||
print(manager._config)
|
||||
```
|
||||
|
||||
If empty, check:
|
||||
1. `tenants.json` exists in Website directory
|
||||
2. File is valid JSON: `python -m json.tool tenants.json`
|
||||
|
||||
### Issue: Tests failing
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Run with verbose output
|
||||
pytest test_tenant_system.py -vv
|
||||
|
||||
# Run single test
|
||||
pytest test_tenant_system.py::TestTenantResolver::test_extract_subdomain_valid -v
|
||||
```
|
||||
|
||||
### Issue: Decorators not working
|
||||
|
||||
**Fix:**
|
||||
1. Verify decorators are added ABOVE `@app.route()`
|
||||
2. Check decorator names match exactly
|
||||
3. Verify module names match `tenants.json`
|
||||
4. Restart Flask app after changes
|
||||
|
||||
### Issue: 403 but should be allowed
|
||||
|
||||
**Fix:**
|
||||
1. Check tenant resolution: Add debug logging
|
||||
2. Check module configuration in `tenants.json`
|
||||
3. Verify module name is spelled correctly
|
||||
4. Check decorator is actually applied
|
||||
|
||||
---
|
||||
|
||||
## Performance Testing (Optional)
|
||||
|
||||
### Load Test
|
||||
|
||||
```python
|
||||
import requests
|
||||
import time
|
||||
|
||||
start = time.time()
|
||||
for i in range(100):
|
||||
requests.get('http://localhost:4999/',
|
||||
headers={'X-Tenant-ID': f'tenant-{i%5}'})
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"100 requests in {elapsed:.2f}s = {100/elapsed:.0f} req/s")
|
||||
# Should be > 1000 req/s (tenant system has minimal overhead)
|
||||
```
|
||||
|
||||
### Memory Test
|
||||
|
||||
Configuration should use minimal memory:
|
||||
|
||||
```python
|
||||
from tenant_config import get_config_manager
|
||||
import sys
|
||||
|
||||
manager = get_config_manager()
|
||||
size_bytes = sys.getsizeof(manager._config)
|
||||
size_kb = size_bytes / 1024
|
||||
|
||||
print(f"Configuration size: {size_kb:.2f} KB")
|
||||
# Should be < 100 KB for typical configuration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sign-Off Checklist
|
||||
|
||||
- [ ] All files created and readable
|
||||
- [ ] Unit tests pass (pytest)
|
||||
- [ ] Flask app starts without errors
|
||||
- [ ] Tenant resolution works (curl tests)
|
||||
- [ ] Configuration loads correctly
|
||||
- [ ] Error handling works (403 responses)
|
||||
- [ ] JSON API responses are correct
|
||||
- [ ] Decorators prevent unauthorized access
|
||||
- [ ] Decorators allow authorized access
|
||||
- [ ] Template context available
|
||||
- [ ] No significant performance impact
|
||||
|
||||
**If all checkmarks are ticked, the system is working correctly!** 🎉
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Add decorators to remaining routes (see TENANT_LINE_NUMBERS.md)
|
||||
2. Update templates to use module_enabled() checks
|
||||
3. Customize tenants.json for your specific needs
|
||||
4. Deploy to production
|
||||
5. Monitor for issues
|
||||
+1
-46
@@ -1,4 +1,4 @@
|
||||
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session, send_file, after_this_request, g
|
||||
from flask import Flask, render_template, request, jsonify, flash, redirect, url_for, get_flashed_messages, session, send_file, after_this_request
|
||||
import os
|
||||
import json
|
||||
import atexit
|
||||
@@ -22,12 +22,6 @@ from pymongo.errors import PyMongoError
|
||||
from bson.objectid import ObjectId
|
||||
import user as user_store
|
||||
|
||||
# Tenant-aware configuration imports
|
||||
import tenant_resolver
|
||||
import tenant_config
|
||||
import tenant_guards
|
||||
import tenant_templates
|
||||
|
||||
app = Flask(__name__)
|
||||
app.secret_key = "ASDfhbsdfseiufhgildsrfrjg874368546987s6e8468f4!?FAUS/&s"
|
||||
app.config["SESSION_COOKIE_HTTPONLY"] = True
|
||||
@@ -36,45 +30,6 @@ app.config["SESSION_COOKIE_SECURE"] = os.environ.get("SESSION_COOKIE_SECURE", "0
|
||||
app.config["PREFERRED_URL_SCHEME"] = "https" if os.environ.get("SESSION_COOKIE_SECURE") == "1" else "http"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TENANT-AWARE CONFIGURATION SETUP
|
||||
# ============================================================================
|
||||
|
||||
# Register Jinja2 context processor for tenant-aware template helpers
|
||||
app.context_processor(tenant_templates.inject_tenant_context)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def resolve_tenant_context():
|
||||
"""
|
||||
Resolve the active tenant for the current request and store in g.
|
||||
This runs before every request, making the tenant available to all handlers.
|
||||
"""
|
||||
parent_domain = os.environ.get("INSTANCE_PARENT_DOMAIN", "meine-domain")
|
||||
tenant_id = tenant_resolver.resolve_tenant(parent_domain)
|
||||
g.tenant_id = tenant_id
|
||||
|
||||
|
||||
@app.errorhandler(403)
|
||||
def forbidden_error(error):
|
||||
"""
|
||||
Custom error handler for 403 Forbidden responses.
|
||||
Provides tenant-aware error messages for disabled modules.
|
||||
"""
|
||||
if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
|
||||
return jsonify({
|
||||
'error': 'Access denied',
|
||||
'message': 'This resource is not available for your organization.'
|
||||
}), 403
|
||||
|
||||
return render_template('error_403.html',
|
||||
tenant_id=g.get('tenant_id', 'default')), 403
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
|
||||
|
||||
@app.after_request
|
||||
def set_security_headers(response):
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>403 - Zugriff verweigert</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 60px 40px;
|
||||
max-width: 500px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
font-size: 72px;
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-size: 28px;
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 16px;
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-details {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #667eea;
|
||||
padding: 15px;
|
||||
margin-bottom: 30px;
|
||||
text-align: left;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: #764ba2;
|
||||
}
|
||||
|
||||
.tenant-info {
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<div class="error-code">403</div>
|
||||
<div class="error-title">Zugriff verweigert</div>
|
||||
|
||||
<div class="error-message">
|
||||
Diese Ressource ist für Ihre Organisation nicht verfügbar.
|
||||
</div>
|
||||
|
||||
<div class="error-details">
|
||||
<strong>Grund:</strong> Das angeforderte Modul ist in der Konfiguration Ihrer Organisation nicht aktiviert.
|
||||
<br><br>
|
||||
Kontaktieren Sie einen Administrator, wenn Sie glauben, dass dies ein Fehler ist.
|
||||
</div>
|
||||
|
||||
<a href="/" class="back-link">Zur Startseite</a>
|
||||
|
||||
{% if tenant_id %}
|
||||
<div class="tenant-info">
|
||||
Tenant: {{ tenant_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,246 +0,0 @@
|
||||
"""
|
||||
Tenant-aware configuration system for module and feature management.
|
||||
|
||||
Supports:
|
||||
- Global default module configuration
|
||||
- Per-tenant overrides
|
||||
- Runtime module availability checks
|
||||
- Safe fallback to defaults for missing configurations
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Set
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TenantConfigManager:
|
||||
"""
|
||||
Manages tenant-specific configurations with global defaults and per-tenant overrides.
|
||||
"""
|
||||
|
||||
def __init__(self, config_file: Optional[str] = None):
|
||||
"""
|
||||
Initialize the tenant config manager.
|
||||
|
||||
Args:
|
||||
config_file: Path to the tenants.json configuration file.
|
||||
If not provided, defaults to tenants.json in the same directory.
|
||||
"""
|
||||
if config_file is None:
|
||||
config_file = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"tenants.json"
|
||||
)
|
||||
|
||||
self.config_file = config_file
|
||||
self._config: Dict[str, Any] = {}
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self) -> None:
|
||||
"""Load configuration from tenants.json file."""
|
||||
try:
|
||||
if not os.path.exists(self.config_file):
|
||||
logger.warning(f"Config file not found: {self.config_file}. Using empty defaults.")
|
||||
self._config = {"defaults": {"modules": {}}, "tenants": {}}
|
||||
return
|
||||
|
||||
with open(self.config_file, 'r', encoding='utf-8') as f:
|
||||
self._config = json.load(f)
|
||||
|
||||
# Ensure required sections exist
|
||||
if "defaults" not in self._config:
|
||||
self._config["defaults"] = {}
|
||||
if "modules" not in self._config["defaults"]:
|
||||
self._config["defaults"]["modules"] = {}
|
||||
if "tenants" not in self._config:
|
||||
self._config["tenants"] = {}
|
||||
|
||||
logger.info(f"Loaded tenant configuration from {self.config_file}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Invalid JSON in config file: {e}")
|
||||
self._config = {"defaults": {"modules": {}}, "tenants": {}}
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading config file: {e}")
|
||||
self._config = {"defaults": {"modules": {}}, "tenants": {}}
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Reload configuration from file."""
|
||||
self._load_config()
|
||||
|
||||
def get_tenant_config(self, tenant_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the complete configuration for a tenant (merged with defaults).
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
Dict with tenant configuration, including inherited defaults
|
||||
"""
|
||||
# Validate tenant_id
|
||||
if not isinstance(tenant_id, str) or not tenant_id.strip():
|
||||
return self._get_default_config()
|
||||
|
||||
tenant_id = tenant_id.lower().strip()
|
||||
|
||||
# Get tenant-specific config if it exists
|
||||
tenant_config = self._config.get("tenants", {}).get(tenant_id, {})
|
||||
|
||||
# Start with defaults
|
||||
merged = {
|
||||
"modules": dict(self._config.get("defaults", {}).get("modules", {}))
|
||||
}
|
||||
|
||||
# Merge tenant-specific module overrides
|
||||
if "modules" in tenant_config:
|
||||
merged["modules"].update(tenant_config.get("modules", {}))
|
||||
|
||||
# Include any other tenant-specific settings
|
||||
for key in tenant_config:
|
||||
if key != "modules":
|
||||
merged[key] = tenant_config[key]
|
||||
|
||||
return merged
|
||||
|
||||
def _get_default_config(self) -> Dict[str, Any]:
|
||||
"""Get the default configuration."""
|
||||
return {
|
||||
"modules": dict(self._config.get("defaults", {}).get("modules", {}))
|
||||
}
|
||||
|
||||
def is_module_enabled(self, tenant_id: str, module_name: str) -> bool:
|
||||
"""
|
||||
Check if a module is enabled for a specific tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
module_name: The module name (e.g., 'library', 'chat', 'appointments')
|
||||
|
||||
Returns:
|
||||
True if the module is enabled, False otherwise
|
||||
"""
|
||||
if not isinstance(module_name, str) or not module_name.strip():
|
||||
return False
|
||||
|
||||
config = self.get_tenant_config(tenant_id)
|
||||
modules = config.get("modules", {})
|
||||
|
||||
# If module is explicitly configured, use that value
|
||||
if module_name in modules:
|
||||
enabled = modules[module_name]
|
||||
return enabled is True or (isinstance(enabled, dict) and enabled.get("enabled", False) is True)
|
||||
|
||||
# If not configured, default to False (fail-safe for optional features)
|
||||
return False
|
||||
|
||||
def get_enabled_modules(self, tenant_id: str) -> Set[str]:
|
||||
"""
|
||||
Get the set of enabled modules for a tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
Set of enabled module names
|
||||
"""
|
||||
config = self.get_tenant_config(tenant_id)
|
||||
modules = config.get("modules", {})
|
||||
|
||||
enabled = set()
|
||||
for module_name, module_config in modules.items():
|
||||
if module_config is True:
|
||||
enabled.add(module_name)
|
||||
elif isinstance(module_config, dict) and module_config.get("enabled", False) is True:
|
||||
enabled.add(module_name)
|
||||
|
||||
return enabled
|
||||
|
||||
def get_all_modules(self) -> Set[str]:
|
||||
"""
|
||||
Get all module names defined in the configuration.
|
||||
|
||||
Returns:
|
||||
Set of all module names
|
||||
"""
|
||||
all_modules = set()
|
||||
|
||||
# Add modules from defaults
|
||||
all_modules.update(self._config.get("defaults", {}).get("modules", {}).keys())
|
||||
|
||||
# Add modules from tenants
|
||||
for tenant_config in self._config.get("tenants", {}).values():
|
||||
all_modules.update(tenant_config.get("modules", {}).keys())
|
||||
|
||||
return all_modules
|
||||
|
||||
def get_config_value(self, tenant_id: str, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a tenant-specific configuration value with fallback to default.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
key: The configuration key
|
||||
default: The default value if not found
|
||||
|
||||
Returns:
|
||||
The configuration value or default
|
||||
"""
|
||||
config = self.get_tenant_config(tenant_id)
|
||||
return config.get(key, default)
|
||||
|
||||
|
||||
# Global instance (lazy loaded)
|
||||
_manager: Optional[TenantConfigManager] = None
|
||||
|
||||
|
||||
def get_config_manager() -> TenantConfigManager:
|
||||
"""Get or create the global config manager instance."""
|
||||
global _manager
|
||||
if _manager is None:
|
||||
_manager = TenantConfigManager()
|
||||
return _manager
|
||||
|
||||
|
||||
def is_module_enabled(tenant_id: str, module_name: str) -> bool:
|
||||
"""
|
||||
Check if a module is enabled for a tenant (convenience function).
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
module_name: The module name
|
||||
|
||||
Returns:
|
||||
True if enabled, False otherwise
|
||||
"""
|
||||
return get_config_manager().is_module_enabled(tenant_id, module_name)
|
||||
|
||||
|
||||
def get_tenant_config(tenant_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get tenant configuration (convenience function).
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
The tenant configuration dict
|
||||
"""
|
||||
return get_config_manager().get_tenant_config(tenant_id)
|
||||
|
||||
|
||||
def get_enabled_modules(tenant_id: str) -> Set[str]:
|
||||
"""
|
||||
Get enabled modules for a tenant (convenience function).
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
Set of enabled module names
|
||||
"""
|
||||
return get_config_manager().get_enabled_modules(tenant_id)
|
||||
@@ -1,162 +0,0 @@
|
||||
"""
|
||||
Route guards and helpers for module-aware access control.
|
||||
|
||||
Provides decorators and functions to:
|
||||
- Protect routes based on module availability
|
||||
- Return consistent error responses for disabled modules
|
||||
- Check module availability in views
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
from flask import request, jsonify, abort, g
|
||||
from typing import Callable, Any
|
||||
from tenant_config import is_module_enabled, get_enabled_modules
|
||||
|
||||
|
||||
def require_module(module_name: str) -> Callable:
|
||||
"""
|
||||
Decorator to require a module to be enabled for accessing a route.
|
||||
|
||||
Usage:
|
||||
@app.route('/chat')
|
||||
@require_module('chat')
|
||||
def chat_endpoint():
|
||||
return render_template('chat.html')
|
||||
|
||||
Args:
|
||||
module_name: The module to check (e.g., 'chat', 'invoices')
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs) -> Any:
|
||||
tenant_id = g.get('tenant_id', 'default')
|
||||
|
||||
if not is_module_enabled(tenant_id, module_name):
|
||||
# For JSON requests, return JSON response
|
||||
if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
|
||||
return jsonify({
|
||||
'error': 'Module not available',
|
||||
'message': f'The {module_name} module is not available for your organization.',
|
||||
'module': module_name
|
||||
}), 403
|
||||
|
||||
# For HTML requests, return abort with custom message
|
||||
abort(403)
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def require_admin() -> Callable:
|
||||
"""
|
||||
Decorator to require admin module to be enabled (and user to be admin).
|
||||
|
||||
Usage:
|
||||
@app.route('/admin/dashboard')
|
||||
@require_admin()
|
||||
def admin_dashboard():
|
||||
return render_template('admin_dashboard.html')
|
||||
|
||||
Returns:
|
||||
Decorator function
|
||||
"""
|
||||
def decorator(f: Callable) -> Callable:
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs) -> Any:
|
||||
tenant_id = g.get('tenant_id', 'default')
|
||||
|
||||
# Check if admin module is enabled
|
||||
if not is_module_enabled(tenant_id, 'admin'):
|
||||
if request.accept_mimetypes.best_match(['application/json', 'text/html']) == 'application/json':
|
||||
return jsonify({
|
||||
'error': 'Admin module not available',
|
||||
'message': 'Admin functionality is not available for your organization.'
|
||||
}), 403
|
||||
abort(403)
|
||||
|
||||
# Additional user role check (if implemented)
|
||||
# This would be combined with existing auth checks
|
||||
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
|
||||
def module_enabled_in_context(module_name: str) -> bool:
|
||||
"""
|
||||
Check if a module is enabled for the current request context.
|
||||
|
||||
Can be used in route handlers and templates (via Jinja2 context).
|
||||
|
||||
Args:
|
||||
module_name: The module name
|
||||
|
||||
Returns:
|
||||
True if enabled, False otherwise
|
||||
"""
|
||||
tenant_id = g.get('tenant_id', 'default')
|
||||
return is_module_enabled(tenant_id, module_name)
|
||||
|
||||
|
||||
def get_enabled_modules_in_context() -> set:
|
||||
"""
|
||||
Get all enabled modules for the current request context.
|
||||
|
||||
Useful for determining which menu items to show, etc.
|
||||
|
||||
Returns:
|
||||
Set of enabled module names
|
||||
"""
|
||||
tenant_id = g.get('tenant_id', 'default')
|
||||
return get_enabled_modules(tenant_id)
|
||||
|
||||
|
||||
class TenantAwareErrorHandler:
|
||||
"""
|
||||
Provides consistent error handling for tenant-specific access issues.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def module_not_available(module_name: str, is_json: bool = False):
|
||||
"""
|
||||
Generate error response for disabled module.
|
||||
|
||||
Args:
|
||||
module_name: The module that is not available
|
||||
is_json: Whether to return JSON or HTML
|
||||
|
||||
Returns:
|
||||
Response tuple (response, status_code)
|
||||
"""
|
||||
if is_json:
|
||||
return jsonify({
|
||||
'error': 'Module not available',
|
||||
'message': f'The {module_name} module is not available for your organization.',
|
||||
'module': module_name
|
||||
}), 403
|
||||
|
||||
abort(403)
|
||||
|
||||
@staticmethod
|
||||
def unauthorized_access(reason: str = None, is_json: bool = False):
|
||||
"""
|
||||
Generate error response for unauthorized access.
|
||||
|
||||
Args:
|
||||
reason: Optional reason for the denial
|
||||
is_json: Whether to return JSON or HTML
|
||||
|
||||
Returns:
|
||||
Response tuple (response, status_code)
|
||||
"""
|
||||
if is_json:
|
||||
return jsonify({
|
||||
'error': 'Access denied',
|
||||
'message': reason or 'You do not have access to this resource.'
|
||||
}), 403
|
||||
|
||||
abort(403)
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Tenant resolver for identifying the active tenant from incoming requests.
|
||||
|
||||
Supports:
|
||||
- Subdomain-based tenant detection (e.g., school1.example.com -> school1)
|
||||
- X-Tenant-ID header for internal APIs and testing
|
||||
- Fallback to 'default' tenant
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
from flask import request
|
||||
|
||||
|
||||
def extract_subdomain(host: str, parent_domain: str) -> Optional[str]:
|
||||
"""
|
||||
Extract subdomain from host if it matches the parent domain.
|
||||
|
||||
Args:
|
||||
host: The Host header value (e.g., 'school1.example.com')
|
||||
parent_domain: The parent domain (e.g., 'example.com')
|
||||
|
||||
Returns:
|
||||
The subdomain if found, None otherwise
|
||||
"""
|
||||
if not host or not parent_domain:
|
||||
return None
|
||||
|
||||
# Remove port if present
|
||||
host = host.split(':')[0]
|
||||
parent_domain = parent_domain.strip()
|
||||
|
||||
# Check if host ends with parent domain
|
||||
if not host.endswith(parent_domain):
|
||||
return None
|
||||
|
||||
# Extract subdomain
|
||||
prefix = host[: -len(parent_domain)].rstrip('.')
|
||||
|
||||
# Validate subdomain (alphanumeric, hyphens, underscores)
|
||||
if prefix and re.match(r'^[a-zA-Z0-9_-]+$', prefix):
|
||||
return prefix.lower()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def resolve_tenant(parent_domain: str = "example.com") -> str:
|
||||
"""
|
||||
Resolve the active tenant for the current request.
|
||||
|
||||
Resolution order:
|
||||
1. X-Tenant-ID header (for APIs and testing)
|
||||
2. Subdomain from Host header
|
||||
3. Fallback to 'default'
|
||||
|
||||
Args:
|
||||
parent_domain: The parent domain for subdomain extraction
|
||||
|
||||
Returns:
|
||||
The tenant identifier (lowercase alphanumeric string)
|
||||
"""
|
||||
# 1. Check X-Tenant-ID header
|
||||
tenant_from_header = request.headers.get("X-Tenant-ID", "").strip().lower()
|
||||
if tenant_from_header and re.match(r'^[a-zA-Z0-9_-]+$', tenant_from_header):
|
||||
return tenant_from_header
|
||||
|
||||
# 2. Check subdomain
|
||||
host = request.host
|
||||
subdomain = extract_subdomain(host, parent_domain)
|
||||
if subdomain:
|
||||
return subdomain
|
||||
|
||||
# 3. Fallback to default
|
||||
return "default"
|
||||
@@ -1,45 +0,0 @@
|
||||
"""
|
||||
Jinja2 template helpers for tenant-aware module visibility.
|
||||
|
||||
Provides template functions to:
|
||||
- Check if a module is enabled in templates
|
||||
- Get list of enabled modules
|
||||
- Conditionally show UI elements based on module availability
|
||||
"""
|
||||
|
||||
from flask import g
|
||||
from tenant_config import is_module_enabled, get_enabled_modules
|
||||
|
||||
|
||||
def inject_tenant_context():
|
||||
"""
|
||||
Jinja2 context processor to inject tenant-aware helpers into all templates.
|
||||
|
||||
Usage in Flask app setup:
|
||||
app.context_processor(inject_tenant_context)
|
||||
|
||||
This makes available in templates:
|
||||
- module_enabled(module_name) - check if module is enabled
|
||||
- enabled_modules - set of enabled module names
|
||||
- tenant_id - current tenant identifier
|
||||
"""
|
||||
tenant_id = g.get('tenant_id', 'default')
|
||||
|
||||
return {
|
||||
'module_enabled': lambda module: is_module_enabled(tenant_id, module),
|
||||
'enabled_modules': get_enabled_modules(tenant_id),
|
||||
'tenant_id': tenant_id
|
||||
}
|
||||
|
||||
|
||||
def module_visible(module_name: str) -> bool:
|
||||
"""
|
||||
Check if a module should be visible in UI (convenience function).
|
||||
|
||||
Args:
|
||||
module_name: The module name
|
||||
|
||||
Returns:
|
||||
True if visible/enabled, False otherwise
|
||||
"""
|
||||
return is_module_enabled(g.get('tenant_id', 'default'), module_name)
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": false,
|
||||
"blog": true,
|
||||
"chat": false,
|
||||
"tickets": false,
|
||||
"invoices": false,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"kontakt": true,
|
||||
"admin": false
|
||||
},
|
||||
"description": "Default configuration for all tenants. Tenants inherit these settings unless they override them explicitly."
|
||||
},
|
||||
"tenants": {
|
||||
"default": {
|
||||
"description": "Default tenant - used as fallback when tenant cannot be resolved"
|
||||
},
|
||||
"school1": {
|
||||
"description": "School 1 - full featured",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": true,
|
||||
"chat": true,
|
||||
"tickets": true,
|
||||
"invoices": true,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"school2": {
|
||||
"description": "School 2 - limited features, no invoicing",
|
||||
"modules": {
|
||||
"inventarsystem": true,
|
||||
"appointments": true,
|
||||
"blog": false,
|
||||
"chat": false,
|
||||
"tickets": true,
|
||||
"invoices": false,
|
||||
"dienstleistungen": false,
|
||||
"projekte": false,
|
||||
"team": true,
|
||||
"admin": true
|
||||
}
|
||||
},
|
||||
"partner-org": {
|
||||
"description": "Partner organization - service provider",
|
||||
"modules": {
|
||||
"inventarsystem": false,
|
||||
"appointments": false,
|
||||
"blog": false,
|
||||
"chat": true,
|
||||
"tickets": false,
|
||||
"invoices": true,
|
||||
"dienstleistungen": true,
|
||||
"projekte": true,
|
||||
"team": true,
|
||||
"admin": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,341 +0,0 @@
|
||||
"""
|
||||
Unit tests for the tenant-aware configuration system.
|
||||
|
||||
Run with: pytest test_tenant_system.py -v
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Test imports
|
||||
from tenant_resolver import resolve_tenant, extract_subdomain
|
||||
from tenant_config import (
|
||||
TenantConfigManager,
|
||||
is_module_enabled,
|
||||
get_config_manager,
|
||||
get_enabled_modules,
|
||||
get_tenant_config,
|
||||
)
|
||||
|
||||
|
||||
class TestTenantResolver:
|
||||
"""Tests for tenant resolution from requests."""
|
||||
|
||||
def test_extract_subdomain_valid(self):
|
||||
"""Test extracting valid subdomain."""
|
||||
assert extract_subdomain("school1.example.com", "example.com") == "school1"
|
||||
assert extract_subdomain("my-tenant.example.com", "example.com") == "my-tenant"
|
||||
assert extract_subdomain("tenant_1.example.com", "example.com") == "tenant_1"
|
||||
|
||||
def test_extract_subdomain_with_port(self):
|
||||
"""Test subdomain extraction with port number."""
|
||||
assert extract_subdomain("school1.example.com:8080", "example.com") == "school1"
|
||||
assert extract_subdomain("example.com:443", "example.com") is None
|
||||
|
||||
def test_extract_subdomain_invalid(self):
|
||||
"""Test invalid subdomain extraction."""
|
||||
assert extract_subdomain("example.com", "example.com") is None
|
||||
assert extract_subdomain("other.com", "example.com") is None
|
||||
assert extract_subdomain("", "example.com") is None
|
||||
# Subdomain with invalid characters
|
||||
assert extract_subdomain("invalid@sub.example.com", "example.com") is None
|
||||
assert extract_subdomain("invalid#sub.example.com", "example.com") is None
|
||||
|
||||
def test_extract_subdomain_case_insensitive(self):
|
||||
"""Test that subdomains are normalized to lowercase."""
|
||||
result = extract_subdomain("SCHOOL1.example.com", "example.com")
|
||||
assert result == "school1" # Should be lowercase
|
||||
|
||||
|
||||
class TestTenantConfigManager:
|
||||
"""Tests for tenant configuration management."""
|
||||
|
||||
@pytest.fixture
|
||||
def config_file(self):
|
||||
"""Create a temporary config file for testing."""
|
||||
config = {
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"chat": True,
|
||||
"blog": False,
|
||||
"tickets": False,
|
||||
"invoices": False,
|
||||
}
|
||||
},
|
||||
"tenants": {
|
||||
"tenant_a": {
|
||||
"modules": {
|
||||
"chat": False,
|
||||
"blog": True,
|
||||
}
|
||||
},
|
||||
"tenant_b": {
|
||||
"modules": {
|
||||
"invoices": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(config, f)
|
||||
yield path
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_load_config(self, config_file):
|
||||
"""Test loading configuration from file."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
assert "defaults" in manager._config
|
||||
assert "tenants" in manager._config
|
||||
assert "tenant_a" in manager._config["tenants"]
|
||||
|
||||
def test_missing_config_file(self):
|
||||
"""Test handling of missing configuration file."""
|
||||
manager = TenantConfigManager("/nonexistent/path/tenants.json")
|
||||
# Should create empty config with safe defaults
|
||||
assert manager._config == {"defaults": {"modules": {}}, "tenants": {}}
|
||||
|
||||
def test_get_tenant_config_with_defaults(self, config_file):
|
||||
"""Test getting tenant config with defaults merged."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Tenant A overrides some modules
|
||||
config_a = manager.get_tenant_config("tenant_a")
|
||||
assert config_a["modules"]["chat"] == False # Override
|
||||
assert config_a["modules"]["blog"] == True # Override
|
||||
assert config_a["modules"]["tickets"] == False # From default
|
||||
|
||||
def test_get_tenant_config_inherits_defaults(self, config_file):
|
||||
"""Test that tenant inherits all defaults."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Tenant B has minimal overrides
|
||||
config_b = manager.get_tenant_config("tenant_b")
|
||||
assert config_b["modules"]["chat"] == True # From default
|
||||
assert config_b["modules"]["blog"] == False # From default
|
||||
assert config_b["modules"]["invoices"] == True # Override
|
||||
|
||||
def test_get_tenant_config_missing_tenant(self, config_file):
|
||||
"""Test getting config for non-existent tenant."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
config = manager.get_tenant_config("nonexistent_tenant")
|
||||
# Should inherit all defaults
|
||||
assert config["modules"]["chat"] == True
|
||||
assert config["modules"]["blog"] == False
|
||||
|
||||
def test_is_module_enabled_default_true(self, config_file):
|
||||
"""Test module enabled when default is true."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# chat is enabled by default
|
||||
assert manager.is_module_enabled("tenant_c", "chat") == True
|
||||
|
||||
def test_is_module_enabled_default_false(self, config_file):
|
||||
"""Test module disabled when default is false."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# blog is disabled by default
|
||||
assert manager.is_module_enabled("tenant_c", "blog") == False
|
||||
|
||||
def test_is_module_enabled_override(self, config_file):
|
||||
"""Test module override by tenant."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# tenant_a overrides chat to false
|
||||
assert manager.is_module_enabled("tenant_a", "chat") == False
|
||||
# tenant_a overrides blog to true
|
||||
assert manager.is_module_enabled("tenant_a", "blog") == True
|
||||
|
||||
def test_is_module_enabled_nonexistent_module(self, config_file):
|
||||
"""Test checking non-existent module."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Module not configured anywhere should be False
|
||||
assert manager.is_module_enabled("tenant_a", "nonexistent") == False
|
||||
|
||||
def test_get_enabled_modules(self, config_file):
|
||||
"""Test getting set of enabled modules."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Tenant A has chat=false and blog=true (+ inherited tickets=false, invoices=false)
|
||||
enabled = manager.get_enabled_modules("tenant_a")
|
||||
assert "blog" in enabled
|
||||
assert "chat" not in enabled
|
||||
assert "tickets" not in enabled
|
||||
assert "invoices" not in enabled
|
||||
|
||||
def test_get_all_modules(self, config_file):
|
||||
"""Test getting all modules defined in config."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
all_modules = manager.get_all_modules()
|
||||
assert "chat" in all_modules
|
||||
assert "blog" in all_modules
|
||||
assert "tickets" in all_modules
|
||||
assert "invoices" in all_modules
|
||||
|
||||
def test_invalid_json_file(self):
|
||||
"""Test handling of invalid JSON."""
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
f.write("{ invalid json }")
|
||||
|
||||
manager = TenantConfigManager(path)
|
||||
# Should fall back to empty config
|
||||
assert manager._config == {"defaults": {"modules": {}}, "tenants": {}}
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_reload_config(self, config_file):
|
||||
"""Test reloading configuration."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Verify initial state
|
||||
assert manager.is_module_enabled("tenant_a", "chat") == False
|
||||
|
||||
# Modify the config file
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
config["tenants"]["tenant_a"]["modules"]["chat"] = True
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(config, f)
|
||||
|
||||
# Reload and verify new state
|
||||
manager.reload()
|
||||
assert manager.is_module_enabled("tenant_a", "chat") == True
|
||||
|
||||
def test_get_config_value(self, config_file):
|
||||
"""Test getting arbitrary config values."""
|
||||
manager = TenantConfigManager(config_file)
|
||||
|
||||
# Get existing value
|
||||
modules = manager.get_config_value("tenant_a", "modules")
|
||||
assert modules is not None
|
||||
|
||||
# Get non-existent value with default
|
||||
value = manager.get_config_value("tenant_a", "nonexistent", default="fallback")
|
||||
assert value == "fallback"
|
||||
|
||||
|
||||
class TestConvenienceFunctions:
|
||||
"""Tests for convenience wrapper functions."""
|
||||
|
||||
@pytest.fixture
|
||||
def setup_global_manager(self):
|
||||
"""Setup global config manager for testing."""
|
||||
config = {
|
||||
"defaults": {
|
||||
"modules": {"test_module": True}
|
||||
},
|
||||
"tenants": {
|
||||
"test_tenant": {
|
||||
"modules": {"test_module": False}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(config, f)
|
||||
|
||||
# Reset global manager
|
||||
import tenant_config
|
||||
tenant_config._manager = TenantConfigManager(path)
|
||||
yield path
|
||||
finally:
|
||||
os.unlink(path)
|
||||
# Reset global manager
|
||||
import tenant_config
|
||||
tenant_config._manager = None
|
||||
|
||||
def test_is_module_enabled_convenience(self, setup_global_manager):
|
||||
"""Test convenience function for module checking."""
|
||||
assert is_module_enabled("default", "test_module") == True
|
||||
assert is_module_enabled("test_tenant", "test_module") == False
|
||||
|
||||
def test_get_tenant_config_convenience(self, setup_global_manager):
|
||||
"""Test convenience function for config retrieval."""
|
||||
config = get_tenant_config("test_tenant")
|
||||
assert "modules" in config
|
||||
assert config["modules"]["test_module"] == False
|
||||
|
||||
def test_get_enabled_modules_convenience(self, setup_global_manager):
|
||||
"""Test convenience function for enabled modules."""
|
||||
enabled = get_enabled_modules("default")
|
||||
assert "test_module" in enabled
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests for edge cases and error handling."""
|
||||
|
||||
def test_empty_tenant_id(self):
|
||||
"""Test handling empty tenant ID."""
|
||||
config = TenantConfigManager("/nonexistent/path/tenants.json")
|
||||
result = config.get_tenant_config("")
|
||||
# Should get defaults
|
||||
assert "modules" in result
|
||||
|
||||
def test_none_tenant_id(self):
|
||||
"""Test handling None tenant ID."""
|
||||
config = TenantConfigManager("/nonexistent/path/tenants.json")
|
||||
result = config.get_tenant_config(None)
|
||||
# Should get defaults
|
||||
assert "modules" in result
|
||||
|
||||
def test_module_as_dict(self):
|
||||
"""Test module configuration as dict with 'enabled' key."""
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
config = {
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"advanced_module": {"enabled": True, "tier": "premium"}
|
||||
}
|
||||
}
|
||||
}
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(config, f)
|
||||
|
||||
manager = TenantConfigManager(path)
|
||||
assert manager.is_module_enabled("default", "advanced_module") == True
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
def test_invalid_module_value(self):
|
||||
"""Test handling invalid module values."""
|
||||
fd, path = tempfile.mkstemp(suffix=".json")
|
||||
try:
|
||||
config = {
|
||||
"defaults": {
|
||||
"modules": {
|
||||
"bad_module": "yes", # Should be boolean
|
||||
"another_bad": None,
|
||||
}
|
||||
}
|
||||
}
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
json.dump(config, f)
|
||||
|
||||
manager = TenantConfigManager(path)
|
||||
# Should treat non-boolean as False (safe)
|
||||
assert manager.is_module_enabled("default", "bad_module") == False
|
||||
assert manager.is_module_enabled("default", "another_bad") == False
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# Integration tests would go here, possibly with Flask test client
|
||||
# These are more complex and depend on Flask app setup
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
Reference in New Issue
Block a user