Skip to content

Commit de921bc

Browse files
CG-1920 - BUGFIX : sops file rewritten as cleartext (#49)
* WIP * working to preserve SOPS-ness of stored data. * update comments * update tests for storage providers sneaking in data. * Target Dec 10, the next comitee date. * remove comments * formatting
1 parent c2a12ee commit de921bc

File tree

9 files changed

+96
-18
lines changed

9 files changed

+96
-18
lines changed

docs/changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Changelog
22

3+
## 2.3.1 - 2025-12-10
4+
- Fix a bug where sops protected files would be rewritten without preserving
5+
their sops protection.
6+
37
## 2.3.0 - 2025-10-20
48
- Improve the user experience around old stale sessions that appear to be
59
initialized, but are actually expired. This is done by providing the new

src/planet_auth/storage_utils.py

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import subprocess
2020
import time
2121
from abc import ABC, abstractmethod
22+
from enum import Enum
2223
from typing import Optional, Dict, Any
2324

2425
from planet_auth.auth_exception import AuthException
@@ -96,9 +97,15 @@ def _default_storage_provider():
9697
class _SOPSAwareFilesystemObjectStorageProvider(ObjectStorageProvider):
9798
"""
9899
Storage provider geared around backing a single object in a single file
99-
with paths take from the root of the local file system.
100+
with paths taken from the root of the local file system.
100101
"""
101102

103+
_STORAGE_TYPE_KEY = "___SOPSAwareFilesystemObjectStorageProvider__storage_type"
104+
105+
class _StorageType(Enum):
106+
PLAINTEXT = "plaintext"
107+
SOPS = "sops"
108+
102109
def __init__(self, root: Optional[pathlib.Path] = None):
103110
if root:
104111
self._storage_root = root
@@ -116,13 +123,18 @@ def _obj_filepath(self, obj_key):
116123
return obj_path
117124

118125
@staticmethod
119-
def _is_sops_path(file_path):
126+
def _is_sops_path(file_path: pathlib.Path) -> bool:
120127
# TODO: Could be ".json.sops", or ".sops.json", depending on file
121128
# level or field level encryption, respectively. We currently
122129
# only look for and support field level encryption in json
123130
# files with a ".sops.json" suffix.
124131
return bool(file_path.suffixes == [".sops", ".json"])
125132

133+
@staticmethod
134+
def _filter_write_object(data: dict) -> dict:
135+
final_data = {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith("__"))}
136+
return final_data
137+
126138
@staticmethod
127139
def _read_json(file_path: pathlib.Path):
128140
auth_logger.debug(msg="Loading JSON data from file {}".format(file_path))
@@ -137,7 +149,7 @@ def _read_json_sops(file_path: pathlib.Path):
137149

138150
@staticmethod
139151
def _write_json(file_path: pathlib.Path, data: dict):
140-
auth_logger.debug(msg="Writing JSON data to file {}".format(file_path))
152+
auth_logger.debug(msg="Writing JSON data to cleartext file {}".format(file_path))
141153
with open(file_path, mode="w", encoding="UTF-8") as file_w:
142154
os.chmod(file_path, stat.S_IREAD | stat.S_IWRITE)
143155
_no_none_data = {key: value for key, value in data.items() if value is not None}
@@ -155,26 +167,54 @@ def _write_json_sops(file_path: pathlib.Path, data: dict):
155167
# ['sops', '-e', '--input-type', 'json', '--output-type',
156168
# 'json', '--output', file_path, '/dev/stdin'],
157169
# stdin=data_f)
158-
auth_logger.debug(msg="Writing JSON data to SOPS encrypted file {}".format(file_path))
159170
_SOPSAwareFilesystemObjectStorageProvider._write_json(file_path, data)
171+
auth_logger.debug(msg="Writing JSON data to SOPS encrypted file {}".format(file_path))
160172
subprocess.check_call(["sops", "-e", "--input-type", "json", "--output-type", "json", "-i", file_path])
161173

