Skip to content

Commit c5cb709

Browse files
-ammend
1 parent 1b1f410 commit c5cb709

8 files changed

Lines changed: 330 additions & 295 deletions

File tree

enterprise_data/api/v1/views/lpr_data_source_snowflake.py

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,18 @@
1616
Required Django settings
1717
------------------------
1818
SNOWFLAKE_SERVICE_USER
19-
SNOWFLAKE_SERVICE_PRIVKEY PEM-encoded private key string (set in Django settings / config)
19+
SNOWFLAKE_SERVICE_PRIVKEY PEM-encoded private key string
2020
SNOWFLAKE_SERVICE_PASSPHRASE Passphrase to decrypt the private key
2121
2222
Optional Django settings (with defaults)
2323
-----------------------------------------
2424
SNOWFLAKE_ACCOUNT = 'edx.us-east-1'
25+
SNOWFLAKE_ROLE = 'ENTERPRISE_SERVICE_USER_ROLE'
2526
LPR_SNOWFLAKE_WAREHOUSE = None
2627
LPR_SNOWFLAKE_ROLE = None
2728
LPR_SNOWFLAKE_DATABASE = 'PROD'
2829
LPR_SNOWFLAKE_SCHEMA = 'ENTERPRISE'
29-
LPR_SNOWFLAKE_INTERNAL_TABLE = 'LEARNER_PROGRESS_REPORT_EXTERNAL'
30+
LPR_SNOWFLAKE_INTERNAL_TABLE = 'LEARNER_PROGRESS_REPORT_INTERNAL'
3031
LPR_COURSE_PROGRESS_CACHE_TIMEOUT = 300 (5 min)
3132
LPR_SNOWFLAKE_LMS_DATABASE = 'PROD'
3233
LPR_SNOWFLAKE_LMS_SCHEMA = 'LMS'
@@ -37,11 +38,11 @@
3738

3839
import logging
3940
from contextlib import contextmanager
41+
from types import SimpleNamespace
4042

4143
from django.conf import settings
4244

4345
from enterprise_data import cache
44-
from enterprise_data.clients.snowflake import get_snowflake_connection as _get_snowflake_connection
4546

4647
LOGGER = logging.getLogger(__name__)
4748

@@ -52,6 +53,93 @@
5253
# Batch size for Snowflake queries to avoid unbounded SQL and parameter limits.
5354
SNOWFLAKE_QUERY_BATCH_SIZE = 500
5455

