Skip to content

Commit e34a73c

Browse files
SNOW-2127911 Add unsafe_ignore_permission_check flag which turns off permission check validation on unix systems (#2430)
1 parent 22b34a2 commit e34a73c

File tree

8 files changed

+175
-37
lines changed

8 files changed

+175
-37
lines changed

DESCRIPTION.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ Source code is also available at: https://github.com/snowflakedb/snowflake-conne
1414
- Added in-band HTTP exception telemetry.
1515
- Fixed a bug where timezoned timestamps fetched as pandas.DataFrame or pyarrow.Table would overflow for the sake of unnecessary precision. In the case where an overflow cannot be prevented a clear error will be raised now.
1616
- Fix OAuth authenticator values.
17+
- Add `unsafe_skip_file_permissions_check` flag to skip file permissions check on cache and config.
1718

1819
- v3.16.0(July 04,2025)
1920
- Bumped numpy dependency from <2.1.0 to <=2.2.4.

src/snowflake/connector/auth/_auth.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -542,7 +542,9 @@ def _delete_temporary_credential(
542542

543543
def get_token_cache(self) -> TokenCache:
544544
if self._token_cache is None:
545-
self._token_cache = TokenCache.make()
545+
self._token_cache = TokenCache.make(
546+
skip_file_permissions_check=self._rest._connection._unsafe_skip_file_permissions_check
547+
)
546548
return self._token_cache
547549

548550

src/snowflake/connector/config_manager.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ def _sub_parsers(self) -> dict[str, ConfigManager]:
295295

296296
def read_config(
297297
self,
298+
skip_file_permissions_check: bool = False,
298299
) -> None:
299300
"""Read and cache config file contents.
300301
@@ -310,8 +311,11 @@ def read_config(
310311
read_config_file = tomlkit.TOMLDocument()
311312

312313
# Read in all of the config slices
314+
config_slice_options = ConfigSliceOptions(
315+
check_permissions=not skip_file_permissions_check
316+
)
313317
for filep, sliceoptions, section in itertools.chain(
314-
((self.file_path, ConfigSliceOptions(), None),),
318+
((self.file_path, config_slice_options, None),),
315319
self._slices,
316320
):
317321
if sliceoptions.only_in_slice:

src/snowflake/connector/connection.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,10 @@ def _get_private_bytes_from_file(
377377
False,
378378
bool,
379379
), # SNOW-1944208: add unsafe write flag
380+
"unsafe_skip_file_permissions_check": (
381+
False,
382+
bool,
383+
), # SNOW-2127911: add flag to opt-out file permissions check
380384
_VARIABLE_NAME_SERVER_DOP_CAP_FOR_FILE_TRANSFER: (
381385
_DEFAULT_VALUE_SERVER_DOP_CAP_FOR_FILE_TRANSFER, # default value
382386
int, # type
@@ -489,8 +493,13 @@ def __init__(
489493
If overwriting values from the default connection is desirable, supply
490494
the name explicitly.
491495
"""
496+
self._unsafe_skip_file_permissions_check = kwargs.get(
497+
"unsafe_skip_file_permissions_check", False
498+
)
492499
# initiate easy logging during every connection
493-
easy_logging = EasyLoggingConfigPython()
500+
easy_logging = EasyLoggingConfigPython(
501+
skip_config_file_permissions_check=self._unsafe_skip_file_permissions_check
502+
)
494503
easy_logging.create_log()
495504
self._lock_sequence_counter = Lock()
496505
self.sequence_counter = 0
@@ -549,7 +558,9 @@ def __init__(
549558
for i, s in enumerate(CONFIG_MANAGER._slices):
550559
if s.section == "connections":
551560
CONFIG_MANAGER._slices[i] = s._replace(path=connections_file_path)
552-
CONFIG_MANAGER.read_config()
561+
CONFIG_MANAGER.read_config(
562+
skip_file_permissions_check=self._unsafe_skip_file_permissions_check
563+
)
553564
break
554565
if connection_name is not None:
555566
connections = CONFIG_MANAGER["connections"]

src/snowflake/connector/log_configuration.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,16 @@
1212

1313

1414
class EasyLoggingConfigPython:
15-
def __init__(self):
15+
def __init__(self, skip_config_file_permissions_check: bool = False):
1616
self.path: str | None = None
1717
self.level: str | None = None
1818
self.save_logs: bool = False
19-
self.parse_config_file()
19+
self.parse_config_file(skip_config_file_permissions_check)
2020

21-
def parse_config_file(self):
22-
CONFIG_MANAGER.read_config()
21+
def parse_config_file(self, skip_config_file_permissions_check: bool = False):
22+
CONFIG_MANAGER.read_config(
23+
skip_file_permissions_check=skip_config_file_permissions_check
24+
)
2325
data = CONFIG_MANAGER.conf_file_cache
2426
if log := data.get("log"):
2527
self.save_logs = log.get("save_logs", False)

src/snowflake/connector/token_cache.py

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def _warn(warning: str) -> None:
5858

5959
class TokenCache(ABC):
6060
@staticmethod
61-
def make() -> TokenCache:
61+
def make(skip_file_permissions_check: bool = False) -> TokenCache:
6262
if IS_MACOS or IS_WINDOWS:
6363
if not installed_keyring:
6464
_warn(
@@ -71,7 +71,7 @@ def make() -> TokenCache:
7171
return KeyringTokenCache()
7272

7373
if IS_LINUX:
74-
cache = FileTokenCache.make()
74+
cache = FileTokenCache.make(skip_file_permissions_check)
7575
if cache:
7676
return cache
7777
else:
@@ -128,23 +128,30 @@ class _CacheFileWriteError(_FileTokenCacheError):
128128

129129
class FileTokenCache(TokenCache):
130130
@staticmethod
131-
def make() -> FileTokenCache | None:
132-
cache_dir = FileTokenCache.find_cache_dir()
131+
def make(skip_file_permissions_check: bool = False) -> FileTokenCache | None:
132+
cache_dir = FileTokenCache.find_cache_dir(skip_file_permissions_check)
133133
if cache_dir is None:
134134
logging.getLogger(__name__).debug(
135135
"Failed to find suitable cache directory for token cache. File based token cache initialization failed."
136136
)
137137
return None
138138
else:
139-
return FileTokenCache(cache_dir)
139+
return FileTokenCache(
140+
cache_dir, skip_file_permissions_check=skip_file_permissions_check
141+
)
140142

141-
def __init__(self, cache_dir: Path) -> None:
143+
def __init__(
144+
self, cache_dir: Path, skip_file_permissions_check: bool = False
145+
) -> None:
142146
self.logger = logging.getLogger(__name__)
143147
self.cache_dir: Path = cache_dir
148+
self._skip_file_permissions_check = skip_file_permissions_check
144149

145150
def store(self, key: TokenKey, token: str) -> None:
146151
try:
147-
FileTokenCache.validate_cache_dir(self.cache_dir)
152+
FileTokenCache.validate_cache_dir(
153+
self.cache_dir, self._skip_file_permissions_check
154+
)
148155
with FileLock(self.lock_file()):
149156
cache = self._read_cache_file()
150157
cache["tokens"][key.hash_key()] = token
@@ -158,7 +165,9 @@ def store(self, key: TokenKey, token: str) -> None:
158165

159166
def retrieve(self, key: TokenKey) -> str | None:
160167
try:
161-
FileTokenCache.validate_cache_dir(self.cache_dir)
168+
FileTokenCache.validate_cache_dir(
169+
self.cache_dir, self._skip_file_permissions_check
170+
)
162171
with FileLock(self.lock_file()):
163172
cache = self._read_cache_file()
164173
token = cache["tokens"].get(key.hash_key(), None)
@@ -178,7 +187,9 @@ def retrieve(self, key: TokenKey) -> str | None:
178187

179188
def remove(self, key: TokenKey) -> None:
180189
try:
181-
FileTokenCache.validate_cache_dir(self.cache_dir)
190+
FileTokenCache.validate_cache_dir(
191+
self.cache_dir, self._skip_file_permissions_check
192+
)
182193
with FileLock(self.lock_file()):
183194
cache = self._read_cache_file()
184195
cache["tokens"].pop(key.hash_key(), None)
@@ -201,7 +212,8 @@ def _read_cache_file(self) -> dict[str, dict[str, Any]]:
201212
json_data = {"tokens": {}}
202213
try:
203214
fd = os.open(self.cache_file(), os.O_RDONLY)
204-
self._ensure_permissions(fd, 0o600)
215+
if not self._skip_file_permissions_check:
216+
self._ensure_permissions(fd, 0o600)
205217
size = os.lseek(fd, 0, os.SEEK_END)
206218
os.lseek(fd, 0, os.SEEK_SET)
207219
data = os.read(fd, size)
@@ -234,7 +246,8 @@ def _write_cache_file(self, json_data: dict):
234246
fd = os.open(
235247
self.cache_file(), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600
236248
)
237-
self._ensure_permissions(fd, 0o600)
249+
if not self._skip_file_permissions_check:
250+
self._ensure_permissions(fd, 0o600)
238251
os.write(fd, codecs.encode(json.dumps(json_data), "utf-8"))
239252
return json_data
240253
except OSError as e:
@@ -244,7 +257,7 @@ def _write_cache_file(self, json_data: dict):
244257
os.close(fd)
245258

246259
@staticmethod
247-
def find_cache_dir() -> Path | None:
260+
def find_cache_dir(skip_file_permissions_check: bool = False) -> Path | None:
248261
def lookup_env_dir(env_var: str, subpath_segments: list[str]) -> Path | None:
249262
env_val = os.getenv(env_var)
250263
if env_val is None:
@@ -276,10 +289,12 @@ def lookup_env_dir(env_var: str, subpath_segments: list[str]) -> Path | None:
276289
directory.mkdir(exist_ok=True, mode=0o700)
277290

278291
try:
279-
FileTokenCache.validate_cache_dir(directory)
292+
FileTokenCache.validate_cache_dir(
293+
directory, skip_file_permissions_check
294+
)
280295
return directory
281296
except _FileTokenCacheError as e:
282-
logger.debug(
297+
_warn(
283298
f"Cache directory validation failed for {str(directory)} due to error '{e}'. Skipping it in cache directory lookup."
284299
)
285300
return None
@@ -298,7 +313,9 @@ def lookup_env_dir(env_var: str, subpath_segments: list[str]) -> Path | None:
298313
return None
299314

300315
@staticmethod
301-
def validate_cache_dir(cache_dir: Path | None) -> None:
316+
def validate_cache_dir(
317+
cache_dir: Path | None, skip_file_permissions_check: bool = False
318+
) -> None:
302319
try:
303320
statinfo = cache_dir.stat()
304321

@@ -308,17 +325,18 @@ def validate_cache_dir(cache_dir: Path | None) -> None:
308325
if not stat.S_ISDIR(statinfo.st_mode):
309326
raise _InvalidCacheDirError(f"Cache dir {cache_dir} is not a directory")
310327

311-
permissions = stat.S_IMODE(statinfo.st_mode)
312-
if permissions != 0o700:
313-
raise _PermissionsTooWideError(
314-
f"Cache dir {cache_dir} has incorrect permissions. {permissions:o} != 0700"
315-
)
328+
if not skip_file_permissions_check:
329+
permissions = stat.S_IMODE(statinfo.st_mode)
330+
if permissions != 0o700:
331+
raise _PermissionsTooWideError(
332+
f"Cache dir {cache_dir} has incorrect permissions. {permissions:o} != 0700"
333+
)
316334

317-
euid = os.geteuid()
318-
if statinfo.st_uid != euid:
319-
raise _OwnershipError(
320-
f"Cache dir {cache_dir} has incorrect owner. {euid} != {statinfo.st_uid}"
321-
)
335+
euid = os.geteuid()
336+
if statinfo.st_uid != euid:
337+
raise _OwnershipError(
338+
f"Cache dir {cache_dir} has incorrect owner. {euid} != {statinfo.st_uid}"
339+
)
322340

323341
except FileNotFoundError:
324342
raise _CacheDirNotFoundError(

test/integ/test_connection.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import snowflake.connector
2020
from snowflake.connector import DatabaseError, OperationalError, ProgrammingError
21+
from snowflake.connector.compat import IS_WINDOWS
2122
from snowflake.connector.connection import (
2223
DEFAULT_CLIENT_PREFETCH_THREADS,
2324
SnowflakeConnection,
@@ -1639,3 +1640,56 @@ def test_file_utils_sanity_check():
16391640
conn = create_connection("default")
16401641
assert hasattr(conn._file_operation_parser, "parse_file_operation")
16411642
assert hasattr(conn._stream_downloader, "download_as_stream")
1643+
1644+
1645+
@pytest.mark.skipolddriver
1646+
@pytest.mark.skipif(IS_WINDOWS, reason="chmod doesn't work on Windows")
1647+
def test_unsafe_skip_file_permissions_check_skips_config_permissions_check(
1648+
db_parameters, tmp_path
1649+
):
1650+
"""Test that unsafe_skip_file_permissions_check flag bypasses permission checks on config files."""
1651+
# Write config file and set unsafe permissions (readable by others)
1652+
tmp_config_file = tmp_path / "config.toml"
1653+
tmp_config_file.write_text("[log]\n" "save_logs = false\n" 'level = "INFO"\n')
1654+
tmp_config_file.chmod(stat.S_IRUSR | stat.S_IWUSR | stat.S_IROTH)
1655+
1656+
def _run_select_1(unsafe_skip_file_permissions_check: bool):
1657+
warnings.simplefilter("always")
1658+
# Connect directly with db_parameters, using custom config file path
1659+
# We need to modify CONFIG_MANAGER to point to our test file
1660+
from snowflake.connector.config_manager import CONFIG_MANAGER
1661+
1662+
original_file_path = CONFIG_MANAGER.file_path
1663+
try:
1664+
CONFIG_MANAGER.file_path = tmp_config_file
1665+
CONFIG_MANAGER.conf_file_cache = None # Force re-read
1666+
with snowflake.connector.connect(
1667+
**db_parameters,
1668+
unsafe_skip_file_permissions_check=unsafe_skip_file_permissions_check,
1669+
) as conn:
1670+
with conn.cursor() as cur:
1671+
result = cur.execute("select 1;").fetchall()
1672+
assert result == [(1,)]
1673+
finally:
1674+
CONFIG_MANAGER.file_path = original_file_path
1675+
CONFIG_MANAGER.conf_file_cache = None
1676+
1677+
# Without the flag - should trigger permission warnings
1678+
with warnings.catch_warnings(record=True) as warning_list:
1679+
_run_select_1(unsafe_skip_file_permissions_check=False)
1680+
permission_warnings = [
1681+
w for w in warning_list if "Bad owner or permissions" in str(w.message)
1682+
]
1683+
assert (
1684+
len(permission_warnings) > 0
1685+
), "Expected permission warning when unsafe_skip_file_permissions_check=False"
1686+
1687+
# With the flag - should bypass permission checks and not show warnings
1688+
with warnings.catch_warnings(record=True) as warning_list:
1689+
_run_select_1(unsafe_skip_file_permissions_check=True)
1690+
permission_warnings = [
1691+
w for w in warning_list if "Bad owner or permissions" in str(w.message)
1692+
]
1693+
assert (
1694+
len(permission_warnings) == 0
1695+
), "Expected no permission warning when unsafe_skip_file_permissions_check=True"

0 commit comments

Comments
 (0)