162174
@staticmethod
163175
def _load_file(file_path: pathlib.Path) -> dict:
164176
if _SOPSAwareFilesystemObjectStorageProvider._is_sops_path(file_path):
165177
new_data = _SOPSAwareFilesystemObjectStorageProvider._read_json_sops(file_path)
178+
new_data[_SOPSAwareFilesystemObjectStorageProvider._STORAGE_TYPE_KEY] = (
179+
_SOPSAwareFilesystemObjectStorageProvider._StorageType.SOPS.value
180+
)
166181
else:
167182
new_data = _SOPSAwareFilesystemObjectStorageProvider._read_json(file_path)
183+
new_data[_SOPSAwareFilesystemObjectStorageProvider._STORAGE_TYPE_KEY] = (
184+
_SOPSAwareFilesystemObjectStorageProvider._StorageType.PLAINTEXT.value
185+
)
168186

169187
return new_data
170188

189+
@staticmethod
190+
def _do_sops(file_path: pathlib.Path, data: dict) -> bool:
191+
if _SOPSAwareFilesystemObjectStorageProvider._is_sops_path(file_path):
192+
return True
193+
if (
194+
data
195+
and data.get(_SOPSAwareFilesystemObjectStorageProvider._STORAGE_TYPE_KEY)
196+
== _SOPSAwareFilesystemObjectStorageProvider._StorageType.SOPS.value
197+
):
198+
auth_logger.warning(msg=f"Data sourced from SOPS being written cleartext to the file {file_path}.")
199+
# Upgrading to SOPS would be great, but also problematic.
200+
# The problem is that if we are writing to SOPS we should use a
201+
# SOPS file name so that we know we should read it as a SOPS file
202+
# later. We can't change the name here because the caller would
203+
# not know what we did, and may not be able to find the object
204+
# later.
205+
206+
return False
207+
171208
@staticmethod
172209
def _save_file(file_path: pathlib.Path, data: dict):
173210
file_path.parent.mkdir(parents=True, exist_ok=True)
174-
if _SOPSAwareFilesystemObjectStorageProvider._is_sops_path(file_path):
175-
_SOPSAwareFilesystemObjectStorageProvider._write_json_sops(file_path, data)
211+
do_sops = _SOPSAwareFilesystemObjectStorageProvider._do_sops(file_path, data)
212+
write_data = _SOPSAwareFilesystemObjectStorageProvider._filter_write_object(data)
213+
214+
if do_sops:
215+
_SOPSAwareFilesystemObjectStorageProvider._write_json_sops(file_path, write_data)
176216
else:
177-
_SOPSAwareFilesystemObjectStorageProvider._write_json(file_path, data)
217+
_SOPSAwareFilesystemObjectStorageProvider._write_json(file_path, write_data)
178218

179219
def load_obj(self, key: ObjectStorageProvider_KeyType) -> dict:
180220
obj_filepath = self._obj_filepath(key)

src/planet_auth_utils/commands/cli/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ def cmd_plauth_login(
227227
)
228228
print("Login succeeded.") # Errors should throw.
229229

230-
post_login_cmd_helper(override_auth_context=override_auth_context, use_sops=sops, prompt_pre_selection=yes)
230+
post_login_cmd_helper(override_auth_context=override_auth_context, use_sops_opt=sops, prompt_pre_selection=yes)
231231

232232

233233
cmd_plauth.add_command(cmd_oauth)

src/planet_auth_utils/commands/cli/oauth_cmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ def cmd_oauth_login(
128128
extra=login_extra,
129129
)
130130
print("Login succeeded.") # Errors should throw.
131-
post_login_cmd_helper(override_auth_context=current_auth_context, use_sops=sops, prompt_pre_selection=yes)
131+
post_login_cmd_helper(override_auth_context=current_auth_context, use_sops_opt=sops, prompt_pre_selection=yes)
132132

133133

134134
@cmd_oauth.command("refresh")

