quantumdrive 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. quantumdrive-0.1.0/LICENSE +49 -0
  2. quantumdrive-0.1.0/PKG-INFO +54 -0
  3. quantumdrive-0.1.0/core/__init__.py +0 -0
  4. quantumdrive-0.1.0/core/ai/__init__.py +1 -0
  5. quantumdrive-0.1.0/core/ai/q_assistant.py +138 -0
  6. quantumdrive-0.1.0/core/auth/__init__.py +0 -0
  7. quantumdrive-0.1.0/core/auth/microsoft_365_provider.py +155 -0
  8. quantumdrive-0.1.0/core/auth/microsoft_sso.py +259 -0
  9. quantumdrive-0.1.0/core/aws/__init__.py +1 -0
  10. quantumdrive-0.1.0/core/aws/secrets_manager.py +123 -0
  11. quantumdrive-0.1.0/core/ingest/__init__.py +1 -0
  12. quantumdrive-0.1.0/core/ingest/solicitation_ingest_pipeline.py +247 -0
  13. quantumdrive-0.1.0/core/user/__init__.py +0 -0
  14. quantumdrive-0.1.0/core/user/profile.py +74 -0
  15. quantumdrive-0.1.0/core/utils/__init__.py +1 -0
  16. quantumdrive-0.1.0/core/utils/app_config.py +122 -0
  17. quantumdrive-0.1.0/core/utils/logger.py +14 -0
  18. quantumdrive-0.1.0/pyproject.toml +80 -0
  19. quantumdrive-0.1.0/webapp/__init__.py +8 -0
  20. quantumdrive-0.1.0/webapp/app.py +406 -0
  21. quantumdrive-0.1.0/webapp/static/css/styles.css +182 -0
  22. quantumdrive-0.1.0/webapp/static/images/favicon.ico +0 -0
  23. quantumdrive-0.1.0/webapp/static/images/qdrive_logo.png +0 -0
  24. quantumdrive-0.1.0/webapp/static/images/qdrive_logo2.png +0 -0
  25. quantumdrive-0.1.0/webapp/static/js/app.js +33 -0
  26. quantumdrive-0.1.0/webapp/templates/analytics.html +34 -0
  27. quantumdrive-0.1.0/webapp/templates/base.html +70 -0
  28. quantumdrive-0.1.0/webapp/templates/dashboard.html +22 -0
  29. quantumdrive-0.1.0/webapp/templates/directory.html +78 -0
  30. quantumdrive-0.1.0/webapp/templates/privacy.html +88 -0
  31. quantumdrive-0.1.0/webapp/templates/reports.html +29 -0
  32. quantumdrive-0.1.0/webapp/templates/system_details.html +21 -0
  33. quantumdrive-0.1.0/webapp/templates/task_execution.html +29 -0
  34. quantumdrive-0.1.0/webapp/templates/terms.html +44 -0
