1616Required 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
2222Optional 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'
3738
3839import logging
3940from contextlib import contextmanager
41+ from types import SimpleNamespace
4042
4143from django .conf import settings
4244
4345from enterprise_data import cache
44- from enterprise_data .clients .snowflake import get_snowflake_connection as _get_snowflake_connection
4546
4647LOGGER = logging .getLogger (__name__ )
4748
5253# Batch size for Snowflake queries to avoid unbounded SQL and parameter limits.
5354SNOWFLAKE_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
0 commit comments