mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Resolve all 5 deferred items from Epic 5 adversarial code review: - Migration 124: Add CASCADE to subscriptionstatus enum drop (prevent orphaned references) - Stripe rate limiting: In-memory per-user limiter (20 calls/60s) on verify-checkout-session - Subscription request cooldown: 24h cooldown before resubmitting rejected requests - Token reset date: Initialize on first subscription activation - Checkout URL validation: Confirmed HTTPS-only (Stripe always returns HTTPS) Implement Story 5.4 (Usage Tracking & Rate Limit Enforcement): - Page quota pre-check at HTTP upload layer - Extend UserRead schema with token quota fields - Frontend 402 error handling in document upload - Quota indicator in dashboard sidebar Story 5.5 (Admin Seed & Approval Flow): - Seed admin user migration with default credentials warning - Subscription approval/rejection routes with admin guard - 24h rejection cooldown enforcement Story 5.6 (Admin-Only Model Config): - Global model config visible across all search spaces - Per-search-space model configs with user access control - Superuser CRUD for global configs Additional fixes from code review: - PageLimitService: PAST_DUE subscriptions enforce free-tier limits - TokenQuotaService: PAST_DUE subscriptions enforce free-tier limits - Config routes: Fixed user_id.is_(None) filter on mutation endpoints - Stripe webhook: Added guard against silent plan downgrade on unrecognized price_id All changes formatted with Ruff (Python) and Biome (TypeScript). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
207 lines
7.9 KiB
Python
207 lines
7.9 KiB
Python
"""126_seed_admin_user
|
|
|
|
Revision ID: 126
|
|
Revises: 125
|
|
Create Date: 2026-04-15
|
|
|
|
Seeds one admin user on fresh installs (no-op if any user already exists).
|
|
Credentials are overridable via env vars:
|
|
ADMIN_EMAIL (default: admin@surfsense.local)
|
|
ADMIN_PASSWORD (default: Admin@SurfSense1)
|
|
|
|
Admin is created with:
|
|
- is_superuser = TRUE, is_active = TRUE, is_verified = TRUE
|
|
- subscription_status = 'active', plan_id = 'pro_yearly'
|
|
- monthly_token_limit = 1_000_000, pages_limit = 5000
|
|
- A default search space, roles, membership, and prompt defaults
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import uuid
|
|
from collections.abc import Sequence
|
|
|
|
import sqlalchemy as sa
|
|
|
|
from alembic import op
|
|
|
|
revision: str = "126"
|
|
down_revision: str | None = "125"
|
|
branch_labels: str | Sequence[str] | None = None
|
|
depends_on: str | Sequence[str] | None = None
|
|
|
|
|
|
def _hash_password(password: str) -> str:
|
|
"""Hash password using argon2-cffi (installed as a fastapi-users dependency)."""
|
|
from argon2 import PasswordHasher
|
|
|
|
ph = PasswordHasher()
|
|
return ph.hash(password)
|
|
|
|
|
|
def upgrade() -> None:
|
|
conn = op.get_bind()
|
|
|
|
# Only seed when the database is empty
|
|
result = conn.execute(sa.text('SELECT 1 FROM "user" LIMIT 1'))
|
|
if result.fetchone() is not None:
|
|
return # Users already exist — skip seed
|
|
|
|
admin_email = os.environ.get("ADMIN_EMAIL", "admin@surfsense.local")
|
|
admin_password = os.environ.get("ADMIN_PASSWORD", "Admin@SurfSense1")
|
|
if not os.environ.get("ADMIN_PASSWORD"):
|
|
print(
|
|
"\n⚠️ WARNING: ADMIN_PASSWORD env var not set. "
|
|
"Using default password 'Admin@SurfSense1'. "
|
|
"Change this immediately after first login!\n"
|
|
)
|
|
hashed_pw = _hash_password(admin_password)
|
|
admin_id = str(uuid.uuid4())
|
|
|
|
# 1. Insert admin user
|
|
conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO "user" (
|
|
id, email, hashed_password,
|
|
is_active, is_superuser, is_verified,
|
|
subscription_status, plan_id,
|
|
monthly_token_limit, pages_limit, pages_used,
|
|
tokens_used_this_month
|
|
) VALUES (
|
|
:id, :email, :hashed_password,
|
|
TRUE, TRUE, TRUE,
|
|
'active', 'pro_yearly',
|
|
1000000, 5000, 0,
|
|
0
|
|
)
|
|
"""
|
|
),
|
|
{
|
|
"id": admin_id,
|
|
"email": admin_email,
|
|
"hashed_password": hashed_pw,
|
|
},
|
|
)
|
|
|
|
# 2. Insert default search space for admin (only required columns; defaults handle the rest)
|
|
search_space_result = conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO searchspaces (name, description, citations_enabled, user_id, created_at)
|
|
VALUES ('My Search Space', 'Your personal search space', TRUE, :user_id, now())
|
|
RETURNING id
|
|
"""
|
|
),
|
|
{"user_id": admin_id},
|
|
)
|
|
search_space_id = search_space_result.fetchone()[0]
|
|
|
|
# 3. Insert default roles for the search space
|
|
owner_role_result = conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO search_space_roles
|
|
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
|
|
VALUES (
|
|
'Owner', 'Full access to all search space resources and settings',
|
|
ARRAY['*'], FALSE, TRUE, :ss_id, now()
|
|
)
|
|
RETURNING id
|
|
"""
|
|
),
|
|
{"ss_id": search_space_id},
|
|
)
|
|
owner_role_id = owner_role_result.fetchone()[0]
|
|
|
|
conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO search_space_roles
|
|
(name, description, permissions, is_default, is_system_role, search_space_id, created_at)
|
|
VALUES
|
|
(
|
|
'Editor',
|
|
'Can create and update content (no delete, role management, or settings access)',
|
|
ARRAY[
|
|
'documents:create','documents:read','documents:update',
|
|
'chats:create','chats:read','chats:update',
|
|
'comments:create','comments:read',
|
|
'llm_configs:create','llm_configs:read','llm_configs:update',
|
|
'podcasts:create','podcasts:read','podcasts:update',
|
|
'video_presentations:create','video_presentations:read','video_presentations:update',
|
|
'image_generations:create','image_generations:read',
|
|
'vision_configs:create','vision_configs:read',
|
|
'connectors:create','connectors:read','connectors:update',
|
|
'logs:read', 'members:invite'
|
|
],
|
|
TRUE, TRUE, :ss_id, now()
|
|
),
|
|
(
|
|
'Viewer', 'Read-only access to search space resources',
|
|
ARRAY[
|
|
'documents:read','chats:read','comments:read',
|
|
'llm_configs:read','podcasts:read','video_presentations:read',
|
|
'image_generations:read','vision_configs:read','connectors:read','logs:read'
|
|
],
|
|
FALSE, TRUE, :ss_id, now()
|
|
)
|
|
"""
|
|
),
|
|
{"ss_id": search_space_id},
|
|
)
|
|
|
|
# 4. Insert owner membership
|
|
conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO search_space_memberships
|
|
(user_id, search_space_id, role_id, is_owner, joined_at, created_at)
|
|
VALUES (:user_id, :ss_id, :role_id, TRUE, now(), now())
|
|
"""
|
|
),
|
|
{"user_id": admin_id, "ss_id": search_space_id, "role_id": owner_role_id},
|
|
)
|
|
|
|
# 5. Insert default prompts (same as migration 114 but just for admin)
|
|
conn.execute(
|
|
sa.text(
|
|
"""
|
|
INSERT INTO prompts
|
|
(user_id, default_prompt_slug, name, prompt, mode, version, is_public, created_at)
|
|
VALUES
|
|
(:uid, 'fix-grammar', 'Fix grammar',
|
|
'Fix the grammar and spelling in the following text. Return only the corrected text, nothing else.\n\n{selection}',
|
|
'transform'::prompt_mode, 1, false, now()),
|
|
(:uid, 'make-shorter', 'Make shorter',
|
|
'Make the following text more concise while preserving its meaning. Return only the shortened text, nothing else.\n\n{selection}',
|
|
'transform'::prompt_mode, 1, false, now()),
|
|
(:uid, 'translate', 'Translate',
|
|
'Translate the following text to English. If it is already in English, translate it to French. Return only the translation, nothing else.\n\n{selection}',
|
|
'transform'::prompt_mode, 1, false, now()),
|
|
(:uid, 'rewrite', 'Rewrite',
|
|
'Rewrite the following text to improve clarity and readability. Return only the rewritten text, nothing else.\n\n{selection}',
|
|
'transform'::prompt_mode, 1, false, now()),
|
|
(:uid, 'summarize', 'Summarize',
|
|
'Summarize the following text concisely. Return only the summary, nothing else.\n\n{selection}',
|
|
'transform'::prompt_mode, 1, false, now()),
|
|
(:uid, 'explain', 'Explain',
|
|
'Explain the following text in simple terms:\n\n{selection}',
|
|
'explore'::prompt_mode, 1, false, now()),
|
|
(:uid, 'ask-knowledge-base', 'Ask my knowledge base',
|
|
'Search my knowledge base for information related to:\n\n{selection}',
|
|
'explore'::prompt_mode, 1, false, now()),
|
|
(:uid, 'look-up-web', 'Look up on the web',
|
|
'Search the web for information about:\n\n{selection}',
|
|
'explore'::prompt_mode, 1, false, now())
|
|
ON CONFLICT (user_id, default_prompt_slug) DO NOTHING
|
|
"""
|
|
),
|
|
{"uid": admin_id},
|
|
)
|
|
|
|
|
|
def downgrade() -> None:
|
|
# Intentional no-op: never delete users on downgrade
|
|
pass
|