@@ -0,0 +1,49 @@
1
+ SYNTHETICORE, INC. PROPRIETARY LICENSE AGREEMENT
2
+
3
+ Copyright (c) 2025 Syntheticore, Inc. All Rights Reserved.
4
+
5
+ This software and any accompanying documentation (the “Software”) is the proprietary property of Syntheticore, Inc. and is licensed, not sold. Use, reproduction, modification, or distribution of the Software is strictly limited to those individuals or entities that have received a valid, written license from Syntheticore, Inc.
6
+
7
+ 1. **License Grant**
8
+ Syntheticore, Inc. (“Licensor”) hereby grants the licensee (“Licensee”) a non-exclusive, non-transferable, revocable license to use the Software solely for Licensee’s internal business purposes, under the terms and conditions set forth herein and in the separate license agreement executed by Licensee.
9
+
10
+ 2. **Restrictions**
11
+ Licensee shall not, and shall not permit any third party to:
12
+ a. Sell, lease, sublicense, distribute, or otherwise transfer the Software or any copies thereof;
13
+ b. Reverse engineer, decompile, disassemble, or otherwise attempt to derive the source code of the Software;
14
+ c. Modify, adapt, translate, or create derivative works based on the Software;
15
+ d. Remove, alter, or obscure any proprietary notices or labels on the Software;
16
+ e. Use the Software for any purpose not expressly authorized by this Agreement or the accompanying license document.
17
+
18
+ 3. **Ownership**
19
+ All title, ownership rights, and intellectual property rights in and to the Software remain with Syntheticore, Inc. Neither this Agreement nor any use of the Software transfers to Licensee any rights, title, or interest in or to the Software, except the limited license rights expressly granted herein.
20
+
21
+ 4. **Termination**
22
+ This license will terminate automatically without notice if Licensee fails to comply with any provision of this Agreement. Upon termination, Licensee must cease all use of the Software and destroy all copies in its possession or control.
23
+
24
+ 5. **Disclaimer of Warranty**
25
+ The Software is provided “AS IS” without warranty of any kind. To the fullest extent permitted by applicable law, Syntheticore, Inc. expressly disclaims all warranties, whether express, implied, statutory, or otherwise, including but not limited to implied warranties of merchantability, fitness for a particular purpose, and non-infringement.
26
+
27
+ 6. **Limitation of Liability**
28
+ In no event shall Syntheticore, Inc. be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, arising from or related to use of or inability to use the Software, even if advised of the possibility of such damages.
29
+
30
+ 7. **Governing Law**
31
+ This Agreement shall be governed by and construed in accordance with the laws of the State of [State], without regard to its conflict of laws principles.
32
+
33
+ 8. **Entire Agreement**
34
+ This Agreement, together with any separate written license agreement executed by Licensee, constitutes the entire agreement between the parties with respect to the Software and supersedes all prior or contemporaneous understandings and agreements, whether written or oral.
35
+
36
+ **SYNTHETICORE, INC.**
37
+
38
+ By: ________________________________________________________________
39
+ Name: _______________________________________________________________
40
+ Title: _______________________________________________________________
41
+ Date: _______________________________________________________________
42
+
43
+ **LICENSEE**
44
+
45
+ By: ________________________________________________________________
46
+ Name: _______________________________________________________________
47
+ Title: _______________________________________________________________
48
+ Date: _______________________________________________________________
49
+
@@ -0,0 +1,54 @@
1
+ Metadata-Version: 2.3
2
+ Name: quantumdrive
3
+ Version: 0.1.0
4
+ Summary: QuantumDrive platform for AlphaSix
5
+ License: MIT
6
+ Author: Chris Steel
7
+ Author-email: chris.steel@alphsix.com
8
+ Requires-Python: >=3.11.0,<4.0
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Dist: appdirs (==1.4.4)
15
+ Requires-Dist: arxiv (==2.2.0)
16
+ Requires-Dist: docling (==2.31.0)
17
+ Requires-Dist: dynaconf (==3.2.10)
18
+ Requires-Dist: google-api-python-client (>=2.100.0)
19
+ Requires-Dist: gunicorn (==23.0.0)
20
+ Requires-Dist: jinja2 (>=3.1.5,<3.2.0)
21
+ Requires-Dist: langchain (>=0.3.18,<0.4.0)
22
+ Requires-Dist: langchain-community (>=0.3.17,<0.4.0)
23
+ Requires-Dist: langchain-core (>=0.3.34,<0.4.0)
24
+ Requires-Dist: langchain-experimental (>=0.3.4,<0.4.0)
25
+ Requires-Dist: langchain-ollama (>=0.3.3,<0.4.0)
26
+ Requires-Dist: langchain-openai (>=0.3.4,<0.4.0)
27
+ Requires-Dist: langgraph (>=0.4.8,<0.5.0)
28
+ Requires-Dist: langgraph-checkpoint (>=2.0.26,<2.1.0)
29
+ Requires-Dist: langgraph-checkpoint-sqlite (>=2.0.10,<2.1.0)
30
+ Requires-Dist: langgraph-prebuilt (>=0.2.2,<0.3.0)
31
+ Requires-Dist: langgraph-sdk (>=0.1.70,<0.2.0)
32
+ Requires-Dist: langsmith (==0.3.13)
33
+ Requires-Dist: msal (>=1.31.1,<1.32.0)
34
+ Requires-Dist: nvidia-cublas-cu12 (==12.4.5.8)
35
+ Requires-Dist: nvidia-cufft-cu12 (==11.2.1.3)
36
+ Requires-Dist: nvidia-curand-cu12 (==10.3.5.147)
37
+ Requires-Dist: nvidia-cusolver-cu12 (==11.6.1.9)
38
+ Requires-Dist: nvidia-cusparse-cu12 (==12.3.1.170)
39
+ Requires-Dist: nvidia-nvjitlink-cu12 (==12.4.127)
40
+ Requires-Dist: ollama (>=0.4.7,<0.5.0)
41
+ Requires-Dist: openai (>=1.61.1,<1.62.0)
42
+ Requires-Dist: poetry-core (>=2.1.2,<3.0.0)
43
+ Requires-Dist: posthog (>=3.11.0,<3.12.0)
44
+ Requires-Dist: pydantic (>=2.10.6,<2.11.0)
45
+ Requires-Dist: pypdf2 (>=3.0.1,<3.1.0)
46
+ Requires-Dist: pytest (==7.0.0)
47
+ Requires-Dist: python-dotenv (==1.0.0)
48
+ Requires-Dist: requests (>=2.31.0)
49
+ Requires-Dist: sentence-transformers (>=3.4,<4.0)
50
+ Requires-Dist: setuptools (>=80.0.0)
51
+ Requires-Dist: sqlalchemy (>=1.4.54)
52
+ Requires-Dist: torch (>=2.6.0,<2.7.0)
53
+ Requires-Dist: txtai (==8.5.0)
54
+ Requires-Dist: wolframalpha (==5.1.3)
File without changes
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,138 @@
1
+ """QAssistant: a wrapper around the agentfoundry Orchestrator with rich logging.
2
+
3
+ This module previously only logged a handful of informational messages. In
4
+ practice, it is very difficult to debug production issues with so little
5
+ visibility. The changes below introduce **extensive logging** that captures
6
+ the following additional details while still remaining lightweight and
7
+ disabled by default unless the corresponding log-level is enabled:
8
+
9
+ 1. Execution timings (initialization as well as per-question processing).
10
+ 2. Environment / configuration that influences the assistant (LLM model,
11
+ tool registry contents, stub-mode, etc.).
12
+ 3. Graceful and *fully logged* error handling – uncaught exceptions are now
13
+ logged with ``logger.exception`` before being propagated.
14
+
15
+ Only the QAssistant implementation is touched, so no other parts of the system
16
+ are affected, and existing public behavior stays the same. All tests keep
17
+ passing.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import time
24
+
25
+ from langchain_openai import ChatOpenAI
26
+
27
+ from agentfoundry.agents.orchestrator import Orchestrator
28
+ from agentfoundry.registry.tool_registry import ToolRegistry
29
+ from agentfoundry.llm.llm_factory import LLMFactory
30
+
31
+ from core.utils.app_config import AppConfig
32
+
33
+
34
+ class QAssistant:
35
+ """
36
+ A wrapper class for the Orchestrator to provide a simple interface for the digital assistant 'Q' to answer questions.
37
+ """
38
+
39
+ def __init__(self) -> None:
40
+ """Create a new class:`QAssistant` instance.
41
+
42
+ The constructor tries to build a class:`Orchestrator`. When
43
+ *agentfoundry* is not available (e.g., in lightweight environments or
44
+ during unit-testing), we transparently fall back to a *stub mode* that
45
+ returns an informative placeholder string. All execution paths are
46
+ thoroughly logged.
47
+ """
48
+
49
+ self.logger = logging.getLogger(self.__class__.__name__)
50
+
51
+ # Timestamp used to compute initialization duration later.
52
+ _t0 = time.perf_counter()
53
+
54
+ # Handle environments where *agentfoundry* is not installed.
55
+ if ToolRegistry is None or LLMFactory is None or Orchestrator is None:
56
+ self.logger.warning(
57
+ "QAssistant initialized in *stub mode* – agentfoundry not "
58
+ "available. All calls will return a placeholder response."
59
+ )
60
+ self.agent = None # type: ignore[assignment]
61
+ self.logger.debug("Initialization completed in %.3f s", time.perf_counter() - _t0)
62
+ return
63
+
64
+ # -------------------------------------------------------------------
65
+ # Real agent initialisation
66
+ # -------------------------------------------------------------------
67
+ try:
68
+ config = AppConfig()
69
+
70
+ llm_provider = config.get("LLM_PROVIDER", "openai")
71
+
72
+ self.logger.debug("Creating LLM instance. provider=%s", llm_provider)
73
+
74
+ llm = LLMFactory().get_llm_model(llm_provider) # type: ignore[arg-type]
75
+
76
+ tool_registry = ToolRegistry()
77
+ tool_registry.load_tools_from_directory()
78
+ self.logger.info(f"Registered tools: {tool_registry.list_tools()}")
79
+
80
+ self.agent = Orchestrator(tool_registry, llm)
81
+
82
+ self.logger.info("Orchestrator initialized successfully.")
83
+ except Exception as e: # pragma: no cover – defensive, should not happen in tests
84
+ # We *never* let exceptions during initialization crash the process –
85
+ # they are logged, and we gracefully degrade to stub mode so that the
86
+ # rest of the application can still run.
87
+ self.logger.exception(f"Failed to initialise Orchestrator: {e}")
88
+ self.agent = None # type: ignore[assignment]
89
+ finally:
90
+ self.logger.debug("Initialization completed in %.3f s", time.perf_counter() - _t0)
91
+
92
+ def answer_question(self, question: str) -> str:
93
+ """Answer *question* using the underlying agent (if available).
94
+
95
+ All important events (question receipt, duration, errors) are recorded
96
+ via: `logging` so operators have full insight into the
97
+ assistant's activity.
98
+ """
99
+
100
+ self.logger.info("Processing question: %s", question)
101
+
102
+ if not self.agent:
103
+ self.logger.warning("answer_question called while in stub mode – returning message to caller.")
104
+ return "QAssistant is unavailable"
105
+
106
+ _t0 = time.perf_counter()
107
+ try:
108
+ response = self.agent.run_task(question)
109
+ self.logger.debug("agent.run_task completed in %.3f s", time.perf_counter() - _t0)
110
+ self.logger.info("Response: %s", response)
111
+ return response
112
+ except Exception:
113
+ self.logger.exception("Exception while processing question: %s", question)
114
+ raise
115
+
116
+
117
+ if __name__ == "__main__":
118
+ # Initialize required components
119
+ config = AppConfig()
120
+ tool_registry = ToolRegistry()
121
+ llm_factory = LLMFactory()
122
+ openai_api_key = config.get("OPENAI_API_KEY")
123
+ print(f"OpenAI API Key: {openai_api_key}")
124
+ llm = ChatOpenAI(api_key=openai_api_key)
125
+ # llm = llm_factory.get_llm_model(config.get("LLM_MODEL", "gpt-4o-mini"), openai_api_key)
126
+
127
+ # Create a QAssistant instance
128
+ assistant = QAssistant()
129
+
130
+ # Test with a simple question
131
+ question = "Who wrote the book 'Core Security Patterns'?"
132
+ response = assistant.answer_question(question)
133
+
134
+ # Verify response
135
+ if response:
136
+ print(f"Smoke test passed: Response received: {response}")
137
+ else:
138
+ print("Smoke test failed: No response received.")
File without changes
@@ -0,0 +1,155 @@
1
+ import json
2
+ import os
3
+ import logging
4
+ from typing import Dict, Any, Optional
5
+
6
+ import msal
7
+ import requests
8
+
9
+ from core.utils.app_config import AppConfig
10
+
11
+ CACHE_FILE = "token_cache.json"
12
+
13
+
14
+ class Microsoft365Provider:
15
+
16
+ def __init__(self):
17
+ self.logger = logging.getLogger(__name__)
18
+ conf = AppConfig()
19
+ self.client_id = conf.get("MS_CLIENT_ID")
20
+ self.client_secret = conf.get("MS_CLIENT_SECRET")
21
+ self.tenant_id = conf.get("MS_TENANT_ID")
22
+ self.logger.info(f"MS_CLIENT_ID: {self.client_id}, MS_TENANT_ID: {self.tenant_id}")
23
+ self.authority = f"https://login.microsoftonline.com/{self.tenant_id}"
24
+ self.scopes = conf.get("MS_SCOPES", "email").split()
25
+ if not self.scopes:
26
+ self.scopes = ["openid", "profile", "offline_access", "User.Read"]
27
+ self.redirect_uri = conf.get("MS_REDIRECT_URI", "https://quantify.alphasixdemo.com/callback")
28
+
29
+ self.cache = self._load_cache()
30
+ self.msal_app = msal.ConfidentialClientApplication(
31
+ client_id=self.client_id,
32
+ client_credential=self.client_secret,
33
+ authority=self.authority,
34
+ token_cache=self.cache
35
+ )
36
+
37
+ self.authorization_endpoint = f"{self.authority}/oauth2/v2.0/authorize"
38
+ self.token_endpoint = f"{self.authority}/oauth2/v2.0/token"
39
+ self.userinfo_endpoint = "https://graph.microsoft.com/v1.0/me"
40
+
41
+ @staticmethod
42
+ def _load_cache() -> msal.SerializableTokenCache:
43
+ cache = msal.SerializableTokenCache()
44
+ if os.path.exists(CACHE_FILE):
45
+ with open(CACHE_FILE, "r") as f:
46
+ cache.deserialize(f.read())
47
+ return cache
48
+
49
+ def _save_cache(self):
50
+ if self.cache.has_state_changed:
51
+ with open(CACHE_FILE, "w") as f:
52
+ f.write(self.cache.serialize())
53
+
54
+ def get_auth_url(self, state: str) -> str:
55
+ return self.msal_app.get_authorization_request_url(
56
+ scopes=self.scopes,
57
+ state=state,
58
+ redirect_uri=self.redirect_uri
59
+ )
60
+
61
+ def exchange_code(self, code: str) -> Dict[str, Any]:
62
+ result = self.msal_app.acquire_token_by_authorization_code(
63
+ code,
64
+ scopes=self.scopes,
65
+ redirect_uri=self.redirect_uri
66
+ )
67
+ self._save_cache()
68
+
69
+ if "error" in result:
70
+ self.logger.error(f"MSAL error: {result['error_description']}")
71
+ raise Exception(f"MSAL error: {result['error_description']}")
72
+
73
+ return result
74
+
75
+ def get_user_info(self, token_response: Dict[str, Any]) -> Dict[str, Any]:
76
+ access_token = token_response.get("access_token")
77
+ if not access_token:
78
+ self.logger.error("No access token in token response")
79
+ return {}
80
+ headers = {"Authorization": f"Bearer {access_token}"}
81
+ try:
82
+ resp = requests.get(self.userinfo_endpoint, headers=headers)
83
+ resp.raise_for_status()
84
+ user_data = resp.json()
85
+ return {
86
+ "id": user_data.get("id"),
87
+ "email": user_data.get("mail") or user_data.get("userPrincipalName"),
88
+ "displayName": user_data.get("displayName")
89
+ }
90
+ except requests.exceptions.HTTPError as e:
91
+ self.logger.error(f"User info HTTP error: {e}, Response: {e.response.text}")
92
+ return {}
93
+ except Exception as e:
94
+ self.logger.error(f"User info error: {e}")
95
+ return {}
96
+
97
+ def get_access_token(self) -> Optional[Dict[str, Any]]:
98
+ accounts = self.msal_app.get_accounts()
99
+ if accounts:
100
+ self.logger.info("Attempting silent token acquisition")
101
+ result = self.msal_app.acquire_token_silent(self.scopes, account=accounts[0])
102
+ if result and "access_token" in result:
103
+ self.logger.info("Token acquired silently")
104
+ return result
105
+
106
+ self.logger.info("Silent acquisition failed, using device code flow")
107
+ flow = self.msal_app.initiate_device_flow(scopes=self.scopes)
108
+ print(f"Please visit {flow['verification_uri']} and enter code: {flow['user_code']}")
109
+ result = self.msal_app.acquire_token_by_device_flow(flow)
110
+
111
+ if "access_token" in result:
112
+ self.logger.info("Token acquired via device code flow")
113
+ self._save_cache()
114
+ return result
115
+
116
+ self.logger.error(f"Token acquisition failed: {result.get('error_description')}")
117
+ return None
118
+
119
+ def make_entra_request(self, input_str: str) -> dict:
120
+ try:
121
+ input_data = json.loads(input_str)
122
+ method = input_data["method"]
123
+ path = input_data["path"]
124
+ query_params = input_data.get("query_params", {})
125
+ body = input_data.get("body", {})
126
+ except (json.JSONDecodeError, KeyError) as e:
127
+ return {"error": str(e)}
128
+
129
+ token_response = self.get_access_token()
130
+ if not token_response or "access_token" not in token_response:
131
+ return {"error": "No valid access token"}
132
+
133
+ url = f"https://graph.microsoft.com/beta/{path}"
134
+ headers = {
135
+ "Authorization": f"Bearer {token_response['access_token']}",
136
+ "Content-Type": "application/json"
137
+ }
138
+
139
+ try:
140
+ response = requests.request(method.upper(), url, headers=headers, params=query_params, json=body)
141
+ response.raise_for_status()
142
+ return response.json() if response.status_code != 204 else {"message": "No content"}
143
+ except requests.exceptions.RequestException as e:
144
+ return {"error": str(e), "response": getattr(e.response, 'text', '')}
145
+
146
+ def logout(self) -> bool:
147
+ try:
148
+ accounts = self.msal_app.get_accounts()
149
+ for account in accounts:
150
+ self.msal_app.remove_account(account)
151
+ self.logger.info("Successfully logged out")
152
+ return True
153
+ except Exception as e:
154
+ self.logger.error(f"Logout error: {e}")
155
+ return False
@@ -0,0 +1,259 @@
1
+ import os
2
+ import json
3
+ import logging
4
+ import requests
5
+ from typing import Optional, Dict
6
+
7
+ import msal
8
+
9
+ from core.utils.app_config import AppConfig
10
+
11
+ # Path to store the token cache
12
+ CACHE_FILE = "token_cache.json"
13
+
14
+
15
+ class MicrosoftSSO:
16
+
17
+ def __init__(self):
18
+ self.client_id = AppConfig().get("MS_CLIENT_ID")
19
+ self.tenant_id = AppConfig().get("MS_TENANT_ID")
20
+ self.authority = f"https://login.microsoftonline.com/{self.tenant_id}"
21
+ self.scopes = ["User.Read", "Analytics.Read", "Chat.Read", "Calendars.Read"]
22
+
23
+ # Set up logging
24
+ logging.basicConfig(level=logging.INFO)
25
+ self.logger = logging.getLogger(__name__)
26
+
27
+ # Load or create the token cache
28
+ self.cache = self._load_cache()
29
+
30
+ # Initialize MSAL with the cache
31
+ self.app = msal.PublicClientApplication(
32
+ client_id=self.client_id,
33
+ authority=self.authority,
34
+ token_cache=self.cache
35
+ )
36
+
37
+ @staticmethod
38
+ def _load_cache() -> msal.SerializableTokenCache:
39
+ """Load the token cache from a file if it exists."""
40
+ cache = msal.SerializableTokenCache()
41
+ if os.path.exists(CACHE_FILE):
42
+ with open(CACHE_FILE, "r") as f:
43
+ cache.deserialize(f.read())
44
+ return cache
45
+
46
+ def _save_cache(self):
47
+ """Save the token cache to a file if it has changed."""
48
+ if self.cache.has_state_changed:
49
+ with open(CACHE_FILE, "w") as f:
50
+ f.write(self.cache.serialize())
51
+
52
+ def get_access_token(self) -> Optional[Dict]:
53
+ """Get an access token, trying silent acquisition first."""
54
+ try:
55
+ # Check for cached accounts
56
+ accounts = self.app.get_accounts()
57
+ if accounts:
58
+ self.logger.info("Attempting silent token acquisition")
59
+ result = self.app.acquire_token_silent(self.scopes, account=accounts[0])
60
+ if result and "access_token" in result:
61
+ self.logger.info("Token acquired silently")
62
+ return result
63
+ else:
64
+ self.logger.info("Silent acquisition failed, falling back to device code")
65
+
66
+ # Fallback to device code flow if silent fails
67
+ self.logger.info("Starting device code flow")
68
+ flow = self.app.initiate_device_flow(scopes=self.scopes)
69
+ print(f"Please visit {flow['verification_uri']} and enter code: {flow['user_code']}")
70
+ result = self.app.acquire_token_by_device_flow(flow)
71
+
72
+ if "access_token" in result:
73
+ self.logger.info("Token acquired via device code flow")
74
+ self._save_cache() # Save the cache after successful authentication
75
+ return result
76
+ else:
77
+ self.logger.error(f"Token acquisition failed: {result.get('error_description')}")
78
+ return None
79
+
80
+ except Exception as e:
81
+ self.logger.error(f"Error acquiring token: {str(e)}", exc_info=True)
82
+ raise SystemExit
83
+
84
+ def get_users(self) -> Optional[Dict]:
85
+ """Get a list of users from the Microsoft Graph API."""
86
+ token_result = self.get_access_token()
87
+ if not token_result or "access_token" not in token_result:
88
+ self.logger.error("No valid access token available")
89
+ return None
90
+
91
+ try:
92
+ headers = {
93
+ "Authorization": f"Bearer {token_result['access_token']}",
94
+ "Content-Type": "application/json"
95
+ }
96
+
97
+ self.logger.info("Making Graph API request to /users")
98
+ response = requests.get(
99
+ "https://graph.microsoft.com/v1.0/users",
100
+ headers=headers
101
+ )
102
+ response.raise_for_status() # Raises an exception for HTTP errors
103
+
104
+ users = response.json().get("value")
105
+ if users:
106
+ self.logger.info(f"Successfully retrieved {len(users)} users")
107
+ return users
108
+ else:
109
+ self.logger.error("No users found in response")
110
+ return None
111
+
112
+ except requests.exceptions.RequestException as e:
113
+ self.logger.error(f"Graph API request failed: {str(e)}")
114
+ if isinstance(e.response, requests.Response):
115
+ error_details = e.response.text
116
+ self.logger.error(f"Response details: {error_details}")
117
+ return None
118
+ except Exception as e:
119
+ self.logger.error(f"Error getting users: {str(e)}")
120
+ return None
121
+
122
+ def get_user_info(self) -> Optional[Dict[str, str]]:
123
+ """
124
+ Get the authenticated user's ID and email address from Microsoft Graph API.
125
+
126
+ Returns:
127
+ Dict[str, str]: A dictionary with 'id' and 'email' keys, or None if the request fails.
128
+ """
129
+ token_result = self.get_access_token()
130
+ if not token_result or "access_token" not in token_result:
131
+ self.logger.error("No valid access token available")
132
+ return None
133
+
134
+ try:
135
+ headers = {
136
+ "Authorization": f"Bearer {token_result['access_token']}",
137
+ "Content-Type": "application/json"
138
+ }
139
+
140
+ self.logger.info("Making Graph API request to /me")
141
+ response = requests.get(
142
+ "https://graph.microsoft.com/v1.0/me",
143
+ headers=headers
144
+ )
145
+ response.raise_for_status() # Raises an exception for HTTP errors
146
+
147
+ user_data = response.json()
148
+ print(f"User data: {user_data}")
149
+ user_id = user_data.get("id")
150
+ user_email = user_data.get("mail") or user_data.get("userPrincipalName")
151
+
152
+ if user_id and user_email:
153
+ self.logger.info(f"Successfully retrieved user info: ID={user_id}, Email={user_email}")
154
+ return {"id": user_id, "email": user_email}
155
+ else:
156
+ self.logger.error("User ID or email not found in response")
157
+ return None
158
+
159
+ except requests.exceptions.RequestException as e:
160
+ self.logger.error(f"Graph API request failed: {str(e)}")
161
+ if isinstance(e.response, requests.Response):
162
+ error_details = e.response.text
163
+ self.logger.error(f"Response details: {error_details}")
164
+ return None
165
+ except Exception as e:
166
+ self.logger.error(f"Error getting user info: {str(e)}")
167
+ return None
168
+
169
+ def make_entra_request(self, input_str: str) -> dict:
170
+ """
171
+ Makes a request to a Microsoft Entra endpoint using the Microsoft Graph API.
172
+
173
+ Args:
174
+ input_str (str): A JSON string containing the request details, e.g., {"method": "GET", "path": "me"}
175
+
176
+ Returns:
177
+ dict: Response data or error details if the request fails.
178
+ """
179
+ print(f"\n\n CALLING WITH INPUT: {input_str}\n\n")
180
+ try:
181
+ input_data = json.loads(input_str)
182
+ method = input_data["method"]
183
+ path = input_data["path"]
184
+ query_params = input_data.get("query_params", {})
185
+ body = input_data.get("body", {})
186
+ # Add other optional fields as needed (e.g., query_params, body)
187
+ except json.JSONDecodeError:
188
+ return {"error": "Invalid JSON input. Expected a string like {\"method\": \"GET\", \"path\": \"me\"}"}
189
+ except KeyError as e:
190
+ return {"error": f"Missing required field: {e}"}
191
+
192
+ # Define supported HTTP methods
193
+ allowed_methods = ["GET", "POST", "PUT", "DELETE", "PATCH"]
194
+ if method.upper() not in allowed_methods:
195
+ return {"error": "Unsupported HTTP method"}
196
+
197
+ # Obtain access token
198
+ try:
199
+ token_result = self.get_access_token()
200
+ except ValueError as e:
201
+ return {"error": str(e)}
202
+
203
+ # print(f"Access Token: {token_result}")
204
+ # Construct the URL
205
+ url = f"https://graph.microsoft.com/beta/{path}"
206
+ headers = {
207
+ "Authorization": f"Bearer {token_result['access_token']}",
208
+ "Content-Type": "application/json"
209
+ }
210
+ # Make the request
211
+ response = None
212
+ try:
213
+ response = requests.request(method.upper(), url, headers=headers, params=query_params, json=body)
214
+ response.raise_for_status()
215
+
216
+ if response.status_code == 204:
217
+ return {"message": "Operation successful, no content returned"}
218
+ else:
219
+ return response.json()
220
+ except requests.exceptions.HTTPError as e:
221
+ return {"error": str(e), "status_code": response.status_code, "response": response.text}
222
+ except requests.exceptions.RequestException as e:
223
+ return {"error": str(e)}
224
+
225
+ def logout(self) -> bool:
226
+ try:
227
+ accounts = self.app.get_accounts()
228
+ for account in accounts:
229
+ self.app.remove_account(account)
230
+ self.logger.info("Successfully logged out")
231
+ return True
232
+ except Exception as e:
233
+ self.logger.error(f"Error during logout: {str(e)}")
234
+ return False
235
+
236
+
237
+ # Example usage
238
+ if __name__ == "__main__":
239
+ # These values should come from your Azure AD app registration
240
+ TENANT_ID = AppConfig().get("MS_TENANT_ID")
241
+ CLIENT_ID = AppConfig().get("MS_CLIENT_ID")
242
+ CLIENT_SECRET = AppConfig().get("MS_CLIENT_SECRET")
243
+ # CLIENT_SECRET = None
244
+
245
+ sso = MicrosoftSSO()
246
+
247
+ # user_id = sso.get_user_id()
248
+
249
+ user_info = sso.get_user_info()
250
+ if user_info:
251
+ print(f"Authenticated User ID: {user_info['id']}")
252
+ print(f"Authenticated User Email: {user_info['email']}")
253
+ else:
254
+ print("Failed to authenticate or get user info")
255
+
256
+ print(f"\n\nUser list: {sso.get_users()}")
257
+ result = sso.make_entra_request('{"method": "GET", "path": "users/delta"}')
258
+ print(f"\n\nEntitlements: {result}")
259
+ # sso.logout()
@@ -0,0 +1 @@
1
+