Files
mnemosyne/mnemosyne/themis/models.py
Robert Helewka 81426327bf feat(mcp): store MCP tokens as SHA-256 hashes instead of plaintext
Replace plaintext token storage with SHA-256 hashes so leaked database
contents cannot be used to authenticate. Plaintext is generated, shown
once at creation time, and never persisted.

- Add `hash_token()` helper and `MCPTokenManager.create_token()` that
  returns `(instance, plaintext)`.
- Replace `token` field with indexed `token_hash`; look up bearers by
  hashing the incoming value.
- Update dashboard, management command, and admin to surface plaintext
  only at creation. Disable admin "add" since it cannot reveal plaintext.
- Migration drops the old `token` column and adds `token_hash`;
  pre-existing tokens are invalidated and must be reissued.
2026-04-27 09:01:36 -04:00

499 lines
14 KiB
Python

"""
Themis models — user preferences, API key management, and notifications.
"""
import uuid
from zoneinfo import available_timezones
from django.conf import settings
from django.db import models
from django.urls import reverse
# Build sorted timezone choices once at module load
TIMEZONE_CHOICES = [(tz, tz) for tz in sorted(available_timezones())]
# DaisyUI theme choices
DAISYUI_THEMES = [
("light", "Light"),
("dark", "Dark"),
("cupcake", "Cupcake"),
("bumblebee", "Bumblebee"),
("emerald", "Emerald"),
("corporate", "Corporate"),
("synthwave", "Synthwave"),
("retro", "Retro"),
("cyberpunk", "Cyberpunk"),
("valentine", "Valentine"),
("halloween", "Halloween"),
("garden", "Garden"),
("forest", "Forest"),
("aqua", "Aqua"),
("lofi", "Lo-Fi"),
("pastel", "Pastel"),
("fantasy", "Fantasy"),
("wireframe", "Wireframe"),
("black", "Black"),
("luxury", "Luxury"),
("dracula", "Dracula"),
("cmyk", "CMYK"),
("autumn", "Autumn"),
("business", "Business"),
("acid", "Acid"),
("lemonade", "Lemonade"),
("night", "Night"),
("coffee", "Coffee"),
("winter", "Winter"),
("dim", "Dim"),
("nord", "Nord"),
("sunset", "Sunset"),
]
class UserProfile(models.Model):
"""
Extends Django's User model with display preferences and theme settings.
Automatically created via signal when a User is created.
Supports dual timezones for traveling users: a permanent home timezone
and an optional current timezone that takes precedence when set.
"""
# Theme mode choices
THEME_MODE_CHOICES = [
("light", "Light"),
("dark", "Dark"),
("auto", "Auto (System)"),
]
# Date format choices
DATE_FORMAT_CHOICES = [
("YYYY-MM-DD", "2024-12-25"),
("DD/MM/YYYY", "25/12/2024"),
("MM/DD/YYYY", "12/25/2024"),
("DD.MM.YYYY", "25.12.2024"),
("DD-MM-YYYY", "25-12-2024"),
]
# Time format choices
TIME_FORMAT_CHOICES = [
("12-hour", "12-hour (3:30 PM)"),
("24-hour", "24-hour (15:30)"),
]
# Thousand separator choices
SEPARATOR_CHOICES = [
("comma", "Comma (1,000)"),
("period", "Period (1.000)"),
("space", "Space (1 000)"),
("none", "None (1000)"),
]
# Week start choices
WEEK_START_CHOICES = [
("monday", "Monday"),
("sunday", "Sunday"),
("saturday", "Saturday"),
]
# Core relationship
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
)
# Timezone settings
home_timezone = models.CharField(
max_length=50,
default="UTC",
choices=TIMEZONE_CHOICES,
help_text="User's home/permanent timezone",
)
current_timezone = models.CharField(
max_length=50,
blank=True,
default="",
choices=[("", "---------")] + TIMEZONE_CHOICES,
help_text="User's current timezone when traveling (leave blank if at home)",
)
# Display preferences
date_format = models.CharField(
max_length=20,
choices=DATE_FORMAT_CHOICES,
default="YYYY-MM-DD",
help_text="Preferred date display format",
)
time_format = models.CharField(
max_length=10,
choices=TIME_FORMAT_CHOICES,
default="24-hour",
help_text="12-hour or 24-hour time format",
)
thousand_separator = models.CharField(
max_length=10,
choices=SEPARATOR_CHOICES,
default="comma",
help_text="Number formatting preference",
)
week_start = models.CharField(
max_length=10,
choices=WEEK_START_CHOICES,
default="monday",
help_text="First day of the week",
)
# Theme settings (DaisyUI)
theme_mode = models.CharField(
max_length=10,
choices=THEME_MODE_CHOICES,
default="auto",
help_text="Theme mode: light, dark, or auto (follows system)",
)
theme_name = models.CharField(
max_length=30,
choices=DAISYUI_THEMES,
default="corporate",
help_text="DaisyUI theme for light mode",
)
dark_theme_name = models.CharField(
max_length=30,
choices=DAISYUI_THEMES,
default="business",
help_text="DaisyUI theme for dark mode",
)
# Notification preferences
NOTIFICATION_LEVEL_CHOICES = [
("info", "All notifications"),
("warning", "Warnings and errors only"),
("danger", "Errors only"),
]
notifications_enabled = models.BooleanField(
default=True,
help_text="Enable in-app notifications",
)
notifications_min_level = models.CharField(
max_length=10,
choices=NOTIFICATION_LEVEL_CHOICES,
default="info",
help_text="Minimum notification level to display",
)
browser_notifications_enabled = models.BooleanField(
default=False,
help_text="Enable browser desktop notifications (requires permission)",
)
notification_retention_days = models.PositiveIntegerField(
default=30,
help_text="Days to keep read notifications before auto-cleanup",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "User Profile"
verbose_name_plural = "User Profiles"
def __str__(self):
return f"{self.user.username}'s Profile"
def get_absolute_url(self):
return reverse("themis:profile-settings")
@property
def effective_timezone(self):
"""Return current timezone if set, otherwise home timezone."""
return self.current_timezone or self.home_timezone
@property
def is_traveling(self):
"""Check if user has a current timezone different from home."""
return bool(
self.current_timezone and self.current_timezone != self.home_timezone
)
class UserAPIKey(models.Model):
"""
Stores encrypted API keys, MCP credentials, DAV passwords, and other
service credentials for a user. Keys are encrypted at rest using Fernet.
This model does not define what the keys are for — consuming apps
register which key types they need. The instructions field allows
per-key documentation so users know how to obtain and use each key.
"""
KEY_TYPE_CHOICES = [
("api", "API Key"),
("dav", "DAV Credentials"),
("token", "Access Token"),
("secret", "Secret Key"),
("other", "Other"),
]
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="api_keys",
)
# Key identity
service_name = models.CharField(
max_length=100,
help_text="Service this key belongs to (e.g. OpenAI, Anthropic, CalDAV)",
)
key_type = models.CharField(
max_length=30,
choices=KEY_TYPE_CHOICES,
default="api",
help_text="Type of credential",
)
label = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Your nickname for this key (e.g. 'Work account')",
)
# Encrypted key value
encrypted_value = models.TextField(
help_text="Fernet-encrypted credential value",
)
# Documentation
instructions = models.TextField(
blank=True,
default="",
help_text="How to obtain and use this key",
)
help_url = models.URLField(
blank=True,
default="",
help_text="Link to service documentation",
)
# Status
is_active = models.BooleanField(
default=True,
help_text="Whether this key is currently in use",
)
last_used_at = models.DateTimeField(
null=True,
blank=True,
help_text="Last time this key was used",
)
expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="When this key expires (if applicable)",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "API Key"
verbose_name_plural = "API Keys"
def __str__(self):
return f"{self.service_name} ({self.key_type}) — {self.user.username}"
def get_absolute_url(self):
return reverse("themis:key-detail", kwargs={"pk": self.pk})
@property
def masked_value(self):
"""Return masked version of the decrypted key, showing only last 4 chars."""
from themis.encryption import decrypt_value
try:
value = decrypt_value(self.encrypted_value)
if len(value) <= 4:
return "****"
return f"{'*' * (len(value) - 4)}{value[-4:]}"
except Exception:
return "****"
@property
def display_name(self):
"""Return label if set, otherwise service_name."""
return self.label or self.service_name
class UserNotification(models.Model):
"""
In-app notification for a user.
Notifications are created by consuming apps via ``themis.notifications.notify_user()``
and displayed in the notification bell dropdown. Source tracking fields
(``source_app``, ``source_model``, ``source_id``) enable deduplication and
bulk cleanup when the originating object is deleted.
"""
LEVEL_CHOICES = [
("info", "Info"),
("success", "Success"),
("warning", "Warning"),
("danger", "Danger"),
]
# Numeric weights for level comparison
LEVEL_WEIGHTS = {
"info": 0,
"success": 0,
"warning": 1,
"danger": 2,
}
id = models.UUIDField(
primary_key=True,
default=uuid.uuid4,
editable=False,
)
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="notifications",
)
# Content
title = models.CharField(
max_length=200,
help_text="Short notification headline",
)
message = models.TextField(
blank=True,
default="",
help_text="Notification body text",
)
level = models.CharField(
max_length=10,
choices=LEVEL_CHOICES,
default="info",
help_text="Notification severity level",
)
url = models.CharField(
max_length=500,
blank=True,
default="",
help_text="URL to navigate to when notification is clicked",
)
# Source tracking (for dedup and cleanup)
source_app = models.CharField(
max_length=100,
blank=True,
default="",
db_index=True,
help_text="App label that created this notification",
)
source_model = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Model name that triggered this notification",
)
source_id = models.CharField(
max_length=100,
blank=True,
default="",
help_text="Primary key of the source object",
)
# State
is_read = models.BooleanField(
default=False,
db_index=True,
help_text="Whether the user has read this notification",
)
read_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the notification was read",
)
is_dismissed = models.BooleanField(
default=False,
help_text="Whether the user has dismissed this notification",
)
dismissed_at = models.DateTimeField(
null=True,
blank=True,
help_text="When the notification was dismissed",
)
expires_at = models.DateTimeField(
null=True,
blank=True,
help_text="Auto-dismiss after this time (optional)",
)
# Metadata
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Notification"
verbose_name_plural = "Notifications"
ordering = ["-created_at"]
indexes = [
models.Index(
fields=["user", "is_read", "is_dismissed"],
name="themis_notif_user_state",
),
models.Index(
fields=["user", "created_at"],
name="themis_notif_user_created",
),
models.Index(
fields=["source_app", "source_model", "source_id"],
name="themis_notif_source",
),
]
def __str__(self):
return f"[{self.level}] {self.title}{self.user.username}"
def get_absolute_url(self):
return reverse("themis:notification-list")
@property
def level_weight(self):
"""Numeric weight for level comparison."""
return self.LEVEL_WEIGHTS.get(self.level, 0)
@property
def is_expired(self):
"""Check if notification has passed its expiry time."""
if not self.expires_at:
return False
from django.utils import timezone
return timezone.now() >= self.expires_at
@property
def level_css_class(self):
"""Return DaisyUI alert class for this notification level."""
return {
"info": "alert-info",
"success": "alert-success",
"warning": "alert-warning",
"danger": "alert-error",
}.get(self.level, "alert-info")
@property
def level_badge_class(self):
"""Return DaisyUI badge class for this notification level."""
return {
"info": "badge-info",
"success": "badge-success",
"warning": "badge-warning",
"danger": "badge-error",
}.get(self.level, "badge-info")