From 1390e0750056f9df887a095c2282a2ea7631ddde Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:37:28 +0700 Subject: [PATCH 1/6] Login and Session Fixes --- __pycache__/server.cpython-312.pyc | Bin 266175 -> 269694 bytes server.py | 24 ++++++++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index dd5afa9df8545b2f085dbbfdd41cde4aa6b8b3be..250dae28f737027b76580c6f11eb2c943b7ec3a2 100644 GIT binary patch delta 34586 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 D#kUb^ delta 32333 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 Date: Wed, 7 Jan 2026 14:03:38 +0700 Subject: [PATCH 2/6] Security Hardening --- server.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/server.py b/server.py index 19c14f8..506f7e9 100644 --- a/server.py +++ b/server.py @@ -60,11 +60,29 @@ async def lifespan(app: FastAPI): # Shutdown logger.info("Application shutdown") +# Environment detection +ENVIRONMENT = os.environ.get('ENVIRONMENT', 'development') +IS_PRODUCTION = ENVIRONMENT == 'production' + +# Security: Disable API documentation in production +if IS_PRODUCTION: + print("🔒 Production mode: API documentation disabled") + app_config = { + "lifespan": lifespan, + "root_path": "/membership", + "docs_url": None, # Disable /docs + "redoc_url": None, # Disable /redoc + "openapi_url": None # Disable /openapi.json + } +else: + print("🔓 Development mode: API documentation enabled at /docs and /redoc") + app_config = { + "lifespan": lifespan, + "root_path": "/membership" + } + # Create the main app -app = FastAPI( - lifespan=lifespan, - root_path="/membership" # Configure for serving under /membership path -) +app = FastAPI(**app_config) # Create a router with the /api prefix api_router = APIRouter(prefix="/api") @@ -6262,4 +6280,42 @@ app.add_middleware( allow_headers=["*"], expose_headers=["*"], max_age=600, # Cache preflight requests for 10 minutes -) \ No newline at end of file +) + +# Security Headers Middleware +@app.middleware("http") +async def add_security_headers(request: Request, call_next): + response = await call_next(request) + + # Security headers to protect against common vulnerabilities + security_headers = { + # Prevent clickjacking attacks + "X-Frame-Options": "DENY", + + # Prevent MIME type sniffing + "X-Content-Type-Options": "nosniff", + + # Enable XSS protection in older browsers + "X-XSS-Protection": "1; mode=block", + + # Control referrer information + "Referrer-Policy": "strict-origin-when-cross-origin", + + # Permissions policy (formerly Feature-Policy) + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + # Add HSTS header in production (force HTTPS) + if IS_PRODUCTION: + security_headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + # Apply all security headers + for header, value in security_headers.items(): + response.headers[header] = value + + # Remove server identification headers + response.headers.pop("Server", None) + + return response + +print(f"✓ Security headers configured (Production: {IS_PRODUCTION})") \ No newline at end of file From a74f161efafcc5926532b711310f42f0c3dd8ac9 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:15:50 +0700 Subject: [PATCH 3/6] Security Hardening #1 --- server.py | 113 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/server.py b/server.py index 506f7e9..561d540 100644 --- a/server.py +++ b/server.py @@ -808,6 +808,53 @@ async def get_config(): "max_file_size_mb": int(max_file_size_mb) } +@api_router.get("/diagnostics/cors") +async def cors_diagnostics(request: Request): + """ + CORS Diagnostics Endpoint + Shows current CORS configuration and request details for debugging + + Use this to verify: + 1. What origins are allowed + 2. What origin is making the request + 3. Whether CORS is properly configured + """ + cors_origins_env = os.environ.get('CORS_ORIGINS', '') + + if cors_origins_env: + configured_origins = [origin.strip() for origin in cors_origins_env.split(',')] + cors_status = "✅ CONFIGURED" + else: + configured_origins = [ + "http://localhost:3000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000" + ] + cors_status = "⚠️ NOT CONFIGURED (using defaults)" + + request_origin = request.headers.get('origin', 'None') + origin_allowed = request_origin in configured_origins + + return { + "cors_status": cors_status, + "environment": ENVIRONMENT, + "cors_origins_env_variable": cors_origins_env or "(not set)", + "allowed_origins": configured_origins, + "request_origin": request_origin, + "origin_allowed": origin_allowed, + "diagnosis": { + "cors_configured": bool(cors_origins_env), + "origin_matches": origin_allowed, + "issue": None if origin_allowed else f"Origin '{request_origin}' is not in allowed origins list" + }, + "fix_instructions": None if origin_allowed else ( + f"Add to backend .env file:\n" + f"CORS_ORIGINS={request_origin}" + f"{(',' + ','.join(configured_origins)) if cors_origins_env else ''}" + ) + } + # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): @@ -6254,35 +6301,15 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): # Include the router in the main app app.include_router(api_router) -# CORS Configuration -cors_origins = os.environ.get('CORS_ORIGINS', '') -if cors_origins: - # Use explicitly configured origins - allowed_origins = [origin.strip() for origin in cors_origins.split(',')] -else: - # Default to common development origins if not configured - allowed_origins = [ - "http://localhost:3000", - "http://localhost:8000", - "http://127.0.0.1:3000", - "http://127.0.0.1:8000" - ] - print(f"⚠️ WARNING: CORS_ORIGINS not set. Using defaults: {allowed_origins}") - print("⚠️ For production, set CORS_ORIGINS in .env file!") +# ============================================================================ +# MIDDLEWARE CONFIGURATION +# ============================================================================ +# IMPORTANT: In FastAPI, middleware is executed in REVERSE order of addition +# Last added = First executed +# So we add them in this order: Security Headers -> CORS +# Execution order will be: CORS -> Security Headers -print(f"✓ CORS allowed origins: {allowed_origins}") - -app.add_middleware( - CORSMiddleware, - allow_credentials=True, - allow_origins=allowed_origins, - allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], - allow_headers=["*"], - expose_headers=["*"], - max_age=600, # Cache preflight requests for 10 minutes -) - -# Security Headers Middleware +# Security Headers Middleware (Added first, executes second) @app.middleware("http") async def add_security_headers(request: Request, call_next): response = await call_next(request) @@ -6318,4 +6345,32 @@ async def add_security_headers(request: Request, call_next): return response -print(f"✓ Security headers configured (Production: {IS_PRODUCTION})") \ No newline at end of file +print(f"✓ Security headers configured (Production: {IS_PRODUCTION})") + +# CORS Configuration (Added second, executes first) +cors_origins = os.environ.get('CORS_ORIGINS', '') +if cors_origins: + # Use explicitly configured origins + allowed_origins = [origin.strip() for origin in cors_origins.split(',')] +else: + # Default to common development origins if not configured + allowed_origins = [ + "http://localhost:3000", + "http://localhost:8000", + "http://127.0.0.1:3000", + "http://127.0.0.1:8000" + ] + print(f"⚠️ WARNING: CORS_ORIGINS not set. Using defaults: {allowed_origins}") + print("⚠️ For production, set CORS_ORIGINS in .env file!") + +print(f"✓ CORS allowed origins: {allowed_origins}") + +app.add_middleware( + CORSMiddleware, + allow_credentials=True, + allow_origins=allowed_origins, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"], + allow_headers=["*"], + expose_headers=["*"], + max_age=600, # Cache preflight requests for 10 minutes +) \ No newline at end of file From adbfa7a3c8966355c7a8e28c50c593ab7ba92dac Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Wed, 7 Jan 2026 14:21:47 +0700 Subject: [PATCH 4/6] - Fixed MutableHeaders bug- Disable API docs in production- CORS diagnostic endpoint- Security headers + CORS middlewareMust have ENVIRONMENT=production and CORS_ORIGINS=... in .env file --- server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 561d540..298f7fc 100644 --- a/server.py +++ b/server.py @@ -6340,8 +6340,9 @@ async def add_security_headers(request: Request, call_next): for header, value in security_headers.items(): response.headers[header] = value - # Remove server identification headers - response.headers.pop("Server", None) + # Remove server identification headers (use del, not pop for MutableHeaders) + if "Server" in response.headers: + del response.headers["Server"] return response From 39324ba6f642164a478601cd86b4ecc4f8fe789b Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Wed, 7 Jan 2026 16:23:01 +0700 Subject: [PATCH 5/6] Database prevent dead connection errors and make login work on the first try --- database.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/database.py b/database.py index 9a048a2..11f461b 100644 --- a/database.py +++ b/database.py @@ -1,6 +1,7 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import QueuePool import os from dotenv import load_dotenv from pathlib import Path @@ -10,7 +11,21 @@ load_dotenv(ROOT_DIR / '.env') DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://user:password@localhost:5432/membership_db') -engine = create_engine(DATABASE_URL) +# Configure engine with connection pooling and connection health checks +engine = create_engine( + DATABASE_URL, + poolclass=QueuePool, + pool_size=5, # Keep 5 connections open + max_overflow=10, # Allow up to 10 extra connections during peak + pool_pre_ping=True, # CRITICAL: Test connections before using them + pool_recycle=3600, # Recycle connections every hour (prevents stale connections) + echo=False, # Set to True for SQL debugging + connect_args={ + 'connect_timeout': 10, # Timeout connection attempts after 10 seconds + 'options': '-c statement_timeout=30000' # 30 second query timeout + } +) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) Base = declarative_base() From e938baa78ecbcf4ff3822295125a4697d89fec85 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:07:58 +0700 Subject: [PATCH 6/6] - Add Settings menu for Stripe configuration- In the Member Profile page, Superadmin can assign new Role to the member- Stripe Configuration is now stored with encryption in Database --- .env.example | 13 +- .../4fa11836f7fd_add_role_audit_fields.py | 48 +++ .../ec4cb4a49cde_add_system_settings_table.py | 68 ++++ encryption_service.py | 122 ++++++ models.py | 38 ++ payment_service.py | 64 ++- server.py | 375 +++++++++++++++++- 7 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 alembic/versions/4fa11836f7fd_add_role_audit_fields.py create mode 100644 alembic/versions/ec4cb4a49cde_add_system_settings_table.py create mode 100644 encryption_service.py diff --git a/.env.example b/.env.example index a20ff27..f2f1d6d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ JWT_SECRET=your-secret-key-change-this-in-production JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 +# Settings Encryption (for database-stored sensitive settings) +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))" +SETTINGS_ENCRYPTION_KEY=your-encryption-key-generate-with-command-above + # SMTP Email Configuration (Port 465 - SSL/TLS) SMTP_HOST=p.konceptkit.com SMTP_PORT=465 @@ -28,7 +32,14 @@ SMTP_FROM_NAME=LOAF Membership # Frontend URL FRONTEND_URL=http://localhost:3000 -# Stripe Configuration (for future payment integration) +# Backend URL (for webhook URLs and API references) +# Used to construct Stripe webhook URL shown in Admin Settings +BACKEND_URL=http://localhost:8000 + +# Stripe Configuration (NOW DATABASE-DRIVEN via Admin Settings page) +# Configure Stripe credentials through the Admin Settings UI (requires SETTINGS_ENCRYPTION_KEY) +# No longer requires .env variables - managed through database for dynamic updates +# Legacy .env variables below are deprecated: # STRIPE_SECRET_KEY=sk_test_... # STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/alembic/versions/4fa11836f7fd_add_role_audit_fields.py b/alembic/versions/4fa11836f7fd_add_role_audit_fields.py new file mode 100644 index 0000000..ccda8d9 --- /dev/null +++ b/alembic/versions/4fa11836f7fd_add_role_audit_fields.py @@ -0,0 +1,48 @@ +"""add_role_audit_fields + +Revision ID: 4fa11836f7fd +Revises: 013_sync_permissions +Create Date: 2026-01-16 17:21:40.514605 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = '4fa11836f7fd' +down_revision: Union[str, None] = '013_sync_permissions' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add role audit trail columns + op.add_column('users', sa.Column('role_changed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('users', sa.Column('role_changed_by', UUID(as_uuid=True), nullable=True)) + + # Create foreign key constraint to track who changed the role + op.create_foreign_key( + 'fk_users_role_changed_by', + 'users', 'users', + ['role_changed_by'], ['id'], + ondelete='SET NULL' + ) + + # Create index for efficient querying by role change date + op.create_index('idx_users_role_changed_at', 'users', ['role_changed_at']) + + +def downgrade() -> None: + # Drop index first + op.drop_index('idx_users_role_changed_at') + + # Drop foreign key constraint + op.drop_constraint('fk_users_role_changed_by', 'users', type_='foreignkey') + + # Drop columns + op.drop_column('users', 'role_changed_by') + op.drop_column('users', 'role_changed_at') diff --git a/alembic/versions/ec4cb4a49cde_add_system_settings_table.py b/alembic/versions/ec4cb4a49cde_add_system_settings_table.py new file mode 100644 index 0000000..d403c1c --- /dev/null +++ b/alembic/versions/ec4cb4a49cde_add_system_settings_table.py @@ -0,0 +1,68 @@ +"""add_system_settings_table + +Revision ID: ec4cb4a49cde +Revises: 4fa11836f7fd +Create Date: 2026-01-16 18:16:00.283455 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = 'ec4cb4a49cde' +down_revision: Union[str, None] = '4fa11836f7fd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create enum for setting types (only if not exists) + op.execute(""" + DO $$ BEGIN + CREATE TYPE settingtype AS ENUM ('plaintext', 'encrypted', 'json'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + + # Create system_settings table + op.execute(""" + CREATE TABLE system_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + setting_key VARCHAR(100) UNIQUE NOT NULL, + setting_value TEXT, + setting_type settingtype NOT NULL DEFAULT 'plaintext'::settingtype, + description TEXT, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_sensitive BOOLEAN NOT NULL DEFAULT FALSE + ); + + COMMENT ON COLUMN system_settings.setting_key IS 'Unique setting identifier (e.g., stripe_secret_key)'; + COMMENT ON COLUMN system_settings.setting_value IS 'Setting value (encrypted if setting_type is encrypted)'; + COMMENT ON COLUMN system_settings.setting_type IS 'Type of setting: plaintext, encrypted, or json'; + COMMENT ON COLUMN system_settings.description IS 'Human-readable description of the setting'; + COMMENT ON COLUMN system_settings.updated_by IS 'User who last updated this setting'; + COMMENT ON COLUMN system_settings.is_sensitive IS 'Whether this setting contains sensitive data'; + """) + + # Create indexes + op.create_index('idx_system_settings_key', 'system_settings', ['setting_key']) + op.create_index('idx_system_settings_updated_at', 'system_settings', ['updated_at']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('idx_system_settings_updated_at') + op.drop_index('idx_system_settings_key') + + # Drop table + op.drop_table('system_settings') + + # Drop enum + op.execute('DROP TYPE IF EXISTS settingtype') diff --git a/encryption_service.py b/encryption_service.py new file mode 100644 index 0000000..f12ee46 --- /dev/null +++ b/encryption_service.py @@ -0,0 +1,122 @@ +""" +Encryption service for sensitive settings stored in database. + +Uses Fernet symmetric encryption (AES-128 in CBC mode with HMAC authentication). +The encryption key is derived from a master secret stored in .env. +""" + +import os +import base64 +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend + + +class EncryptionService: + """Service for encrypting and decrypting sensitive configuration values""" + + def __init__(self): + # Get master encryption key from environment + # This should be a long, random string (e.g., 64 characters) + # Generate one with: python -c "import secrets; print(secrets.token_urlsafe(64))" + self.master_secret = os.environ.get('SETTINGS_ENCRYPTION_KEY') + + if not self.master_secret: + raise ValueError( + "SETTINGS_ENCRYPTION_KEY environment variable not set. " + "Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(64))\"" + ) + + # Derive encryption key from master secret using PBKDF2HMAC + # This adds an extra layer of security + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'systemsettings', # Fixed salt (OK for key derivation from strong secret) + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(self.master_secret.encode())) + self.cipher = Fernet(key) + + def encrypt(self, plaintext: str) -> str: + """ + Encrypt a plaintext string. + + Args: + plaintext: The string to encrypt + + Returns: + Base64-encoded encrypted string + """ + if not plaintext: + return "" + + encrypted_bytes = self.cipher.encrypt(plaintext.encode()) + return encrypted_bytes.decode('utf-8') + + def decrypt(self, encrypted: str) -> str: + """ + Decrypt an encrypted string. + + Args: + encrypted: The base64-encoded encrypted string + + Returns: + Decrypted plaintext string + + Raises: + cryptography.fernet.InvalidToken: If decryption fails (wrong key or corrupted data) + """ + if not encrypted: + return "" + + decrypted_bytes = self.cipher.decrypt(encrypted.encode()) + return decrypted_bytes.decode('utf-8') + + def is_encrypted(self, value: str) -> bool: + """ + Check if a value appears to be encrypted (starts with Fernet token format). + + This is a heuristic check - not 100% reliable but useful for validation. + + Args: + value: String to check + + Returns: + True if value looks like a Fernet token + """ + if not value: + return False + + # Fernet tokens are base64-encoded and start with version byte (gAAAAA...) + # They're always > 60 characters + try: + return len(value) > 60 and value.startswith('gAAAAA') + except: + return False + + +# Global encryption service instance +# Initialize on module import so it fails fast if encryption key is missing +try: + encryption_service = EncryptionService() +except ValueError as e: + print(f"WARNING: {e}") + print("Encryption service will not be available.") + encryption_service = None + + +def get_encryption_service() -> EncryptionService: + """ + Get the global encryption service instance. + + Raises: + ValueError: If encryption service is not initialized (missing SETTINGS_ENCRYPTION_KEY) + """ + if encryption_service is None: + raise ValueError( + "Encryption service not initialized. Set SETTINGS_ENCRYPTION_KEY environment variable." + ) + return encryption_service diff --git a/models.py b/models.py index a8d8d30..c0b50e0 100644 --- a/models.py +++ b/models.py @@ -137,6 +137,10 @@ class User(Base): wordpress_user_id = Column(BigInteger, nullable=True, comment="Original WordPress user ID") wordpress_registered_date = Column(DateTime(timezone=True), nullable=True, comment="Original WordPress registration date") + # Role Change Audit Trail + role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed") + role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role") + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) @@ -145,6 +149,7 @@ class User(Base): events_created = relationship("Event", back_populates="creator") rsvps = relationship("EventRSVP", back_populates="user") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") + role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True) class Event(Base): __tablename__ = "events" @@ -509,3 +514,36 @@ class ImportRollbackAudit(Base): # Relationships import_job = relationship("ImportJob") admin_user = relationship("User", foreign_keys=[rolled_back_by]) + + +# ============================================================ +# System Settings Models +# ============================================================ + +class SettingType(enum.Enum): + plaintext = "plaintext" + encrypted = "encrypted" + json = "json" + + +class SystemSettings(Base): + """System-wide configuration settings stored in database""" + __tablename__ = "system_settings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + setting_key = Column(String(100), unique=True, nullable=False, index=True) + setting_value = Column(Text, nullable=True) + setting_type = Column(SQLEnum(SettingType), default=SettingType.plaintext, nullable=False) + description = Column(Text, nullable=True) + updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + is_sensitive = Column(Boolean, default=False, nullable=False) + + # Relationships + updater = relationship("User", foreign_keys=[updated_by]) + + # Index on updated_at for audit queries + __table_args__ = ( + Index('idx_system_settings_updated_at', 'updated_at'), + ) diff --git a/payment_service.py b/payment_service.py index 562ddd2..ea95351 100644 --- a/payment_service.py +++ b/payment_service.py @@ -11,11 +11,9 @@ from datetime import datetime, timezone, timedelta # Load environment variables load_dotenv() -# Initialize Stripe with secret key -stripe.api_key = os.getenv("STRIPE_SECRET_KEY") - -# Stripe webhook secret for signature verification -STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") +# NOTE: Stripe credentials are now database-driven +# These .env fallbacks are kept for backward compatibility only +# The actual credentials are loaded dynamically from system_settings table def create_checkout_session( user_id: str, @@ -23,11 +21,15 @@ def create_checkout_session( plan_id: str, stripe_price_id: str, success_url: str, - cancel_url: str + cancel_url: str, + db = None ): """ Create a Stripe Checkout session for subscription payment. + Args: + db: Database session (optional, for reading Stripe credentials from database) + Args: user_id: User's UUID user_email: User's email address @@ -39,6 +41,28 @@ def create_checkout_session( Returns: dict: Checkout session object with session ID and URL """ + # Load Stripe API key from database if available + if db: + try: + # Import here to avoid circular dependency + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'stripe_secret_key' + ).first() + + if setting and setting.setting_value: + encryption_service = get_encryption_service() + stripe.api_key = encryption_service.decrypt(setting.setting_value) + except Exception as e: + # Fallback to .env if database read fails + print(f"Failed to read Stripe key from database: {e}") + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + else: + # Fallback to .env if no db session + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + try: # Create Checkout Session checkout_session = stripe.checkout.Session.create( @@ -74,13 +98,14 @@ def create_checkout_session( raise Exception(f"Stripe error: {str(e)}") -def verify_webhook_signature(payload: bytes, sig_header: str) -> dict: +def verify_webhook_signature(payload: bytes, sig_header: str, db=None) -> dict: """ Verify Stripe webhook signature and construct event. Args: payload: Raw webhook payload bytes sig_header: Stripe signature header + db: Database session (optional, for reading webhook secret from database) Returns: dict: Verified webhook event @@ -88,9 +113,32 @@ def verify_webhook_signature(payload: bytes, sig_header: str) -> dict: Raises: ValueError: If signature verification fails """ + # Load webhook secret from database if available + webhook_secret = None + if db: + try: + from models import SystemSettings + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'stripe_webhook_secret' + ).first() + + if setting and setting.setting_value: + encryption_service = get_encryption_service() + webhook_secret = encryption_service.decrypt(setting.setting_value) + except Exception as e: + print(f"Failed to read webhook secret from database: {e}") + webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + else: + webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + + if not webhook_secret: + raise ValueError("STRIPE_WEBHOOK_SECRET not configured") + try: event = stripe.Webhook.construct_event( - payload, sig_header, STRIPE_WEBHOOK_SECRET + payload, sig_header, webhook_secret ) return event except ValueError as e: diff --git a/server.py b/server.py index 298f7fc..cbbbc48 100644 --- a/server.py +++ b/server.py @@ -514,6 +514,10 @@ class AcceptInvitationRequest(BaseModel): zipcode: Optional[str] = None date_of_birth: Optional[datetime] = None +class ChangeRoleRequest(BaseModel): + role: str + role_id: Optional[str] = None # For custom roles + # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): @@ -2527,6 +2531,102 @@ async def admin_reset_user_password( return {"message": f"Password reset for {user.email}. Temporary password emailed."} +@api_router.put("/admin/users/{user_id}/role") +async def change_user_role( + user_id: str, + request: ChangeRoleRequest, + current_user: User = Depends(require_permission("users.edit")), + db: Session = Depends(get_db) +): + """ + Change an existing user's role with privilege escalation prevention. + + Requires: users.edit permission + + Rules: + - Superadmin: Can assign any role (including superadmin) + - Admin: Can assign admin, finance, member, guest, and non-elevated custom roles + - Admin CANNOT assign: superadmin or custom roles with elevated permissions + - Users CANNOT change their own role + """ + + # 1. Fetch target user + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # 2. Prevent self-role-change + if str(target_user.id) == str(current_user.id): + raise HTTPException( + status_code=403, + detail="You cannot change your own role" + ) + + # 3. Validate new role + if request.role not in ['guest', 'member', 'admin', 'finance', 'superadmin']: + raise HTTPException(status_code=400, detail="Invalid role") + + # 4. Privilege escalation check + if current_user.role != 'superadmin': + # Non-superadmin cannot assign superadmin role + if request.role == 'superadmin': + raise HTTPException( + status_code=403, + detail="Only superadmin can assign superadmin role" + ) + + # Check custom role elevation + if request.role_id: + custom_role = db.query(Role).filter(Role.id == request.role_id).first() + if not custom_role: + raise HTTPException(status_code=404, detail="Custom role not found") + + # Check if custom role has elevated permissions + elevated_permissions = ['users.delete', 'roles.create', 'roles.edit', + 'roles.delete', 'permissions.edit'] + role_perms = db.query(Permission.name).join(RolePermission).filter( + RolePermission.role_id == custom_role.id, + Permission.name.in_(elevated_permissions) + ).all() + + if role_perms: + raise HTTPException( + status_code=403, + detail=f"Cannot assign role with elevated permissions: {custom_role.name}" + ) + + # 5. Update role with audit trail + old_role = target_user.role + old_role_id = target_user.role_id + + target_user.role = request.role + target_user.role_id = request.role_id if request.role_id else None + target_user.role_changed_at = datetime.now(timezone.utc) + target_user.role_changed_by = current_user.id + target_user.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(target_user) + + # Log admin action + logger.info( + f"Admin {current_user.email} changed role for user {target_user.email} " + f"from {old_role} to {request.role}" + ) + + return { + "message": f"Role changed from {old_role} to {request.role}", + "user": { + "id": str(target_user.id), + "email": target_user.email, + "name": f"{target_user.first_name} {target_user.last_name}", + "old_role": old_role, + "new_role": target_user.role, + "changed_by": f"{current_user.first_name} {current_user.last_name}", + "changed_at": target_user.role_changed_at.isoformat() + } + } + @api_router.post("/admin/users/{user_id}/resend-verification") async def admin_resend_verification( user_id: str, @@ -6197,8 +6297,8 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail="Missing stripe-signature header") try: - # Verify webhook signature - event = verify_webhook_signature(payload, sig_header) + # Verify webhook signature (pass db for reading webhook secret from database) + event = verify_webhook_signature(payload, sig_header, db) except ValueError as e: logger.error(f"Webhook signature verification failed: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) @@ -6298,6 +6398,277 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): return {"status": "success"} +# ============================================================================ +# ADMIN SETTINGS ENDPOINTS +# ============================================================================ + +# Helper functions for system settings +def get_setting(db: Session, key: str, decrypt: bool = False) -> str | None: + """ + Get a system setting value from database. + + Args: + db: Database session + key: Setting key to retrieve + decrypt: If True and setting_type is 'encrypted', decrypt the value + + Returns: + Setting value or None if not found + """ + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first() + if not setting: + return None + + value = setting.setting_value + if decrypt and setting.setting_type == SettingType.encrypted and value: + try: + encryption_service = get_encryption_service() + value = encryption_service.decrypt(value) + except Exception as e: + print(f"Failed to decrypt setting {key}: {e}") + return None + + return value + + +def set_setting( + db: Session, + key: str, + value: str, + user_id: str, + setting_type: str = "plaintext", + description: str = None, + is_sensitive: bool = False, + encrypt: bool = False +) -> None: + """ + Set a system setting value in database. + + Args: + db: Database session + key: Setting key + value: Setting value + user_id: ID of user making the change + setting_type: Type of setting (plaintext, encrypted, json) + description: Human-readable description + is_sensitive: Whether this is sensitive data + encrypt: If True, encrypt the value before storing + """ + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + # Encrypt value if requested + if encrypt and value: + encryption_service = get_encryption_service() + value = encryption_service.encrypt(value) + setting_type = "encrypted" + + # Find or create setting + setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first() + + if setting: + # Update existing + setting.setting_value = value + setting.setting_type = SettingType[setting_type] + setting.updated_by = user_id + setting.updated_at = datetime.now(timezone.utc) + if description: + setting.description = description + setting.is_sensitive = is_sensitive + else: + # Create new + setting = SystemSettings( + setting_key=key, + setting_value=value, + setting_type=SettingType[setting_type], + description=description, + updated_by=user_id, + is_sensitive=is_sensitive + ) + db.add(setting) + + db.commit() + +@api_router.get("/admin/settings/stripe/status") +async def get_stripe_status( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get Stripe integration status (superadmin only). + + Returns: + - configured: Whether credentials exist in database + - secret_key_prefix: First 10 chars of secret key (for verification) + - webhook_configured: Whether webhook secret exists + - environment: test or live (based on key prefix) + - webhook_url: Full webhook URL for Stripe configuration + """ + import os + + # Read from database + secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) + webhook_secret = get_setting(db, 'stripe_webhook_secret', decrypt=True) + + configured = bool(secret_key) + environment = 'unknown' + + if secret_key: + if secret_key.startswith('sk_test_'): + environment = 'test' + elif secret_key.startswith('sk_live_'): + environment = 'live' + + # Get backend URL from environment for webhook URL + # Try multiple environment variable patterns for flexibility + backend_url = ( + os.environ.get('BACKEND_URL') or + os.environ.get('API_URL') or + f"http://{os.environ.get('HOST', 'localhost')}:{os.environ.get('PORT', '8000')}" + ) + webhook_url = f"{backend_url}/api/webhooks/stripe" + + return { + "configured": configured, + "secret_key_prefix": secret_key[:10] if secret_key else None, + "secret_key_set": bool(secret_key), + "webhook_secret_set": bool(webhook_secret), + "environment": environment, + "webhook_url": webhook_url, + "instructions": { + "location": "Database (system_settings table)", + "required_settings": [ + "stripe_secret_key (sk_test_... or sk_live_...)", + "stripe_webhook_secret (whsec_...)" + ], + "restart_required": "No - changes take effect immediately" + } + } + +@api_router.post("/admin/settings/stripe/test-connection") +async def test_stripe_connection( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Test Stripe API connection (superadmin only). + + Performs a simple API call to verify credentials work. + """ + import stripe + + # Read from database + secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) + + if not secret_key: + raise HTTPException( + status_code=400, + detail="STRIPE_SECRET_KEY not configured in database. Please configure Stripe settings first." + ) + + try: + stripe.api_key = secret_key + + # Make a simple API call to test connection + balance = stripe.Balance.retrieve() + + return { + "success": True, + "message": "Stripe connection successful", + "environment": "test" if secret_key.startswith('sk_test_') else "live", + "balance": { + "available": balance.available, + "pending": balance.pending + } + } + except stripe.error.AuthenticationError as e: + raise HTTPException( + status_code=401, + detail=f"Stripe authentication failed: {str(e)}" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Stripe connection test failed: {str(e)}" + ) + + +class UpdateStripeSettingsRequest(BaseModel): + """Request model for updating Stripe settings""" + secret_key: str = Field(..., min_length=1, description="Stripe secret key (sk_test_... or sk_live_...)") + webhook_secret: str = Field(..., min_length=1, description="Stripe webhook secret (whsec_...)") + + +@api_router.put("/admin/settings/stripe") +async def update_stripe_settings( + request: UpdateStripeSettingsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Update Stripe integration settings (superadmin only). + + Stores Stripe credentials encrypted in the database. + Changes take effect immediately without server restart. + """ + # Validate secret key format + if not (request.secret_key.startswith('sk_test_') or request.secret_key.startswith('sk_live_')): + raise HTTPException( + status_code=400, + detail="Invalid Stripe secret key format. Must start with 'sk_test_' or 'sk_live_'" + ) + + # Validate webhook secret format + if not request.webhook_secret.startswith('whsec_'): + raise HTTPException( + status_code=400, + detail="Invalid Stripe webhook secret format. Must start with 'whsec_'" + ) + + try: + # Store secret key (encrypted) + set_setting( + db=db, + key='stripe_secret_key', + value=request.secret_key, + user_id=str(current_user.id), + description='Stripe API secret key for payment processing', + is_sensitive=True, + encrypt=True + ) + + # Store webhook secret (encrypted) + set_setting( + db=db, + key='stripe_webhook_secret', + value=request.webhook_secret, + user_id=str(current_user.id), + description='Stripe webhook secret for verifying webhook signatures', + is_sensitive=True, + encrypt=True + ) + + # Determine environment + environment = 'test' if request.secret_key.startswith('sk_test_') else 'live' + + return { + "success": True, + "message": "Stripe settings updated successfully", + "environment": environment, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to update Stripe settings: {str(e)}" + ) + + # Include the router in the main app app.include_router(api_router)