main
py 1,177 lines 38.9 KB
Raw
1 import json
2 import os
3 from datetime import datetime as dt
4 from datetime import timedelta
5 from typing import Any
6 from typing import Dict
7 from typing import List
8 from typing import Optional
9
10 import requests
11 from fastapi import APIRouter
12 from fastapi import Depends
13 from fastapi import HTTPException
14 from fastapi import Security
15 from loguru import logger
16 from pydantic import BaseModel
17 from pydantic import Field
18 from sqlalchemy import delete
19 from sqlalchemy import select
20 from sqlalchemy.ext.asyncio import AsyncSession
21
22 from app.auth.routes.auth import AuthHandler
23 from app.connectors.schema import UpdateConnector
24 from app.connectors.services import ConnectorServices
25 from app.db.db_session import get_db
26 from app.db.universal_models import License
27 from app.db.universal_models import LicenseCache
28
29
30 class ThreatIntelRegisterRequest(BaseModel):
31 """
32 A Pydantic model for registering to the SOCFortress Threat Intel Feed which
33 requires a valid API key.
34 """
35
36 customer_name: str = Field(..., description="The customer name")
37 requested_by: str = Field("CoPilot", description="The system requesting access")
38 registration_url: str = Field("https://intel.socfortress.co/register", description="The registration URL")
39 requesting_api_key: str = Field(os.getenv("COPILOT_API_KEY"), description="The requesting API key")
40
41
42 class ThreatIntelRegisterResponse(BaseModel):
43 """
44 A Pydantic model for the response to registering to the SOCFortress Threat Intel Feed.
45 """
46
47 api_key: str = Field(..., description="The API key")
48 success: bool = Field(..., description="Indicates if the registration was successful")
49 message: str = Field(..., description="The message")
50
51
52 class ReplaceLicenseRequest(BaseModel):
53 """
54 A Pydantic model for replacing a license.
55
56 Attributes:
57 license_key (str): The license key to replace.
58 """
59
60 license_key: str = Field(..., title="The license key to replace")
61
62
63 class TrialLicenseRequest(BaseModel):
64 period: Optional[int] = Field(7, title="The period of the trial license")
65 email: str = Field(..., title="The email of the user")
66 feature_name: str = Field(..., title="The feature name")
67 customer_name: str = Field(..., title="The customer name")
68 company_name: str = Field(..., title="The company name")
69
70
71 class TrialLicenseResponse(BaseModel):
72 license_key: str
73 success: bool
74 message: str
75
76
77 class CreateCustomerKeyResult(BaseModel):
78 customerId: int
79 key: str
80 result: int
81 message: Optional[str] = None
82
83
84 class CreateCustomerKeyResponseModel(BaseModel):
85 response: List[Optional[CreateCustomerKeyResult]]
86
87
88 class CreateCustomerKeyRouteResponse(BaseModel):
89 response: List[Optional[CreateCustomerKeyResult]]
90 success: bool = Field(..., title="Indicates if the key creation was successful")
91 message: str = Field(..., title="The message")
92
93
94 class Customer(BaseModel):
95 id: int
96 name: str
97 email: str
98 companyName: str
99 created: dt
100
101
102 class RawResponse(BaseModel):
103 license_key: str
104 signature: str
105 result: int
106 message: str
107 metadata: Optional[Any] = None
108
109
110 class LicenseResponse(BaseModel):
111 productId: int
112 id: int
113 key: str
114 created: dt
115 expires: dt
116 period: int
117 f1: bool
118 f2: bool
119 f3: bool
120 f4: bool
121 f5: bool
122 f6: bool
123 f7: bool
124 f8: bool
125 notes: str
126 block: bool
127 globalId: int
128 customer: Customer
129 activatedMachines: List
130 trialActivation: bool
131 maxNoOfMachines: int
132 allowedMachines: Optional[Any] = None
133 dataObjects: List
134 signDate: dt
135 reseller: Optional[Any] = None
136
137
138 class VerifyLicenseResponse(BaseModel):
139 license: LicenseResponse
140 success: bool
141 message: str
142
143
144 class GetLicenseResponse(BaseModel):
145 license_key: str
146 success: bool
147 message: str
148
149
150 class GetLicenseFeaturesResponse(BaseModel):
151 features: List[str]
152 success: bool
153 message: str
154
155
156 class IsFeatureEnabledResponse(BaseModel):
157 enabled: bool
158 success: bool
159 message: str
160
161
162 class Feature(BaseModel):
163 id: int
164 subscription_price_id: str
165 name: str
166 price: int
167 currency: str
168 info: str
169 short_description: str
170 full_description: str
171
172
173 class GetSubscriptionCatalogFeaturesResponse(BaseModel):
174 features: List[Feature]
175 success: bool
176 message: str
177
178
179 class FeatureSubscriptionRequest(BaseModel):
180 feature_id: int = Field(..., examples=[1])
181 cancel_url: str = Field(..., examples=["https://example.com/cancel"])
182 success_url: str = Field(..., examples=["https://example.com/success"])
183 customer_email: str = Field(..., examples=["info@socfortress.co"])
184 company_name: str = Field(..., examples=["SOCFORTRESS"])
185
186
187 class GetLicenseByEmailRequest(BaseModel):
188 email: str = Field(..., examples=["info@socfortress.co"])
189
190
191 class AddLicenseToDB(BaseModel):
192 customer_name: str
193 customer_email: str
194 company_name: str
195
196
197 ###### ! CREATE SESSION CHECKOUT ! ######
198 class AutomaticTax(BaseModel):
199 enabled: bool
200 liability: Optional[str] = None
201 status: Optional[str] = None
202
203
204 class CustomText(BaseModel):
205 after_submit: Optional[str] = None
206 shipping_address: Optional[str] = None
207 submit: Optional[str] = None
208 terms_of_service_acceptance: Optional[str] = None
209
210
211 class InvoiceData(BaseModel):
212 account_tax_ids: Optional[str] = None
213 custom_fields: Optional[str] = None
214 description: Optional[str] = None
215 footer: Optional[str] = None
216 issuer: Optional[str] = None
217 metadata: Dict = {}
218 rendering_options: Optional[str] = None
219
220
221 class InvoiceCreation(BaseModel):
222 enabled: bool
223 invoice_data: InvoiceData
224
225
226 class PaymentMethodOptionsCard(BaseModel):
227 request_three_d_secure: str
228
229
230 class PaymentMethodOptions(BaseModel):
231 card: PaymentMethodOptionsCard
232
233
234 class PhoneNumberCollection(BaseModel):
235 enabled: bool
236
237
238 class TotalDetails(BaseModel):
239 amount_discount: int
240 amount_shipping: int
241 amount_tax: int
242
243
244 class CustomerDetails(BaseModel):
245 address: Optional[str] = None
246 email: Optional[str] = None
247 name: Optional[str] = None
248 phone: Optional[str] = None
249 tax_exempt: Optional[str] = None
250 tax_ids: Optional[str] = None
251
252
253 class CheckoutSession(BaseModel):
254 after_expiration: Optional[str] = None
255 allow_promotion_codes: Optional[str] = None
256 amount_subtotal: int
257 amount_total: int
258 automatic_tax: AutomaticTax
259 billing_address_collection: Optional[str] = None
260 cancel_url: str
261 client_reference_id: Optional[str] = None
262 client_secret: Optional[str] = None
263 consent: Optional[str] = None
264 consent_collection: Optional[str] = None
265 created: int
266 currency: str
267 currency_conversion: Optional[str] = None
268 custom_fields: List = []
269 custom_text: CustomText
270 customer: Optional[str] = None
271 customer_creation: Optional[str] = None
272 customer_details: Optional[CustomerDetails] = None
273 customer_email: Optional[str] = None
274 expires_at: int
275 id: str
276 invoice: Optional[str] = None
277 invoice_creation: Optional[InvoiceCreation] = None
278 livemode: bool
279 locale: Optional[str] = None
280 metadata: Dict
281 mode: str
282 object: str
283 payment_intent: Optional[str] = None
284 payment_link: Optional[str] = None
285 payment_method_collection: str
286 payment_method_configuration_details: Optional[str] = None
287 payment_method_options: PaymentMethodOptions
288 payment_method_types: List[str]
289 payment_status: str
290 phone_number_collection: PhoneNumberCollection
291 recovered_from: Optional[str] = None
292 setup_intent: Optional[str] = None
293 shipping_address_collection: Optional[str] = None
294 shipping_cost: Optional[str] = None
295 shipping_details: Optional[str] = None
296 shipping_options: List = []
297 status: str
298 submit_type: Optional[str] = None
299 subscription: Optional[str] = None
300 success_url: str
301 total_details: TotalDetails
302 ui_mode: str
303 url: str
304
305
306 class CheckoutSessionResponse(BaseModel):
307 success: bool = True
308 message: str = "Checkout session created successfully"
309 session: CheckoutSession
310
311
312 class CancelSubscriptionRequest(BaseModel):
313 customer_email: str
314 subscription_price_id: str
315 feature_name: str
316
317
318 class CancelSubscriptionResponse(BaseModel):
319 success: bool
320 message: str
321
322
323 class RetrieveDockerCompose(BaseModel):
324 docker_compose: str
325 success: bool
326 message: str
327
328
329 license_router = APIRouter()
330
331 # Cache duration in hours
332 CACHE_DURATION_HOURS = 1
333
334
335 def normalize_api_response(response: Dict[str, Any]) -> Dict[str, Any]:
336 """
337 Normalize API responses to handle different response structures.
338 Some endpoints return data wrapped in 'data' key, others don't.
339
340 Args:
341 response: Raw API response
342
343 Returns:
344 Normalized response with consistent structure
345 """
346 if "data" in response:
347 # Response has data wrapper - return as is
348 return response
349 else:
350 # Response doesn't have data wrapper - wrap it
351 return {"data": response, "success": response.get("success", True), "message": response.get("message", "Success")}
352
353
354 async def check_if_license_exists(session: AsyncSession):
355 # Get the first row and raise HTTPException stating license already exists
356 result = await session.execute(select(License))
357 license = result.scalars().first()
358 logger.info(f"License: {license}")
359 if license:
360 raise HTTPException(status_code=400, detail="License already exists")
361
362
363 def get_auth_token():
364 auth = os.getenv("CRYPTOLENS_AUTH")
365 if not auth:
366 raise HTTPException(status_code=500, detail="Auth token not found")
367 return auth
368
369
370 async def add_license_to_db(session: AsyncSession, result, request: AddLicenseToDB):
371 """
372 Add a new license to the database.
373
374 :param session: AsyncSession object for the database session
375 :param result: The license key to be added
376 :param request: The request object containing customer details
377 :return: The newly added License object
378 """
379
380 new_license = License(
381 license_key=result,
382 customer_name=request.customer_name,
383 customer_email=request.customer_email,
384 company_name=request.company_name,
385 )
386
387 logger.info(f"Adding new license: {new_license} to the database")
388 session.add(new_license)
389 await session.commit()
390 return new_license
391
392
393 async def get_license(session: AsyncSession, raise_on_missing: bool = True) -> Optional[License]:
394 """
395 Get the license from the database
396
397 :param session: The AsyncSession object for the database
398 :param raise_on_missing: If True, raise HTTPException when no license found. If False, return None.
399 :return: The License object or None
400 """
401 result = await session.execute(select(License))
402 license = result.scalars().first()
403 if license is None:
404 if raise_on_missing:
405 raise HTTPException(status_code=404, detail="No license found. A license must be created first.")
406 else:
407 return None
408 else:
409 return license
410
411
412 async def get_cached_feature(session: AsyncSession, license_key: str, feature_name: str) -> Optional[LicenseCache]:
413 """
414 Get cached feature information if it exists and is not expired.
415
416 Args:
417 session: Database session
418 license_key: The license key
419 feature_name: The feature name to check
420
421 Returns:
422 LicenseCache object if valid cache exists, None otherwise
423 """
424 current_time = dt.utcnow()
425
426 result = await session.execute(
427 select(LicenseCache).where(
428 LicenseCache.license_key == license_key,
429 LicenseCache.feature_name == feature_name,
430 LicenseCache.expires_at > current_time,
431 ),
432 )
433
434 cached_feature = result.scalars().first()
435
436 if cached_feature:
437 logger.info(f"Found valid cache for feature '{feature_name}' (expires at {cached_feature.expires_at})")
438 return cached_feature
439 else:
440 logger.info(f"No valid cache found for feature '{feature_name}'")
441 return None
442
443
444 async def cache_license_features(session: AsyncSession, license_key: str, license_data: Dict[str, Any]) -> None:
445 """
446 Cache license features from license verification response.
447
448 Args:
449 session: Database session
450 license_key: The license key
451 license_data: The license verification response data
452 """
453 try:
454 # Clear existing cache for this license key
455 await session.execute(delete(LicenseCache).where(LicenseCache.license_key == license_key))
456
457 current_time = dt.utcnow()
458 expires_at = current_time + timedelta(hours=CACHE_DURATION_HOURS)
459
460 # Store the full license data as JSON string for reference
461 license_json = json.dumps(license_data)
462
463 # Extract features from dataObjects - handle both response formats
464 if "data" in license_data and "license" in license_data["data"]:
465 # Wrapped format: {"data": {"license": {"dataObjects": [...]}}}
466 data_objects = license_data["data"]["license"].get("dataObjects", [])
467 elif "license" in license_data:
468 # Direct format: {"license": {"dataObjects": [...]}}
469 data_objects = license_data["license"].get("dataObjects", [])
470 elif "dataObjects" in license_data:
471 # Bare format: {"dataObjects": [...]}
472 data_objects = license_data.get("dataObjects", [])
473 else:
474 logger.warning("Could not find dataObjects in license response")
475 data_objects = []
476
477 # Track which features we've processed
478 processed_features = set()
479
480 for data_object in data_objects:
481 feature_name = data_object.get("name")
482 is_enabled = data_object.get("intValue") == 1
483
484 if feature_name:
485 cache_entry = LicenseCache(
486 license_key=license_key,
487 feature_name=feature_name,
488 is_enabled=is_enabled,
489 cached_at=current_time,
490 expires_at=expires_at,
491 license_data=license_json,
492 )
493
494 session.add(cache_entry)
495 processed_features.add(feature_name)
496
497 logger.info(f"Cached feature '{feature_name}': {'enabled' if is_enabled else 'disabled'}")
498
499 await session.commit()
500 logger.info(f"Successfully cached {len(processed_features)} features for license {license_key[:8]}...")
501
502 except Exception as e:
503 logger.error(f"Error caching license features: {str(e)}")
504 await session.rollback()
505 raise
506
507
508 async def invalidate_license_cache(session: AsyncSession, license_key: str) -> None:
509 """
510 Invalidate (delete) all cached entries for a specific license key.
511
512 Args:
513 session: Database session
514 license_key: The license key to invalidate cache for
515 """
516 try:
517 result = await session.execute(delete(LicenseCache).where(LicenseCache.license_key == license_key))
518 deleted_count = result.rowcount
519 await session.commit()
520
521 logger.info(f"Invalidated {deleted_count} cache entries for license {license_key[:8]}...")
522
523 except Exception as e:
524 logger.error(f"Error invalidating license cache: {str(e)}")
525 await session.rollback()
526
527
528 def is_license_expired(license: dict) -> bool:
529 """
530 Check if a license is expired.
531
532 Args:
533 license (dict): The license to check.
534
535 Returns:
536 bool: True if the license is expired, False otherwise.
537 """
538 logger.info(f"License: {license}")
539 expires = dt.strptime(license["data"]["license"]["expires"], "%Y-%m-%dT%H:%M:%S.%f")
540 return dt.now() > expires
541
542
543 async def is_feature_enabled(feature_name: str, session: AsyncSession, message: str = None) -> bool:
544 """
545 Check if a feature is enabled in a license.
546 Uses cache first, falls back to API if cache miss or expired.
547
548 Args:
549 feature_name (str): The feature name to check.
550 session (AsyncSession): The database session.
551 message (str, optional): Custom error message.
552
553 Returns:
554 bool: True if the feature is enabled, False otherwise.
555 """
556 license = await get_license(session)
557
558 # Check cache first
559 cached_feature = await get_cached_feature(session, license.license_key, feature_name)
560
561 if cached_feature:
562 logger.info(f"Using cached result for feature '{feature_name}': {'enabled' if cached_feature.is_enabled else 'disabled'}")
563
564 if cached_feature.is_enabled:
565 return True
566 else:
567 # Feature is disabled according to cache
568 if message:
569 raise HTTPException(status_code=400, detail=message)
570 raise HTTPException(
571 status_code=400,
572 detail=f"Feature is not enabled. You must purchase the {feature_name} license to use this feature.",
573 )
574
575 # Cache miss - fetch from API
576 logger.info(f"Cache miss for feature '{feature_name}', fetching from API")
577
578 try:
579 result = await send_post_request("verify-license", data={"license_key": license.license_key})
580
581 # Normalize response format
582 normalized_result = normalize_api_response(result)
583
584 # Cache the results
585 await cache_license_features(session, license.license_key, normalized_result)
586
587 # Check if feature is enabled
588 data_objects = normalized_result["data"]["license"].get("dataObjects", [])
589 for data_object in data_objects:
590 if data_object["name"] == feature_name and data_object["intValue"] == 1:
591 logger.info(f"Feature '{feature_name}' is enabled (from API)")
592 return True
593
594 # Feature not found or not enabled
595 logger.info(f"Feature '{feature_name}' is not enabled (from API)")
596
597 if message:
598 raise HTTPException(status_code=400, detail=message)
599
600 raise HTTPException(
601 status_code=400,
602 detail=f"Feature is not enabled. You must purchase the {feature_name} license to use this feature.",
603 )
604
605 except HTTPException:
606 # Re-raise HTTP exceptions (like feature not enabled)
607 raise
608 except Exception as e:
609 logger.error(f"Error verifying license from API: {str(e)}")
610 # If API fails, we can't verify - raise an error
611 raise HTTPException(status_code=500, detail=f"Unable to verify license: {str(e)}")
612
613
614 async def send_get_request(endpoint: str) -> Dict[str, Any]:
615 """
616 Sends a GET request to the Shuffle service.
617
618 Args:
619 endpoint (str): The endpoint to send the GET request to.
620
621 Returns:
622 Dict[str, Any]: The response from the GET request.
623 """
624 logger.info(f"Sending GET request to {endpoint}")
625
626 try:
627 HEADERS = {
628 "x-api-key": f"{os.getenv('COPILOT_API_KEY')}",
629 "Content-Type": "application/json",
630 "module-version": "1.0",
631 }
632 response = requests.get(
633 f"https://license.socfortress.co/{endpoint}",
634 headers=HEADERS,
635 verify=False,
636 timeout=10,
637 )
638
639 if response.status_code == 204:
640 return {"success": True, "message": "No content"}
641 else:
642 return response.json()
643 except Exception as e:
644 logger.error(f"Failed to send GET request to {endpoint} with error: {e}")
645 raise HTTPException(
646 status_code=500,
647 detail=f"Failed to send GET request to {endpoint} with error: {e}",
648 )
649
650
651 @license_router.get(
652 "/subscription_features",
653 description="Get the subscription features available",
654 response_model=GetSubscriptionCatalogFeaturesResponse,
655 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
656 )
657 async def get_subscription_catalog():
658 """
659 Get the subscription catalog. This is handled by the Middleware running in SOCFortress Infra
660
661 Returns:
662 dict: A dictionary containing the subscription catalog.
663 """
664 try:
665 result = await send_get_request("features")
666 normalized_result = normalize_api_response(result)
667
668 # Handle different response structures for subscription features
669 features = []
670 if "data" in normalized_result and "features" in normalized_result["data"]:
671 features = normalized_result["data"]["features"]
672 elif "features" in normalized_result:
673 features = normalized_result["features"]
674 else:
675 logger.warning(f"No features found in response: {normalized_result}")
676 features = []
677
678 return GetSubscriptionCatalogFeaturesResponse(
679 features=features,
680 success=normalized_result.get("success", True),
681 message=normalized_result.get("message", "Subscription features retrieved successfully"),
682 )
683 except Exception as e:
684 logger.error(f"Error getting subscription catalog: {str(e)}")
685 return GetSubscriptionCatalogFeaturesResponse(
686 features=[],
687 success=False,
688 message=f"Error getting subscription catalog: {str(e)}",
689 )
690
691
692 @license_router.post(
693 "/retrieve_license_by_email",
694 description="Retrieve a license by email",
695 response_model=GetLicenseResponse,
696 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
697 )
698 async def retrieve_license_by_email(request: GetLicenseByEmailRequest, session: AsyncSession = Depends(get_db)) -> GetLicenseResponse:
699 """
700 Retrieve a license by email.
701
702 Args:
703 request (GetLicenseRequest): The request containing the email to retrieve the license by.
704 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
705
706 Returns:
707 GetLicenseResponse: A Pydantic model containing the license key, success status, and message.
708 """
709 # Check if a license with the given email already exists in the database
710 result = await session.execute(select(License).where(License.customer_email == request.email))
711 existing_license = result.scalars().first()
712 if existing_license:
713 return GetLicenseResponse(
714 license_key=existing_license.license_key,
715 success=True,
716 message="License already exists in database",
717 )
718
719 results = await send_post_request("retrieve-license-by-email", data={"email": request.email})
720 normalized_results = normalize_api_response(results)
721 logger.info(f"Results: {normalized_results}")
722 if normalized_results["data"]["success"] is False:
723 raise HTTPException(status_code=400, detail=normalized_results["data"]["message"])
724
725 # Add the license to the database
726 await add_license_to_db(
727 session,
728 normalized_results["data"]["license"]["key"],
729 AddLicenseToDB(
730 customer_email=normalized_results["data"]["license"]["customer"]["email"],
731 customer_name=normalized_results["data"]["license"]["customer"]["name"],
732 company_name=normalized_results["data"]["license"]["customer"]["companyName"],
733 ),
734 )
735 return GetLicenseResponse(
736 license_key=normalized_results["data"]["license"]["key"],
737 success=normalized_results["data"]["success"],
738 message=normalized_results["data"]["message"],
739 )
740
741
742 @license_router.post(
743 "/create_checkout_session",
744 description="Create a checkout session",
745 response_model=CheckoutSessionResponse,
746 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
747 )
748 async def create_checkout_session(request: FeatureSubscriptionRequest):
749 """
750 Create a checkout session.
751
752 Args:
753 request (FeatureSubscriptionRequest): The request containing the feature id and user id.
754
755 Returns:
756 dict: A dictionary containing the checkout session.
757 """
758 results = await send_post_request(
759 "create-checkout-session",
760 data={
761 "feature_id": request.feature_id,
762 "cancel_url": request.cancel_url,
763 "success_url": request.success_url,
764 "customer_email": request.customer_email,
765 "company_name": request.company_name,
766 },
767 )
768 normalized_results = normalize_api_response(results)
769 logger.info(f"Results: {normalized_results}")
770 if normalized_results["data"]["success"] is False:
771 raise HTTPException(status_code=400, detail=normalized_results["data"]["message"])
772 return CheckoutSessionResponse(
773 session=normalized_results["data"]["session"],
774 success=normalized_results["data"]["success"],
775 message=normalized_results["data"]["message"],
776 )
777
778
779 @license_router.post(
780 "/trial_license",
781 description="Create a trial license",
782 response_model=TrialLicenseResponse,
783 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
784 )
785 async def create_trial_license_key(request: TrialLicenseRequest, session: AsyncSession = Depends(get_db)) -> TrialLicenseResponse:
786 """
787 Create a trial license key.
788
789 Args:
790 request (CreateLicenseRequest): The request containing the license key to create.
791 session (AsyncSession, optional): The database session. Defaults to Depends(get_db).
792
793 Returns:
794 LicenseVerificationResponse: A Pydantic model containing the verification status and message.
795 """
796 await check_if_license_exists(session)
797
798 results = await send_post_request(
799 "trial-license",
800 data={
801 "period": request.period,
802 "email": request.email,
803 "feature_name": request.feature_name,
804 "customer_name": request.customer_name,
805 "company_name": request.company_name,
806 },
807 )
808 normalized_results = normalize_api_response(results)
809 logger.info(f"Results: {normalized_results}")
810 if normalized_results["data"]["success"] is False:
811 raise HTTPException(status_code=400, detail=normalized_results["data"]["message"])
812
813 # Add the license to the database
814 await add_license_to_db(
815 session,
816 normalized_results["data"]["license_key"],
817 AddLicenseToDB(
818 customer_email=request.email,
819 customer_name=request.customer_name,
820 company_name=request.company_name,
821 ),
822 )
823
824 return TrialLicenseResponse(
825 license_key=normalized_results["data"]["license_key"],
826 success=normalized_results["data"]["success"],
827 message=normalized_results["data"]["message"],
828 )
829
830
831 @license_router.post(
832 "/cancel_subscription",
833 description="Cancel a subscription",
834 response_model=CancelSubscriptionResponse,
835 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
836 )
837 async def cancel_subscription(request: CancelSubscriptionRequest) -> CancelSubscriptionResponse:
838 results = await send_post_request(
839 "cancel-subscription",
840 data={
841 "customer_email": request.customer_email,
842 "subscription_price_id": request.subscription_price_id,
843 "feature_name": request.feature_name,
844 },
845 )
846 normalized_results = normalize_api_response(results)
847 logger.info(f"Results: {normalized_results}")
848 if normalized_results["data"]["success"] is False:
849 raise HTTPException(status_code=400, detail=normalized_results["data"]["message"])
850 return CancelSubscriptionResponse(
851 success=normalized_results["data"]["success"],
852 message=normalized_results["data"]["message"],
853 )
854
855
856 @license_router.get(
857 "/verify_license",
858 response_model=VerifyLicenseResponse,
859 description="Verify a license key",
860 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
861 )
862 async def verify_license_key(session: AsyncSession = Depends(get_db)) -> VerifyLicenseResponse:
863 license = await get_license(session)
864
865 # Check if we have a recent cache entry for any feature of this license
866 current_time = dt.utcnow()
867 result = await session.execute(
868 select(LicenseCache).where(LicenseCache.license_key == license.license_key, LicenseCache.expires_at > current_time).limit(1),
869 )
870
871 cached_entry = result.scalars().first()
872
873 if cached_entry and cached_entry.license_data:
874 logger.info("Using cached license verification data")
875 try:
876 license_data = json.loads(cached_entry.license_data)
877 # Handle both wrapped and unwrapped formats
878 if "data" in license_data and "license" in license_data["data"]:
879 license_obj = license_data["data"]["license"]
880 success = license_data["data"]["success"]
881 elif "license" in license_data:
882 license_obj = license_data["license"]
883 success = license_data.get("success", True)
884 else:
885 raise ValueError("Invalid cached license data format")
886
887 return VerifyLicenseResponse(
888 license=license_obj,
889 success=success,
890 message="License verified successfully (from cache)",
891 )
892 except (json.JSONDecodeError, KeyError, ValueError) as e:
893 logger.warning(f"Error parsing cached license data: {e}, falling back to API")
894
895 # No valid cache, fetch from API
896 logger.info("No valid cache found, verifying license via API")
897 results = await send_post_request("verify-license", data={"license_key": license.license_key})
898
899 # Normalize response format
900 normalized_results = normalize_api_response(results)
901
902 # Cache the results
903 await cache_license_features(session, license.license_key, normalized_results)
904
905 logger.info(f"Results: {normalized_results}")
906 if not normalized_results.get("success", True):
907 raise HTTPException(status_code=400, detail=normalized_results.get("message", "License verification failed"))
908
909 # Handle both response formats for return value
910 if "data" in normalized_results and "license" in normalized_results["data"]:
911 license_obj = normalized_results["data"]["license"]
912 success = normalized_results["data"]["success"]
913 message = normalized_results["data"]["message"]
914 else:
915 license_obj = normalized_results["license"]
916 success = normalized_results.get("success", True)
917 message = normalized_results.get("message", "License verified successfully")
918
919 return VerifyLicenseResponse(
920 license=license_obj,
921 success=success,
922 message=message,
923 )
924
925
926 @license_router.get(
927 "/get_license",
928 description="Get a license",
929 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
930 )
931 async def get_license_key(session: AsyncSession = Depends(get_db)) -> GetLicenseResponse:
932 license = await get_license(session)
933 return GetLicenseResponse(
934 license_key=license.license_key,
935 success=True,
936 message="License retrieved successfully",
937 )
938
939
940 async def send_post_request(endpoint: str, data: Dict[str, Any] = None) -> Dict[str, Any]:
941 """
942 Sends a POST request to the Shuffle service.
943
944 Args:
945 endpoint (str): The endpoint to send the POST request to.
946 data (Dict[str, Any]): The data to send with the POST request.
947
948 Returns:
949 Dict[str, Any]: The response from the POST request.
950 """
951 logger.info(f"Sending POST request to {endpoint}")
952
953 try:
954 HEADERS = {
955 "x-api-key": f"{os.getenv('COPILOT_API_KEY')}",
956 "Content-Type": "application/json",
957 "module-version": "1.0",
958 }
959 response = requests.post(
960 f"https://license.socfortress.co/{endpoint}",
961 headers=HEADERS,
962 json=data,
963 verify=False,
964 timeout=10,
965 )
966
967 if response.status_code == 204:
968 return {"success": True, "message": "No content"}
969 else:
970 return response.json()
971 except Exception as e:
972 logger.error(f"Failed to send POST request to {endpoint} with error: {e}")
973 raise HTTPException(
974 status_code=500,
975 detail=f"Failed to send POST request to {endpoint} with error: {e}",
976 )
977
978
979 @license_router.get(
980 "/is_feature_enabled/{feature_name}",
981 response_model=IsFeatureEnabledResponse,
982 description="Check if a feature is enabled in a license",
983 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
984 )
985 async def is_feature_enabled_route(feature_name: str, session: AsyncSession = Depends(get_db)) -> IsFeatureEnabledResponse:
986 try:
987 await is_feature_enabled(feature_name, session)
988 return IsFeatureEnabledResponse(
989 enabled=True,
990 success=True,
991 message=f"Feature '{feature_name}' is enabled",
992 )
993 except HTTPException as e:
994 if e.status_code == 400: # Feature not enabled
995 return IsFeatureEnabledResponse(
996 status_code=400,
997 message=e.detail,
998 )
999 else:
1000 raise
1001
1002
1003 @license_router.get(
1004 "/get_license_features",
1005 response_model=GetLicenseFeaturesResponse,
1006 description="Get license features",
1007 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
1008 )
1009 async def get_license_features(session: AsyncSession = Depends(get_db)) -> GetLicenseFeaturesResponse:
1010 license = await get_license(session)
1011
1012 # Try to get features from cache first
1013 current_time = dt.utcnow()
1014 result = await session.execute(
1015 select(LicenseCache).where(
1016 LicenseCache.license_key == license.license_key,
1017 LicenseCache.expires_at > current_time,
1018 LicenseCache.is_enabled == True,
1019 ),
1020 )
1021
1022 cached_features = result.scalars().all()
1023
1024 if cached_features:
1025 logger.info(f"Using cached license features ({len(cached_features)} features)")
1026 features = [cache.feature_name for cache in cached_features]
1027 return GetLicenseFeaturesResponse(
1028 features=features,
1029 success=True,
1030 message="License features retrieved successfully (from cache)",
1031 )
1032
1033 # No cache, fetch from API
1034 logger.info("No cached features found, fetching from API")
1035 results = await send_post_request("license-features", data={"license_key": license.license_key})
1036
1037 # This endpoint returns features directly without data wrapper
1038 logger.info(f"Results: {results}")
1039 if not results.get("success", True):
1040 raise HTTPException(status_code=400, detail=results.get("message", "Failed to get license features"))
1041
1042 # For license-features endpoint, create a fake normalized response to cache the features
1043 fake_license_data = {
1044 "data": {
1045 "license": {"dataObjects": [{"name": feature, "intValue": 1} for feature in results.get("features", [])]},
1046 "success": True,
1047 },
1048 }
1049
1050 # Cache the results
1051 await cache_license_features(session, license.license_key, fake_license_data)
1052
1053 return GetLicenseFeaturesResponse(
1054 features=results.get("features", []),
1055 success=results.get("success", True),
1056 message=results.get("message", "License features retrieved successfully"),
1057 )
1058
1059
1060 @license_router.post(
1061 "/replace_license_in_db",
1062 description="Replace a license or create one if it doesn't exist",
1063 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
1064 )
1065 async def replace_license_in_db(request: ReplaceLicenseRequest, session: AsyncSession = Depends(get_db)):
1066 # Get license without raising error if it doesn't exist
1067 license = await get_license(session, raise_on_missing=False)
1068
1069 if license:
1070 # Existing license - replace it
1071 logger.info(f"Replacing existing license {license.license_key[:8]}... with {request.license_key[:8]}...")
1072
1073 # Invalidate cache for old license
1074 await invalidate_license_cache(session, license.license_key)
1075
1076 # Update license key
1077 license.license_key = request.license_key
1078 await session.commit()
1079
1080 logger.info("License replaced successfully")
1081 return {"success": True, "message": "License replaced successfully"}
1082 else:
1083 # No existing license - create a new one
1084 logger.info("No existing license found, creating new license")
1085
1086 # Verify the new license key first to get customer details
1087 results = await send_post_request("verify-license", data={"license_key": request.license_key})
1088 normalized_results = normalize_api_response(results)
1089
1090 if not normalized_results.get("success", True):
1091 raise HTTPException(status_code=400, detail="Invalid license key")
1092
1093 # Extract customer info from license verification
1094 license_data = normalized_results["data"]["license"]
1095 customer_data = license_data["customer"]
1096
1097 # Create new license in database
1098 await add_license_to_db(
1099 session,
1100 request.license_key,
1101 AddLicenseToDB(
1102 customer_email=customer_data["email"],
1103 customer_name=customer_data["name"],
1104 company_name=customer_data["companyName"],
1105 ),
1106 )
1107
1108 logger.info("License created successfully")
1109 return {"success": True, "message": "License created successfully"}
1110
1111
1112 @license_router.post(
1113 "/retrieve-docker-compose",
1114 response_model=RetrieveDockerCompose,
1115 description="Retrieve Docker Compose for features enabled",
1116 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
1117 )
1118 async def retrieve_docker_compose(session: AsyncSession = Depends(get_db)) -> RetrieveDockerCompose:
1119 license = await get_license(session)
1120 results = await send_post_request("retrieve-docker-compose", data={"license_key": license.license_key})
1121
1122 # This endpoint returns data directly without wrapper
1123 logger.info(f"Results: {results}")
1124 if not results.get("success", True):
1125 raise HTTPException(status_code=400, detail=results.get("message", "Failed to retrieve Docker Compose"))
1126
1127 return RetrieveDockerCompose(
1128 docker_compose=results.get("docker_compose", ""),
1129 success=results.get("success", True),
1130 message=results.get("message", "Docker Compose retrieved successfully"),
1131 )
1132
1133
1134 @license_router.post(
1135 "/invalidate_cache",
1136 description="Manually invalidate license cache",
1137 dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
1138 )
1139 async def invalidate_cache_route(session: AsyncSession = Depends(get_db)):
1140 """
1141 Manually invalidate the license cache.
1142 Useful for testing or when you want to force a fresh license check.
1143 """
1144 license = await get_license(session)
1145 await invalidate_license_cache(session, license.license_key)
1146
1147 return {"success": True, "message": "License cache invalidated successfully"}
1148
1149
1150 def create_headers(request: ThreatIntelRegisterRequest) -> Dict[str, str]:
1151 return {
1152 "x-api-key": request.requesting_api_key,
1153 "Content-Type": "application/json",
1154 "module": "1.0",
1155 "SOCFortress_Threat_Intel": "c1f882d9-cd09-4f9c-81a6-71fe0fb53129",
1156 }
1157
1158
1159 def create_payload(request: ThreatIntelRegisterRequest) -> Dict[str, str]:
1160 return {
1161 "customer_name": request.customer_name,
1162 "requested_by": request.requested_by,
1163 }
1164
1165
1166 async def update_connector(response: ThreatIntelRegisterResponse, session: AsyncSession):
1167 """
1168 When Threat Intel is purchased, add the API key to the connector.
1169 """
1170 await ConnectorServices.update_connector_by_id(
1171 connector_id=10,
1172 connector=UpdateConnector(
1173 connector_api_key=response.api_key,
1174 connector_url="https://intel.socfortress.co/search",
1175 ),
1176 session=session,
1177 )