src/planet_auth_utils/commands/cli/planet_legacy_auth_cmd.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def cmd_pllegacy_login(ctx, username, password, sops, yes):
8181
password=password,
8282
)
8383
print("Login succeeded.") # Errors should throw.
84-
post_login_cmd_helper(override_auth_context=current_auth_context, use_sops=sops, prompt_pre_selection=yes)
84+
post_login_cmd_helper(override_auth_context=current_auth_context, use_sops_opt=sops, prompt_pre_selection=yes)
8585

8686

8787
@cmd_pllegacy.command("print-api-key")

src/planet_auth_utils/commands/cli/util.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@
1717
import json
1818
from typing import List, Optional
1919

20+
import planet_auth.logging.auth_logger
2021
import planet_auth
2122
from planet_auth.constants import AUTH_CONFIG_FILE_SOPS, AUTH_CONFIG_FILE_PLAIN
23+
from planet_auth.storage_utils import _SOPSAwareFilesystemObjectStorageProvider
2224
from planet_auth.util import custom_json_class_dumper
2325

2426
from planet_auth_utils.builtins import Builtins
2527
from planet_auth_utils.profile import Profile
2628
from .prompts import prompt_and_change_user_default_profile_if_different
2729

30+
auth_logger = planet_auth.logging.auth_logger.getAuthLogger()
31+
2832