56+
try:
57+
import snowflake.connector as _snowflake_connector
58+
except ImportError: # pragma: no cover - depends on runtime extras
59+
_snowflake_connector = None
60+
LOGGER.warning(
61+
'[course_progress] snowflake-connector-python is not installed. '
62+
'course_progress will be null. Add snowflake-connector-python to requirements/base.in.'
63+
)
64+
65+
_snowflake = SimpleNamespace(connector=_snowflake_connector)
66+
67+
try:
68+
from cryptography.hazmat.backends import default_backend as _default_backend
69+
from cryptography.hazmat.primitives import serialization as _serialization
70+
except ImportError: # pragma: no cover - optional dependency
71+
_default_backend = None # type: ignore[assignment]
72+
_serialization = None # type: ignore[assignment]
73+
74+
75+
# ---------------------------------------------------------------------------
76+
# Module-level connection factory (private-key authentication)
77+
# ---------------------------------------------------------------------------
78+
79+
def _get_snowflake_connection(warehouse=None, role=None):
80+
"""
81+
Open and return a Snowflake connection using private-key authentication.
82+
83+
Credentials are read from Django settings. The returned
84+
``SnowflakeConnection`` supports the context-manager protocol::
85+
86+
with _get_snowflake_connection(warehouse='MY_WH') as conn:
87+
with conn.cursor() as cur:
88+
cur.execute(...)
89+
90+
Args:
91+
warehouse (str | None): Snowflake virtual warehouse to activate.
92+
role (str | None): Snowflake role to assume. Defaults to
93+
``settings.SNOWFLAKE_ROLE`` (fallback: ``'ENTERPRISE_SERVICE_USER_ROLE'``),
94+
then overridden by the LPR-specific ``LPR_SNOWFLAKE_ROLE`` setting
95+
when passed from ``_snowflake_cursor``.
96+
97+
Raises:
98+
ImportError: When snowflake-connector-python is not installed.
99+
ValueError: When required credential settings are absent.
100+
"""
101+
if _snowflake.connector is None:
102+
raise ImportError(
103+
'snowflake-connector-python is required for Snowflake LPR access'
104+
)
105+
106+
user = getattr(settings, 'SNOWFLAKE_SERVICE_USER', None)
107+
key_pem = getattr(settings, 'SNOWFLAKE_SERVICE_PRIVKEY', None)
108+
passphrase = getattr(settings, 'SNOWFLAKE_SERVICE_PASSPHRASE', None)
109+
account = getattr(settings, 'SNOWFLAKE_ACCOUNT', 'edx.us-east-1')
110+
default_role = getattr(settings, 'SNOWFLAKE_ROLE', 'ENTERPRISE_SERVICE_USER_ROLE')
111+
112+
if not user:
113+
LOGGER.error('Snowflake credentials missing — SNOWFLAKE_SERVICE_USER is not set.')
114+
raise ValueError('SNOWFLAKE_SERVICE_USER must be configured')
115+
if not key_pem:
116+
LOGGER.error('Snowflake credentials missing — SNOWFLAKE_SERVICE_PRIVKEY is not set.')
117+
raise ValueError('SNOWFLAKE_SERVICE_PRIVKEY must be configured')
118+
if not passphrase:
119+
raise ValueError('SNOWFLAKE_SERVICE_PASSPHRASE must be configured')
120+
121+
key_data = key_pem.encode() if isinstance(key_pem, str) else key_pem
122+
private_key = _serialization.load_pem_private_key(
123+
key_data,
124+
password=passphrase.encode() if isinstance(passphrase, str) else passphrase,
125+
backend=_default_backend(),
126+
)
127+
private_key_bytes = private_key.private_bytes(
128+
encoding=_serialization.Encoding.DER,
129+
format=_serialization.PrivateFormat.PKCS8,
130+
encryption_algorithm=_serialization.NoEncryption(),
131+
)
132+
133+
connect_kwargs = {
134+
'user': user,
135+
'account': account,
136+
'private_key': private_key_bytes,
137+
'role': role or default_role,
138+
}
139+
if warehouse:
140+
connect_kwargs['warehouse'] = warehouse
141+
return _snowflake.connector.connect(**connect_kwargs)
142+
55143

56144
# ---------------------------------------------------------------------------
57145
# Base class
@@ -74,8 +162,8 @@ def _get_setting(name, default=None):
74162
def _snowflake_cursor(self):
75163
"""Yield a Snowflake cursor, managing the connection lifecycle.
76164
77-
Uses ``_get_snowflake_connection()`` (module-level alias for the shared
78-
factory) so that the connection factory can be independently mocked in tests.
165+
Uses ``_get_snowflake_connection()`` (module-level) so that the
166+
connection factory can be independently mocked in tests.
79167
"""
80168
warehouse = self._get_setting('LPR_SNOWFLAKE_WAREHOUSE', None)
81169
role = self._get_setting('LPR_SNOWFLAKE_ROLE', None)
@@ -413,14 +501,6 @@ def get_passing_grade_map(self, courserun_keys):
413501
differs from ``SnowflakeCourseProgressSource.get_course_progress_map``,
414502
which **omits** absent keys from the result entirely.
415503
416-
Security note: the cache key for passing grades is **not** enterprise-
417-
scoped because ``LOWEST_PASSING_GRADE`` is a course-level attribute
418-
identical across all enterprises. Callers **must** only supply
419-
courserun keys that were obtained from the requesting enterprise's
420-
filtered enrollment queryset; passing attacker-influenced keys that
421-
do not belong to the enterprise is not supported and would return
422-
that course's threshold from cache or Snowflake.
423-
424504
Args:
425505
courserun_keys (list[str]): Courserun keys to look up.
426506

enterprise_data/clients/__init__.py

Whitespace-only changes.

enterprise_data/clients/snowflake.py

Lines changed: 0 additions & 111 deletions
This file was deleted.

enterprise_data/tests/clients/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)