05/22 Implement license version

This commit is contained in:
2026-05-22 16:31:19 -04:00
parent e6657e60cf
commit 687216e332
16 changed files with 1373 additions and 37 deletions
+67 -31
View File
@@ -179,8 +179,12 @@ def create_app(config_name=None):
except Exception:
_zone = _ZI('UTC')
now_local = _dt.now(_tz.utc).astimezone(_zone)
license_status = current_app.config.get(
'LICENSE_STATUS', {'valid': False, 'tier': 'community', 'reason': 'no_key'}
)
return dict(unread_notifications=unread, branding=branding,
now_local=now_local, app_tz_name=_tz_name)
now_local=now_local, app_tz_name=_tz_name,
license=license_status)
# ── Security headers ──────────────────────────────────────────────────────
@app.after_request
@@ -199,6 +203,27 @@ def create_app(config_name=None):
_seed_settings()
_seed_ticket_templates()
# ── License validation ────────────────────────────────────────────────────
# Validate on startup and cache the result in app.config so every request
# can read it instantly without hitting disk or doing crypto again.
# The result is also injected into Jinja2 templates via inject_globals.
from app.services.license_service import validate_license
_license_status = validate_license(app.config.get('LICENSE_KEY', ''))
app.config['LICENSE_STATUS'] = _license_status
_lic_tier = _license_status.get('tier', 'community')
if _license_status.get('valid'):
app.logger.info(
f'[LICENSE] {_lic_tier.upper()} license valid for '
f'{_license_status.get("customer")}'
f'expires {_license_status.get("expires_at")} '
f'({_license_status.get("days_left")} days left)'
)
else:
app.logger.warning(
f'[LICENSE] No valid license ({_license_status.get("reason", "unknown")}) — '
f'running in Community mode'
)
# ── Background schedulers (SLA + Email Ingestion) ───────────────────────────
# Both jobs share a single BackgroundScheduler instance to avoid duplicate
# thread pools and simplify startup/shutdown. The scheduler is only started
@@ -241,40 +266,51 @@ def _start_background_schedulers(app):
scheduler = BackgroundScheduler(daemon=True)
# SLA breach check — every 30 minutes
scheduler.add_job(
func = check_sla_breaches,
trigger = IntervalTrigger(minutes=30),
id = 'sla_check',
name = 'SLA Breach Check',
replace_existing = True,
args = [app],
)
_lic = app.config.get('LICENSE_STATUS', {})
_lic_tier = _lic.get('tier', 'community')
_is_business = _lic_tier in ('business', 'enterprise')
_is_enterprise = _lic_tier == 'enterprise'
# Inbound email ingestion — interval from SystemSetting
scheduler.add_job(
func = check_inbound_email,
trigger = IntervalTrigger(minutes=email_interval),
id = 'email_ingest',
name = 'Inbound Email Ingestion',
replace_existing = True,
args = [app],
)
# SLA breach check — Business+ only, every 30 minutes
if _is_business:
scheduler.add_job(
func = check_sla_breaches,
trigger = IntervalTrigger(minutes=30),
id = 'sla_check',
name = 'SLA Breach Check',
replace_existing = True,
args = [app],
)
# Weekly digest — every Monday at 08:00 UTC
scheduler.add_job(
func = send_weekly_digest,
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
id = 'weekly_digest',
name = 'Weekly IT Digest Email',
replace_existing = True,
args = [app],
)
# Inbound email ingestion — Enterprise only
if _is_enterprise:
scheduler.add_job(
func = check_inbound_email,
trigger = IntervalTrigger(minutes=email_interval),
id = 'email_ingest',
name = 'Inbound Email Ingestion',
replace_existing = True,
args = [app],
)
# Weekly digest — Business+ only, every Monday at 08:00 UTC
if _is_business:
scheduler.add_job(
func = send_weekly_digest,
trigger = CronTrigger(day_of_week='mon', hour=8, minute=0),
id = 'weekly_digest',
name = 'Weekly IT Digest Email',
replace_existing = True,
args = [app],
)
scheduler.start()
app.logger.info('[SCHEDULER] APScheduler started — SLA check every 30 min, '
f'email ingestion every {email_interval} min, '
'weekly digest every Monday 08:00 UTC')
app.logger.info(
f'[SCHEDULER] APScheduler started — tier={_lic_tier} '
+ ('SLA check every 30 min, ' if _is_business else '')
+ (f'email ingestion every {email_interval} min, ' if _is_enterprise else '')
+ ('weekly digest every Monday 08:00 UTC' if _is_business else '')
)
except Exception as exc:
app.logger.error(f'[SCHEDULER] Failed to start APScheduler: {exc}')
+17
View File
@@ -1572,3 +1572,20 @@ def email_ingestion_run_now():
except Exception as exc:
logger.error(f'[ADMIN EMAIL INGEST] Manual run error: {exc}', exc_info=True)
return jsonify(ok=False, message=f'Run failed: {exc}')
@admin_bp.route('/license')
@login_required
@admin_required
def license_page():
from app.services.license_service import get_status, days_until_expiry, TIER_FEATURES
status = get_status()
days_left = days_until_expiry()
tier = status.get('tier', 'community')
features = TIER_FEATURES.get(tier, set())
return render_template(
'admin/license.html',
status = status,
days_left = days_left,
features = features,
)
+8
View File
@@ -130,6 +130,14 @@ def _call_groq(api_key, history, user_msg, kb_context=''):
@login_required
@limiter.limit('20 per minute; 100 per hour')
def chat():
from app.services.license_service import feature_enabled
if not feature_enabled('chatbot'):
return jsonify({
'reply' : 'The AI chatbot is not available on the Community plan. '
'Upgrade to a Business or Enterprise license to enable it.',
'ticket': None,
}), 403
data = request.get_json(force=True)
history = data.get('history', []) # [{role, content}, ...]
user_msg = data.get('message', '').strip()
+15 -2
View File
@@ -999,6 +999,10 @@ def ticket_survey(token):
@tickets_bp.route('/tickets/<int:ticket_id>/watch', methods=['POST'])
@login_required
def watch_ticket(ticket_id):
from app.services.license_service import feature_enabled
from flask import jsonify
if not feature_enabled('watchers'):
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
@@ -1006,19 +1010,21 @@ def watch_ticket(ticket_id):
if not existing:
db.session.add(TicketWatcher(ticket_id=ticket_id, user_id=current_user.id))
db.session.commit()
from flask import jsonify
return jsonify({'watching': True})
@tickets_bp.route('/tickets/<int:ticket_id>/unwatch', methods=['POST'])
@login_required
def unwatch_ticket(ticket_id):
from app.services.license_service import feature_enabled
from flask import jsonify
if not feature_enabled('watchers'):
return jsonify({'error': 'Ticket watchers require a Business or Enterprise license.'}), 403
ticket = db.session.get(Ticket, ticket_id) or abort(404)
if not current_user.is_it_staff and ticket.created_by_id != current_user.id:
abort(403)
TicketWatcher.query.filter_by(ticket_id=ticket_id, user_id=current_user.id).delete()
db.session.commit()
from flask import jsonify
return jsonify({'watching': False})
@@ -1027,6 +1033,13 @@ def unwatch_ticket(ticket_id):
@tickets_bp.route('/tickets/<int:ticket_id>/log-time', methods=['POST'])
@login_required
def log_time(ticket_id):
from app.services.license_service import feature_enabled
if not feature_enabled('time_tracking'):
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
from flask import jsonify
return jsonify({'error': 'Time tracking requires a Business or Enterprise license.'}), 403
flash('Time tracking requires a Business or Enterprise license.', 'warning')
return redirect(url_for('tickets.ticket_detail', ticket_id=ticket_id))
if not current_user.is_it_staff:
abort(403)
ticket = db.session.get(Ticket, ticket_id) or abort(404)
+177
View File
@@ -0,0 +1,177 @@
"""
TechDesk License Service offline RSA-signed key validation.
Key format: TDESK-<payload_b64>.<signature_b64>
payload_b64 = base64url( UTF-8 JSON bytes )
signature_b64 = base64url( RSA-SHA256 signature of those bytes )
JSON payload fields:
customer company name
email contact email
tier "community" | "business" | "enterprise"
issued_at ISO date (YYYY-MM-DD)
expires_at ISO date (YYYY-MM-DD)
The public key below is the matching counterpart to the private key in
license_server/private_key.pem. Replace the placeholder with the real key
after running license_server/generate_keys.py for the first time.
Public key replacement steps:
1. cd license_server && python generate_keys.py
2. Copy the full contents of license_server/public_key.pem
3. Replace the PUBLIC_KEY_PEM constant below with the copied text
4. Restart the TechDesk app
The public key can only verify signatures it cannot forge them.
It is safe to embed in the application and ship to customers.
"""
import base64
import json
import logging
from datetime import date, datetime
logger = logging.getLogger(__name__)
# ── Replace this with the output of license_server/generate_keys.py ──────────
# After running generate_keys.py, copy the full contents of public_key.pem here.
PUBLIC_KEY_PEM = """-----BEGIN PUBLIC KEY-----
REPLACE_WITH_REAL_PUBLIC_KEY_FROM_license_server/generate_keys.py
-----END PUBLIC KEY-----"""
# ── Tier → feature set mapping ───────────────────────────────────────────────
TIER_FEATURES: dict[str, set] = {
'community' : set(),
'business' : {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys'},
'enterprise': {'chatbot', 'sla', 'digest', 'watchers', 'time_tracking', 'surveys',
'email_ingestion'},
}
_COMMUNITY_STATUS = {
'valid' : False,
'tier' : 'community',
'customer' : None,
'email' : None,
'issued_at' : None,
'expires_at': None,
'days_left' : None,
}
def validate_license(key_string: str) -> dict:
"""Verify an RSA-signed TechDesk license key and return a status dict.
Returns a dict with keys:
valid bool
tier str ('community' | 'business' | 'enterprise')
customer str | None
email str | None
issued_at str | None (YYYY-MM-DD)
expires_at str | None (YYYY-MM-DD)
days_left int | None (None when invalid/no key)
reason str (human-readable; present when valid=False)
"""
if not key_string or not key_string.strip():
return {**_COMMUNITY_STATUS, 'reason': 'no_key'}
key_string = key_string.strip()
if not key_string.startswith('TDESK-'):
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
# Check if the public key is still a placeholder
if 'REPLACE_WITH_REAL_PUBLIC_KEY' in PUBLIC_KEY_PEM:
logger.warning('[LICENSE] Public key is a placeholder — all keys will fail validation. '
'Run license_server/generate_keys.py and update PUBLIC_KEY_PEM.')
return {**_COMMUNITY_STATUS, 'reason': 'no_public_key'}
try:
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.exceptions import InvalidSignature
raw = key_string[len('TDESK-'):]
if '.' not in raw:
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
payload_b64, sig_b64 = raw.split('.', 1)
payload_bytes = base64.urlsafe_b64decode(_pad_b64(payload_b64))
signature = base64.urlsafe_b64decode(_pad_b64(sig_b64))
public_key = serialization.load_pem_public_key(PUBLIC_KEY_PEM.strip().encode())
public_key.verify(signature, payload_bytes, padding.PKCS1v15(), hashes.SHA256())
payload = json.loads(payload_bytes.decode('utf-8'))
expires_at = payload.get('expires_at', '')
expires = date.fromisoformat(expires_at)
today = date.today()
if expires < today:
return {
**_COMMUNITY_STATUS,
'expires_at': expires_at,
'customer' : payload.get('customer'),
'email' : payload.get('email'),
'issued_at' : payload.get('issued_at'),
'reason' : 'expired',
}
days_left = (expires - today).days
tier = payload.get('tier', 'community')
if tier not in TIER_FEATURES:
tier = 'community'
logger.info(
f'[LICENSE] Valid {tier} license for {payload.get("customer")} '
f'(expires {expires_at}, {days_left} days left)'
)
return {
'valid' : True,
'tier' : tier,
'customer' : payload.get('customer'),
'email' : payload.get('email'),
'issued_at' : payload.get('issued_at'),
'expires_at': expires_at,
'days_left' : days_left,
}
except InvalidSignature:
logger.warning('[LICENSE] Key signature verification failed — key may be tampered.')
return {**_COMMUNITY_STATUS, 'reason': 'invalid_signature'}
except Exception as exc:
logger.warning(f'[LICENSE] Key validation error: {exc}')
return {**_COMMUNITY_STATUS, 'reason': 'invalid_format'}
def get_status() -> dict:
"""Return the cached license status stored in Flask app.config.
Must be called within an active Flask application context.
Falls back to community status if not set (e.g. during testing).
"""
from flask import current_app
return current_app.config.get('LICENSE_STATUS', {**_COMMUNITY_STATUS, 'reason': 'no_key'})
def feature_enabled(feature_name: str) -> bool:
"""Return True if the current license tier grants access to feature_name.
Must be called within an active Flask application context.
"""
status = get_status()
tier = status.get('tier', 'community')
return feature_name in TIER_FEATURES.get(tier, set())
def days_until_expiry() -> 'int | None':
"""Return days remaining on the current license, or None if invalid/community."""
status = get_status()
if not status.get('valid'):
return None
return status.get('days_left')
def _pad_b64(s: str) -> str:
"""Add base64url padding so Python's decoder accepts it."""
return s + '=' * (-len(s) % 4)
+4
View File
@@ -587,6 +587,10 @@ def send_weekly_digest(app):
from app.models import Ticket, TicketStatus, TicketPriority
with app.app_context():
from app.services.license_service import feature_enabled
if not feature_enabled('digest'):
logger.debug('[DIGEST] Skipped — digest feature not enabled on current license tier.')
return
try:
now = datetime.utcnow()
week_ago = now - timedelta(days=7)
+4
View File
@@ -143,6 +143,10 @@ def check_sla_breaches(app):
is re-opened, ensuring fresh notifications if the issue resurfaces.
"""
with app.app_context():
from app.services.license_service import feature_enabled
if not feature_enabled('sla'):
logger.debug('[SLA] Skipped — SLA feature not enabled on current license tier.')
return
try:
_run_sla_check(app)
except Exception as exc:
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}License{% endblock %}
{% block page_title %}License{% endblock %}
{% block content %}
<div class="row g-4">
{# ── Status card ─────────────────────────────────────────────────────────── #}
<div class="col-12 col-lg-5">
<div class="card h-100">
<div class="card-header"><i class="bi bi-key me-2"></i>License Status</div>
<div class="card-body">
{# Tier badge #}
{% if status.tier == 'enterprise' %}
<span class="badge bg-purple fs-6 mb-3" style="background:#6d28d9!important;">
<i class="bi bi-award me-1"></i>Enterprise
</span>
{% elif status.tier == 'business' %}
<span class="badge bg-primary fs-6 mb-3">
<i class="bi bi-briefcase me-1"></i>Business
</span>
{% else %}
<span class="badge bg-secondary fs-6 mb-3">
<i class="bi bi-person me-1"></i>Community
</span>
{% endif %}
{% if status.valid %}
<p class="mb-1"><i class="bi bi-check-circle-fill text-success me-2"></i>
<strong>License is active</strong></p>
{% if status.customer %}
<p class="mb-1 text-muted small">
<i class="bi bi-building me-1"></i>{{ status.customer }}
</p>
{% endif %}
{% if status.email %}
<p class="mb-1 text-muted small">
<i class="bi bi-envelope me-1"></i>{{ status.email }}
</p>
{% endif %}
{% if status.issued_at %}
<p class="mb-1 text-muted small">
<i class="bi bi-calendar-check me-1"></i>Issued: {{ status.issued_at }}
</p>
{% endif %}
{% if status.expires_at %}
<p class="mb-1 text-muted small">
<i class="bi bi-calendar-x me-1"></i>Expires: {{ status.expires_at }}
{% if days_left is not none %}
{% if days_left < 30 %}
<span class="badge bg-warning text-dark ms-1">{{ days_left }}d left</span>
{% else %}
<span class="badge bg-success ms-1">{{ days_left }}d left</span>
{% endif %}
{% endif %}
</p>
{% endif %}
{% else %}
<p class="mb-2">
<i class="bi bi-x-circle-fill text-danger me-2"></i>
<strong>No active license</strong>
</p>
<p class="text-muted small mb-0">
{% if status.get('reason') == 'expired' %}
Your license expired on <strong>{{ status.expires_at }}</strong>.
Contact your vendor to renew.
{% elif status.get('reason') == 'no_key' %}
No license key is configured.
Add <code>LICENSE_KEY=TDESK-...</code> to your <code>.env</code> file and restart.
{% elif status.get('reason') == 'no_public_key' %}
The application's public key has not been configured yet.
Contact your system administrator.
{% else %}
The license key is invalid or has been tampered with.
Contact your vendor for a replacement key.
{% endif %}
</p>
{% endif %}
</div>
</div>
</div>
{# ── Feature checklist ───────────────────────────────────────────────────── #}
<div class="col-12 col-lg-7">
<div class="card h-100">
<div class="card-header"><i class="bi bi-grid-3x3-gap me-2"></i>Feature Access</div>
<div class="card-body">
<table class="table table-sm mb-0">
<thead>
<tr>
<th>Feature</th>
<th class="text-center">Community</th>
<th class="text-center">Business</th>
<th class="text-center">Enterprise</th>
</tr>
</thead>
<tbody>
{% set rows = [
('Core ticketing, comments, attachments', True, True, True),
('Knowledge base', True, True, True),
('User management, bulk actions, CSV export', True, True, True),
('AI Chatbot (Groq)', False, True, True),
('SLA tracking &amp; breach notifications', False, True, True),
('Weekly IT digest email', False, True, True),
('Ticket watchers', False, True, True),
('Time tracking', False, True, True),
('Satisfaction surveys', False, True, True),
('Email ingestion (IMAP)', False, False, True),
] %}
{% for label, com, biz, ent in rows %}
<tr>
<td style="font-size:.9rem;">{{ label | safe }}</td>
{% for flag, tier_name in [(com, 'community'), (biz, 'business'), (ent, 'enterprise')] %}
<td class="text-center">
{% set is_current = (status.tier == tier_name) %}
{% if flag %}
<i class="bi bi-check-circle-fill {% if is_current %}text-success{% else %}text-muted{% endif %}"></i>
{% else %}
<i class="bi bi-dash-circle text-muted opacity-25"></i>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{# ── Setup instructions ──────────────────────────────────────────────────── #}
<div class="col-12">
<div class="card">
<div class="card-header"><i class="bi bi-info-circle me-2"></i>How to Apply a License Key</div>
<div class="card-body">
<ol class="mb-0" style="font-size:.9rem;line-height:2;">
<li>Obtain a license key from your vendor (format: <code>TDESK-...</code>).</li>
<li>Open the <code>.env</code> file in your TechDesk installation directory.</li>
<li>Add or update the line: <code>LICENSE_KEY=TDESK-your-key-here</code></li>
<li>Restart the TechDesk service: <code>sudo systemctl restart gunicorn</code></li>
<li>Return to this page to confirm the license is active.</li>
</ol>
</div>
</div>
</div>
</div>
{% endblock %}
+33 -1
View File
@@ -1162,6 +1162,10 @@
<li><a href="{{ url_for('admin.settings') }}" class="{{ 'active' if request.endpoint == 'admin.settings' }}">
<i class="bi bi-gear"></i> Settings
</a></li>
<li><a href="{{ url_for('admin.license_page') }}" class="{{ 'active' if request.endpoint == 'admin.license_page' }}">
<i class="bi bi-key"></i> License
{% if not license.valid %}<span class="badge bg-warning text-dark ms-1" style="font-size:.65rem;">!</span>{% endif %}
</a></li>
{% endif %}
</ul>
{% endif %}
@@ -1232,6 +1236,32 @@
</div>
{% endfor %}
{% endwith %}
{% if current_user.is_authenticated and current_user.is_admin %}
{% if not license.valid %}
<div class="alert alert-warning alert-dismissible mb-3" role="alert">
<i class="bi bi-key me-2"></i>
{% if license.get('reason') == 'expired' %}
Your TechDesk license expired on <strong>{{ license.expires_at }}</strong>.
Features are restricted to Community tier.
{% elif license.get('reason') == 'no_key' %}
No license key configured. TechDesk is running in <strong>Community mode</strong>.
{% else %}
License key is invalid. TechDesk is running in <strong>Community mode</strong>.
{% endif %}
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license details →</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% elif license.days_left is not none and license.days_left < 30 %}
<div class="alert alert-info alert-dismissible mb-3" role="alert">
<i class="bi bi-key me-2"></i>
Your TechDesk license expires in <strong>{{ license.days_left }} day{{ 's' if license.days_left != 1 }}</strong>.
<a href="{{ url_for('admin.license_page') }}" class="alert-link ms-1">View license →</a>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
{% endif %}
{% block content %}{% endblock %}
</div>
</div>
@@ -1251,7 +1281,8 @@
</div>
</div>
<!-- ── Chat FAB ── -->
<!-- ── Chat FAB (Business+ license required) ── -->
{% if license.tier in ('business', 'enterprise') %}
<div id="chat-fab" onclick="toggleChat()" title="IT Assistant">
<i class="bi bi-robot"></i>
</div>
@@ -1276,6 +1307,7 @@
<button class="chat-send" onclick="sendChat()"><i class="bi bi-send-fill"></i></button>
</div>
</div>
{% endif %}
{% else %}
<!-- Not authenticated — minimal layout -->
+5 -3
View File
@@ -828,7 +828,8 @@ function buildCommentEl(c) {
</div>
</div>
<!-- Watch / Unwatch -->
<!-- Watch / Unwatch (Business+ license required) -->
{% if license.tier in ('business', 'enterprise') %}
<div class="card mb-3">
<div class="card-body py-2 px-3 d-flex align-items-center justify-content-between">
<span style="font-size:13px;color:var(--muted);">
@@ -843,9 +844,10 @@ function buildCommentEl(c) {
</button>
</div>
</div>
{% endif %}
<!-- Time Tracking (IT staff only) -->
{% if current_user.is_it_staff %}
<!-- Time Tracking (IT staff only, Business+ license required) -->
{% if current_user.is_it_staff and license.tier in ('business', 'enterprise') %}
<div class="card mb-3">
<div class="card-header d-flex align-items-center justify-content-between">
<span><i class="bi bi-clock me-2"></i>Time Logged</span>