Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg

# Virtual Environment
.env
.venv
env/
venv/
ENV/

# Coverage
.coverage
.coverage.*
.cache
coverage.xml
*.cover
htmlcov/

# IDE
.idea/
.vscode/
*.swp
*.swo
.DS_Store

# Logs
*.log
logs/
log/

# Database
*.db
*.sqlite3

# Local development
.env.local
.env.*.local
*.env

# Docker
.docker/

# Test
.pytest_cache/
.hypothesis/
# --- FEATURE[apple_pay] START ---
.apple_pay_test_credentials/
# --- FEATURE[apple_pay] END ---

# Alembic
versions/__pycache__/
versions/*.pyc

# Distribution
dist/
build/

# Poetry
# poetry.lock

# vs code workspace
*.code-workspace

# Additional common patterns
__pypackages__/
# --- FEATURE[celery] START ---
celerybeat-schedule
celerybeat.pid
# --- FEATURE[celery] END ---
*.sage.py
.ipynb_checkpoints
profile_default/
ipython_config.py
.pdm.toml
.mypy_cache/
.ruff_cache/
.dmypy.json
dmypy.json

# OS specific
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
*.lnk

# Backend specific
media/
static/uploads/
*.pem
node_modules/
.sass-cache/
tmp/
temp/
235 changes: 235 additions & 0 deletions FINDINGS.md

Large diffs are not rendered by default.

66 changes: 55 additions & 11 deletions backend/app/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,25 +1,69 @@
from fastapi import APIRouter, Depends, HTTPException
from typing import Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from decimal import Decimal
from typing import Dict, Any, List, Optional
from app.services.cache import get_revenue_summary
from app.services.reservations import MixedCurrencyError, list_tenant_properties
from app.core.auth import authenticate_request as get_current_user
from app.models.auth import AuthenticatedUser

router = APIRouter()


def _require_tenant(current_user: AuthenticatedUser) -> str:
"""Return the user's tenant, or refuse when there is none."""
# A user without a tenant must not fall into a shared bucket that other
# tenant-less users could read; refuse instead.
tenant_id = getattr(current_user, "tenant_id", None)
if not tenant_id:
raise HTTPException(status_code=403, detail="No tenant associated with this account")
return tenant_id


@router.get("/dashboard/properties")
async def get_dashboard_properties(
current_user: AuthenticatedUser = Depends(get_current_user)
) -> List[Dict[str, Any]]:
"""Properties of the caller's tenant, for the dashboard selector."""
tenant_id = _require_tenant(current_user)

try:
return await list_tenant_properties(tenant_id)
except Exception:
raise HTTPException(status_code=503, detail="Property list temporarily unavailable")


@router.get("/dashboard/summary")
async def get_dashboard_summary(
property_id: str,
current_user: dict = Depends(get_current_user)
month: Optional[int] = Query(None, ge=1, le=12, description="Calendar month in the property's timezone"),
year: Optional[int] = Query(None, ge=2000, le=2100),
current_user: AuthenticatedUser = Depends(get_current_user)
) -> Dict[str, Any]:

tenant_id = getattr(current_user, "tenant_id", "default_tenant") or "default_tenant"

revenue_data = await get_revenue_summary(property_id, tenant_id)

total_revenue_float = float(revenue_data['total'])


if (month is None) != (year is None):
raise HTTPException(status_code=422, detail="month and year must be given together")

tenant_id = _require_tenant(current_user)

try:
revenue_data = await get_revenue_summary(property_id, tenant_id, month, year)
except MixedCurrencyError:
raise HTTPException(
status_code=409,
detail="Reservations use more than one currency; totals cannot be added",
)
except Exception:
raise HTTPException(status_code=503, detail="Revenue data temporarily unavailable")

# 'total' is already rounded to cents as an exact decimal string.
# total_revenue keeps the numeric field the frontend reads; the string
# field carries the exact amount for consumers that must not use floats.
total_revenue = Decimal(revenue_data['total'])

return {
"property_id": revenue_data['property_id'],
"total_revenue": total_revenue_float,
"total_revenue": float(total_revenue),
"total_revenue_str": str(total_revenue),
"currency": revenue_data['currency'],
"reservations_count": revenue_data['count']
}
39 changes: 27 additions & 12 deletions backend/app/core/database_pool.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,44 @@
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.pool import QueuePool
import logging
from ..config import settings

logger = logging.getLogger(__name__)


def _async_database_url(url: str) -> str:
"""Use the asyncpg driver for a plain postgresql:// URL."""
for prefix in ("postgresql+asyncpg://", "postgresql://", "postgres://"):
if url.startswith(prefix):
return "postgresql+asyncpg://" + url[len(prefix):]
return url


class DatabasePool:
def __init__(self):
self.engine = None
self.session_factory = None

self._init_lock = asyncio.Lock()

async def initialize(self):
"""Initialize database connection pool"""
"""Initialize database connection pool (once per process)"""
if self.session_factory:
return
async with self._init_lock:
if self.session_factory:
return
await self._create_engine()

async def _create_engine(self):
try:
# Create async engine with connection pooling
database_url = f"postgresql+asyncpg://{settings.supabase_db_user}:{settings.supabase_db_password}@{settings.supabase_db_host}:{settings.supabase_db_port}/{settings.supabase_db_name}"

# The async engine uses AsyncAdaptedQueuePool by default.
# QueuePool is not valid for asyncio engines.
self.engine = create_async_engine(
database_url,
poolclass=QueuePool,
pool_size=20, # Number of connections to maintain
max_overflow=30, # Additional connections when needed
_async_database_url(settings.database_url),
pool_size=settings.database_pool_size,
max_overflow=settings.database_max_overflow,
pool_pre_ping=True, # Validate connections
pool_recycle=3600, # Recycle connections every hour
pool_recycle=settings.database_pool_recycle,
echo=False # Set to True for SQL debugging
)

Expand All @@ -45,7 +60,7 @@ async def close(self):
if self.engine:
await self.engine.dispose()

async def get_session(self) -> AsyncSession:
def get_session(self) -> AsyncSession:
"""Get database session from pool"""
if not self.session_factory:
raise Exception("Database pool not initialized")
Expand Down
28 changes: 21 additions & 7 deletions backend/app/core/tenant_resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from typing import Optional
import logging

from jose import jwt, JWTError

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -69,27 +71,39 @@ def resolve_tenant_from_user(user_data: dict) -> Optional[str]:
return None

@staticmethod
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> str:
async def resolve_tenant_id(user_id: str, user_email: str, token: Optional[str] = None) -> Optional[str]:
"""
Resolve tenant ID for a user.

Args:
user_id: User ID
user_email: User email

token: JWT already verified by the caller; its tenant claim wins

Returns:
Tenant ID
Tenant ID, or None when the user has no tenant
"""
# The signed token is the source of truth for the tenant.
if token:
try:
tenant_id = TenantResolver.resolve_tenant_from_token(jwt.get_unverified_claims(token))
if tenant_id:
return tenant_id
except JWTError:
logger.warning(f"Could not read tenant claim from token for {user_email}")
Comment on lines +87 to +93

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: resolve_tenant_from_token trusts user-editable user_metadata.tenant_id over admin-controlled metadata or database assignments.
Severity: HIGH

Suggested Fix

Ignore user_metadata for tenant authorization. Retrieve tenant_id strictly from app_metadata or validate the claim against the user_tenants database table.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/core/tenant_resolver.py#L87-L93

Potential issue: `resolve_tenant_from_token` resolves `tenant_id` by inspecting
`user_metadata.tenant_id` prior to `app_metadata` or authoritative database records. In
Supabase auth, users can modify their own `user_metadata`. If a token from a
Supabase-authenticated user contains a manipulated `user_metadata.tenant_id`, the system
trusts the claim, allowing unauthorized access to other tenants' cached dashboard data,
revenue, and property records.

Did we get this right? 👍 / 👎 to inform future reviews.


# Fallback mapping by known user email.
if user_email == "sunset@propertyflow.com":
return "tenant-a"
if user_email == "ocean@propertyflow.com":
return "tenant-b"
if user_email == "candidate@propertyflow.com":
return "tenant-a"

# Default fallback
return "tenant-a"

# No default: an unknown user must never be placed in a real tenant,
# or they would see that tenant's data.
logger.warning(f"No tenant found for user {user_email} ({user_id})")
return None
Comment on lines 96 to +106

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Removing default fallback in resolve_tenant_id causes callers omitting token to resolve tenant_id as None for standard users.
Severity: HIGH

Suggested Fix

Update callers (login.py, auth_info.py, city_access_fast.py) to pass token to resolve_tenant_id, or use user.tenant_id already populated by authenticate_request.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: backend/app/core/tenant_resolver.py#L86-L106

Potential issue: `resolve_tenant_id` no longer falls back to 'tenant-a' and returns
`None` for any user without a hardcoded email when called without a `token`. Multiple
existing callers (`login.py`, `auth_info.py`, and `city_access_fast.py`) invoke
`resolve_tenant_id` without passing `token`. Consequently, users not in the hardcoded
email map receive a JWT with `tenant_id: None` on login, `/auth/me` returns `tenant_id:
null` while missing permissions, and city access returns `tenant_resolution_failed`.

Did we get this right? 👍 / 👎 to inform future reviews.


@staticmethod
async def update_user_tenant_metadata(user_id: str, tenant_id: str) -> None:
Expand Down
30 changes: 22 additions & 8 deletions backend/app/services/cache.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
import json
import redis.asyncio as redis
from typing import Dict, Any
from typing import Dict, Any, Optional
import os

# Initialize Redis client (typically configured centrally).
redis_client = redis.Redis.from_url(os.getenv("REDIS_URL", "redis://localhost:6379/0"))

async def get_revenue_summary(property_id: str, tenant_id: str) -> Dict[str, Any]:
async def get_revenue_summary(
property_id: str,
tenant_id: str,
month: Optional[int] = None,
year: Optional[int] = None,
) -> Dict[str, Any]:
"""
Fetches revenue summary, utilizing caching to improve performance.
"""
cache_key = f"revenue:{property_id}"

if not tenant_id:
raise ValueError("tenant_id is required for revenue lookups")

# Property IDs are only unique per tenant (prop-001 exists in several
# tenants), so the tenant must be part of the key to keep data isolated.
period = f"{year}-{month:02d}" if month and year else "all"
cache_key = f"revenue:{tenant_id}:{property_id}:{period}"

# Try to get from cache
cached = await redis_client.get(cache_key)
if cached:
return json.loads(cached)

# Revenue calculation is delegated to the reservation service.
from app.services.reservations import calculate_total_revenue
from app.services.reservations import calculate_monthly_revenue, calculate_total_revenue

# Calculate revenue
result = await calculate_total_revenue(property_id, tenant_id)
if month and year:
result = await calculate_monthly_revenue(property_id, tenant_id, month, year)
else:
result = await calculate_total_revenue(property_id, tenant_id)

# Cache the result for 5 minutes
await redis_client.setex(cache_key, 300, json.dumps(result))
Expand Down
Loading