2933
def monkeypatch_hide_click_cmd_options(cmd, hide_options: List[str]):
3034
"""
@@ -68,7 +72,7 @@ def print_obj(obj):
6872

6973

7074
def post_login_cmd_helper(
71-
override_auth_context: planet_auth.Auth, use_sops, prompt_pre_selection: Optional[bool] = None
75+
override_auth_context: planet_auth.Auth, use_sops_opt, prompt_pre_selection: Optional[bool] = None
7276
):
7377
override_profile_name = override_auth_context.profile_name()
7478
if not override_profile_name:
@@ -88,6 +92,22 @@ def post_login_cmd_helper(
8892
# helping CLI commands, we can be more opinionated about what is to
8993
# be done.
9094

95+
# If the profile was read from sops, selected sops even if wasn't an
96+
# explict command line option.
97+
use_sops = use_sops_opt
98+
if not use_sops:
99+
# Here in the CLI we can assume that the default storage provider
100+
# _SOPSAwareFilesystemObjectStorageProvider is being used. Unlike general
101+
# library use, we do not support caller provided custom storage providers
102+
# in the CLI tools at this time.
103+
_config_data = override_auth_context.auth_client().config().data() or {}
104+
if (
105+
_config_data.get(_SOPSAwareFilesystemObjectStorageProvider._STORAGE_TYPE_KEY)
106+
== _SOPSAwareFilesystemObjectStorageProvider._StorageType.SOPS.value
107+
):
108+
auth_logger.debug(msg="Implicitly selecting SOPS based on data originating from SOPS protected storage.")
109+
use_sops = True
110+
91111
# Don't clobber built-in profiles.
92112
if not Builtins.is_builtin_profile(override_profile_name):
93113
if use_sops:

tests/test_planet_auth/unit/auth/auth_clients/oidc/test_oidc_request_authenticator.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ def setUp(self):
7373
self.tmp_dir = tempfile.TemporaryDirectory()
7474
self.tmp_dir_path = pathlib.Path(self.tmp_dir.name)
7575

76+
@staticmethod
77+
def _filter_spdata(data: dict) -> dict:
78+
# Storage providers are known to add "__" keys for their own use.
79+
# These should not be considered part of the general data.
80+
filtered_data = {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith("__"))}
81+
return filtered_data
82+
7683
def under_test_happy_path(self):
7784
credential_path = self.tmp_dir_path / "refreshing_oidc_authenticator_test_token__with_refresh.json"
7885
test_credential = self.mock_auth_login_and_command_initialize(
@@ -485,7 +492,7 @@ def test_side_band_update_credential_data(self):
485492
# It should not be necessary to simulate an API call for everything to be set
486493
current_credential_data = under_test._credential.data()
487494
self.assertNotEqual(current_credential_data, initial_credential_data)
488-
self.assertEqual(current_credential_data, sideband_credential.data())
495+
self.assertEqual(self._filter_spdata(current_credential_data), sideband_credential.data())
489496
self.assertEqual(under_test._token_body, sideband_credential.access_token())
490497
self.assertNotEqual(0, under_test._refresh_at)
491498
self.assertNotEqual(0, under_test._credential._load_time)

tests/test_planet_auth/unit/auth/test_storage_utils_file_backed_json_object.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,13 @@ def setUp(self):
8888
# This seems to help.
8989
os.environ["TZ"] = "UTC"
9090

91+
@staticmethod
92+
def _filter_spdata(data: dict) -> dict:
93+
# Storage providers are known to add "__" keys for their own use.
94+
# These should not be considered part of the general data.
95+
filtered_data = {k: v for k, v in data.items() if not (isinstance(k, str) and k.startswith("__"))}
96+
return filtered_data
97+
9198
def test_set_data_asserts_valid(self):
9299
under_test = Credential(data=None, file_path=None)
93100
with self.assertRaises(FileBackedJsonObjectException):
@@ -129,14 +136,14 @@ def test_load_and_failed_reload(self):
129136
# Load works when we have a valid file
130137
under_test.set_path(tdata_resource_file_path("keys/base_test_credential.json"))
131138
under_test.load()
132-
self.assertEqual({"test_key": "test_value"}, under_test.data())
139+
self.assertEqual({"test_key": "test_value"}, self._filter_spdata(under_test.data()))
133140

134141
# A subsequent failed load should throw, but leave the data unchanged.
135142
under_test.set_path(tdata_resource_file_path("keys/FILE_DOES_NOT_EXIST.json"))
136143
with self.assertRaises(FileNotFoundError):
137144
under_test.load()
138145

139-
self.assertEqual({"test_key": "test_value"}, under_test.data())
146+
self.assertEqual({"test_key": "test_value"}, self._filter_spdata(under_test.data()))
140147

141148
def test_load_file_not_found(self):
142149
under_test = Credential(data=None, file_path=tdata_resource_file_path("keys/FILE_DOES_NOT_EXIST.json"))
@@ -166,7 +173,7 @@ def test_lazy_load(self):
166173
under_test = Credential(data=None, file_path=tdata_resource_file_path("keys/base_test_credential.json"))
167174
self.assertIsNone(under_test.data())
168175
under_test.lazy_load()
169-
self.assertEqual({"test_key": "test_value"}, under_test.data())
176+
self.assertEqual({"test_key": "test_value"}, self._filter_spdata(under_test.data()))
170177

171178
# if the path is invalid, it should error.
172179
under_test = Credential(data=None, file_path=tdata_resource_file_path("keys/FILE_DOES_NOT_EXIST.json"))
@@ -204,7 +211,7 @@ def test_lazy_reload_initial_load_behavior(self):
204211
under_test = Credential(data=None, file_path=tdata_resource_file_path("keys/base_test_credential.json"))
205212
self.assertIsNone(under_test.data())
206213
under_test.lazy_reload()
207-
self.assertEqual({"test_key": "test_value"}, under_test.data())
214+
self.assertEqual({"test_key": "test_value"}, self._filter_spdata(under_test.data()))
208215

209216
# if the path is invalid, it should error.
210217
under_test = Credential(data=None, file_path=tdata_resource_file_path("keys/FILE_DOES_NOT_EXIST.json"))
@@ -299,7 +306,7 @@ def test_save(self):
299306
under_test.save()
300307
test_reader = Credential(data=None, file_path=test_path)
301308
test_reader.load()
302-
self.assertEqual(test_data, test_reader.data())
309+
self.assertEqual(test_data, self._filter_spdata(test_reader.data()))
303310

304311
def test_getters_setters(self):
305312
test_path = pathlib.Path("/test/test_credential.json")

version.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
2.3.0
1+
2.3.1

0 commit comments

Comments
 (0)