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.
- quantumdrive-0.1.0/LICENSE +49 -0
- quantumdrive-0.1.0/PKG-INFO +54 -0
- quantumdrive-0.1.0/core/__init__.py +0 -0
- quantumdrive-0.1.0/core/ai/__init__.py +1 -0
- quantumdrive-0.1.0/core/ai/q_assistant.py +138 -0
- quantumdrive-0.1.0/core/auth/__init__.py +0 -0
- quantumdrive-0.1.0/core/auth/microsoft_365_provider.py +155 -0
- quantumdrive-0.1.0/core/auth/microsoft_sso.py +259 -0
- quantumdrive-0.1.0/core/aws/__init__.py +1 -0
- quantumdrive-0.1.0/core/aws/secrets_manager.py +123 -0
- quantumdrive-0.1.0/core/ingest/__init__.py +1 -0
- quantumdrive-0.1.0/core/ingest/solicitation_ingest_pipeline.py +247 -0
- quantumdrive-0.1.0/core/user/__init__.py +0 -0
- quantumdrive-0.1.0/core/user/profile.py +74 -0
- quantumdrive-0.1.0/core/utils/__init__.py +1 -0
- quantumdrive-0.1.0/core/utils/app_config.py +122 -0
- quantumdrive-0.1.0/core/utils/logger.py +14 -0
- quantumdrive-0.1.0/pyproject.toml +80 -0
- quantumdrive-0.1.0/webapp/__init__.py +8 -0
- quantumdrive-0.1.0/webapp/app.py +406 -0
- quantumdrive-0.1.0/webapp/static/css/styles.css +182 -0
- quantumdrive-0.1.0/webapp/static/images/favicon.ico +0 -0
- quantumdrive-0.1.0/webapp/static/images/qdrive_logo.png +0 -0
- quantumdrive-0.1.0/webapp/static/images/qdrive_logo2.png +0 -0
- quantumdrive-0.1.0/webapp/static/js/app.js +33 -0
- quantumdrive-0.1.0/webapp/templates/analytics.html +34 -0
- quantumdrive-0.1.0/webapp/templates/base.html +70 -0
- quantumdrive-0.1.0/webapp/templates/dashboard.html +22 -0
- quantumdrive-0.1.0/webapp/templates/directory.html +78 -0
- quantumdrive-0.1.0/webapp/templates/privacy.html +88 -0
- quantumdrive-0.1.0/webapp/templates/reports.html +29 -0
- quantumdrive-0.1.0/webapp/templates/system_details.html +21 -0
- quantumdrive-0.1.0/webapp/templates/task_execution.html +29 -0
- 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
|
+
|