New token arch
This commit is contained in:
@@ -1004,13 +1004,48 @@ async def upgrade_subscription_plan(
|
||||
error=str(cache_error))
|
||||
# Don't fail the upgrade if cache invalidation fails
|
||||
|
||||
# SECURITY: Invalidate all existing tokens for this tenant
|
||||
# Forces users to re-authenticate and get new JWT with updated tier
|
||||
try:
|
||||
await _invalidate_tenant_tokens(tenant_id, redis_client)
|
||||
logger.info("Invalidated all tokens for tenant after subscription upgrade",
|
||||
tenant_id=str(tenant_id))
|
||||
except Exception as token_error:
|
||||
logger.error("Failed to invalidate tenant tokens after upgrade",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(token_error))
|
||||
# Don't fail the upgrade if token invalidation fails
|
||||
|
||||
# Also publish event for real-time notification
|
||||
try:
|
||||
from shared.messaging import UnifiedEventPublisher
|
||||
event_publisher = UnifiedEventPublisher()
|
||||
await event_publisher.publish_business_event(
|
||||
event_type="subscription.changed",
|
||||
tenant_id=str(tenant_id),
|
||||
data={
|
||||
"tenant_id": str(tenant_id),
|
||||
"old_tier": active_subscription.plan,
|
||||
"new_tier": new_plan,
|
||||
"action": "upgrade"
|
||||
}
|
||||
)
|
||||
logger.info("Published subscription change event",
|
||||
tenant_id=str(tenant_id),
|
||||
event_type="subscription.changed")
|
||||
except Exception as event_error:
|
||||
logger.error("Failed to publish subscription change event",
|
||||
tenant_id=str(tenant_id),
|
||||
error=str(event_error))
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Plan successfully upgraded to {new_plan}",
|
||||
"old_plan": active_subscription.plan,
|
||||
"new_plan": new_plan,
|
||||
"new_monthly_price": updated_subscription.monthly_price,
|
||||
"validation": validation
|
||||
"validation": validation,
|
||||
"requires_token_refresh": True # Signal to frontend
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
@@ -1192,3 +1227,33 @@ async def get_invoices(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get invoices"
|
||||
)
|
||||
|
||||
|
||||
async def _invalidate_tenant_tokens(tenant_id: str, redis_client):
|
||||
"""
|
||||
Invalidate all tokens for users in this tenant.
|
||||
Forces re-authentication to get fresh subscription data.
|
||||
"""
|
||||
try:
|
||||
# Set a "subscription_changed_at" timestamp for this tenant
|
||||
# Gateway will check this and reject tokens issued before this time
|
||||
import datetime
|
||||
from datetime import timezone
|
||||
|
||||
changed_timestamp = datetime.datetime.now(timezone.utc).timestamp()
|
||||
|
||||
await redis_client.set(
|
||||
f"tenant:{tenant_id}:subscription_changed_at",
|
||||
str(changed_timestamp),
|
||||
ex=86400 # 24 hour TTL
|
||||
)
|
||||
|
||||
logger.info("Set subscription change timestamp for token invalidation",
|
||||
tenant_id=tenant_id,
|
||||
timestamp=changed_timestamp)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to invalidate tenant tokens",
|
||||
tenant_id=tenant_id,
|
||||
error=str(e))
|
||||
raise
|
||||
|
||||
@@ -771,6 +771,43 @@ class EnhancedTenantService:
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to remove team member"
|
||||
)
|
||||
|
||||
async def get_user_memberships(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
"""Get all tenant memberships for a user (for authentication service)"""
|
||||
try:
|
||||
async with self.database_manager.get_session() as db_session:
|
||||
await self._init_repositories(db_session)
|
||||
|
||||
# Get all user memberships
|
||||
memberships = await self.member_repo.get_user_memberships(user_id, active_only=False)
|
||||
|
||||
# Convert to response format
|
||||
result = []
|
||||
for membership in memberships:
|
||||
result.append({
|
||||
"id": str(membership.id),
|
||||
"tenant_id": str(membership.tenant_id),
|
||||
"user_id": str(membership.user_id),
|
||||
"role": membership.role,
|
||||
"is_active": membership.is_active,
|
||||
"joined_at": membership.joined_at.isoformat() if membership.joined_at else None,
|
||||
"invited_by": str(membership.invited_by) if membership.invited_by else None
|
||||
})
|
||||
|
||||
logger.info("Retrieved user memberships",
|
||||
user_id=user_id,
|
||||
membership_count=len(result))
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to get user memberships",
|
||||
user_id=user_id,
|
||||
error=str(e))
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to get user memberships"
|
||||
)
|
||||
|
||||
async def update_model_status(
|
||||
self,
|
||||
|
||||
@@ -14,6 +14,20 @@ from sqlalchemy.dialects.postgresql import UUID
|
||||
import uuid
|
||||
|
||||
|
||||
def _index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists in the database."""
|
||||
result = connection.execute(
|
||||
sa.text("""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE indexname = :index_name
|
||||
)
|
||||
"""),
|
||||
{"index_name": index_name}
|
||||
)
|
||||
return result.scalar()
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '001_unified_initial_schema'
|
||||
down_revision: Union[str, None] = None
|
||||
@@ -226,6 +240,65 @@ def upgrade() -> None:
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Add performance indexes for subscriptions table
|
||||
# Get connection to check existing indexes
|
||||
connection = op.get_bind()
|
||||
|
||||
# Index 1: Fast lookup by tenant_id with status filter
|
||||
if not _index_exists(connection, 'idx_subscriptions_tenant_status'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_tenant_status',
|
||||
'subscriptions',
|
||||
['tenant_id', 'status'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("status IN ('active', 'trial', 'trialing')")
|
||||
)
|
||||
|
||||
# Index 2: Covering index to avoid table lookups (most efficient)
|
||||
if not _index_exists(connection, 'idx_subscriptions_tenant_covering'):
|
||||
op.execute("""
|
||||
CREATE INDEX idx_subscriptions_tenant_covering
|
||||
ON subscriptions (tenant_id, plan, status, next_billing_date, monthly_price, max_users, max_locations, max_products)
|
||||
""")
|
||||
|
||||
# Index 3: Status and validity checks for batch operations
|
||||
if not _index_exists(connection, 'idx_subscriptions_status_billing'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_status_billing',
|
||||
'subscriptions',
|
||||
['status', 'next_billing_date'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("status IN ('active', 'trial', 'trialing')")
|
||||
)
|
||||
|
||||
# Index 4: Quick lookup for tenant's active subscription (specialized)
|
||||
if not _index_exists(connection, 'idx_subscriptions_active_tenant'):
|
||||
op.execute("""
|
||||
CREATE INDEX idx_subscriptions_active_tenant
|
||||
ON subscriptions (tenant_id, status, plan, next_billing_date, max_users, max_locations, max_products)
|
||||
WHERE status = 'active'
|
||||
""")
|
||||
|
||||
# Index 5: Stripe subscription lookup (for webhook processing)
|
||||
if not _index_exists(connection, 'idx_subscriptions_stripe_sub_id'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_stripe_sub_id',
|
||||
'subscriptions',
|
||||
['stripe_subscription_id'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("stripe_subscription_id IS NOT NULL")
|
||||
)
|
||||
|
||||
# Index 6: Stripe customer lookup (for customer-related operations)
|
||||
if not _index_exists(connection, 'idx_subscriptions_stripe_customer_id'):
|
||||
op.create_index(
|
||||
'idx_subscriptions_stripe_customer_id',
|
||||
'subscriptions',
|
||||
['stripe_customer_id'],
|
||||
unique=False,
|
||||
postgresql_where=sa.text("stripe_customer_id IS NOT NULL")
|
||||
)
|
||||
|
||||
# Create coupons table with tenant_id nullable to support system-wide coupons
|
||||
op.create_table('coupons',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
@@ -372,6 +445,14 @@ def downgrade() -> None:
|
||||
op.drop_index('idx_coupon_code_active', table_name='coupons')
|
||||
op.drop_table('coupons')
|
||||
|
||||
# Drop subscriptions table indexes first
|
||||
op.drop_index('idx_subscriptions_stripe_customer_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_stripe_sub_id', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_active_tenant', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_status_billing', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_tenant_covering', table_name='subscriptions')
|
||||
op.drop_index('idx_subscriptions_tenant_status', table_name='subscriptions')
|
||||
|
||||
# Drop subscriptions table
|
||||
op.drop_table('subscriptions')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user