Skip to content

Commit 4abcd0e

Browse files
ChuckBuildsclaude
andauthored
Fix PermissionError reading config_secrets.json in web interface (#416)
ledmatrix.service (main display) runs as root while ledmatrix-web.service runs as the non-root install user (install_web_service.sh). Both config_manager.py and config_manager_atomic.py only chmod'd config_secrets.json to 0o640 without ever fixing its group, so a file written by the root service ended up group-owned by root and unreadable by the web user, crashing the settings page with a raw PermissionError. Add ensure_shared_group_ownership() to chgrp secrets/config files (best effort, root-only) to the project directory's owning group whenever they are created or saved, and self-heal existing files on load. Also make get_raw_file_content() tolerate an unreadable secrets file the same way load_config() already does, degrading to empty secrets instead of a 500. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2a1c47f commit 4abcd0e

3 files changed

Lines changed: 87 additions & 1 deletion

File tree

src/common/permission_utils.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,60 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
146146
raise
147147

148148

149+
_shared_group_gid_cache: Optional[int] = None
150+
151+
152+
def get_shared_group_gid() -> Optional[int]:
153+
"""
154+
Return the gid that should own config/secrets files shared between the
155+
root-run ``ledmatrix.service`` (main display) and the non-root user that
156+
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
157+
``User=$SUDO_USER``).
158+
159+
Resolved once from the project root directory's current group (normally
160+
the login user's group from the initial ``git clone``), since that user
161+
is stable across reinstalls unlike any single file's ownership.
162+
163+
Returns:
164+
The gid, or None if it cannot be determined.
165+
"""
166+
global _shared_group_gid_cache
167+
if _shared_group_gid_cache is not None:
168+
return _shared_group_gid_cache
169+
try:
170+
project_root = Path(__file__).resolve().parent.parent.parent
171+
_shared_group_gid_cache = project_root.stat().st_gid
172+
return _shared_group_gid_cache
173+
except OSError:
174+
return None
175+
176+
177+
def ensure_shared_group_ownership(path: Path) -> None:
178+
"""
179+
Best-effort chgrp of ``path`` to the shared group (see
180+
:func:`get_shared_group_gid`) when running as root.
181+
182+
Only root can change a file's group to one the calling process isn't a
183+
member of, which is exactly the case that causes the web interface
184+
(running as a non-root user) to get ``PermissionError`` reading files
185+
the root-run display service just wrote with a 0o640/2775 mode: the mode
186+
is group-readable, but without this the group is root's, not the web
187+
user's. Silently does nothing if not running as root or on any error —
188+
this is a hardening step, not a required one.
189+
"""
190+
if os.geteuid() != 0:
191+
return
192+
gid = get_shared_group_gid()
193+
if gid is None:
194+
return
195+
try:
196+
if path.exists() and path.stat().st_gid != gid:
197+
os.chown(path, -1, gid)
198+
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
199+
except OSError as e:
200+
logger.debug(f"Could not set shared group ownership on {path}: {e}")
201+
202+
149203
def get_config_file_mode(file_path: Path) -> int:
150204
"""
151205
Return appropriate permission mode for config files.

src/config_manager.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
from src.common.permission_utils import (
3939
ensure_directory_permissions,
4040
ensure_file_permissions,
41+
ensure_shared_group_ownership,
4142
get_config_file_mode,
4243
get_config_dir_mode
4344
)
@@ -234,6 +235,11 @@ def load_config(self) -> Dict[str, Any]:
234235

235236
# Load and merge secrets if they exist (be permissive on errors)
236237
if os.path.exists(self.secrets_path):
238+
# Self-heal stale group ownership (e.g. the root-run display
239+
# service wrote this file before the web user was granted
240+
# group access) before every load attempt; no-op unless
241+
# running as root and the group is already wrong.
242+
ensure_shared_group_ownership(Path(self.secrets_path))
237243
try:
238244
with open(self.secrets_path, 'r') as f:
239245
secrets = json.load(f)
@@ -363,6 +369,7 @@ def _create_config_from_template(self) -> None:
363369
# Set proper file permissions after creation
364370
config_path_obj = Path(self.config_path)
365371
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
372+
ensure_shared_group_ownership(config_path_obj)
366373

367374
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
368375

@@ -475,14 +482,30 @@ def get_raw_file_content(self, file_type: str) -> Dict[str, Any]:
475482
self.logger.error(error_msg)
476483
raise ConfigError(error_msg, config_path=path_to_load)
477484

485+
if file_type == "secrets":
486+
# Best-effort self-heal: no-op unless running as root and the
487+
# group is stale (see load_config for why this can happen).
488+
ensure_shared_group_ownership(Path(path_to_load))
489+
478490
try:
479491
with open(path_to_load, 'r') as f:
480492
return json.load(f)
481493
except json.JSONDecodeError as e:
482494
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
483495
self.logger.error(error_msg, exc_info=True)
484496
raise ConfigError(error_msg, config_path=path_to_load) from e
485-
except (IOError, OSError, PermissionError) as e:
497+
except PermissionError as e:
498+
if file_type == "secrets":
499+
# Match load_config()'s tolerance: a secrets file the web
500+
# process can't read (e.g. written 0640 by the root-run
501+
# display service before the group was fixed up) shouldn't
502+
# 500 the settings page — degrade to "no secrets" instead.
503+
self.logger.warning(f"Secrets file not readable ({path_to_load}): {e}. Returning empty secrets.")
504+
return {}
505+
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
506+
self.logger.error(error_msg, exc_info=True)
507+
raise ConfigError(error_msg, config_path=path_to_load) from e
508+
except (IOError, OSError) as e:
486509
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
487510
self.logger.error(error_msg, exc_info=True)
488511
raise ConfigError(error_msg, config_path=path_to_load) from e
@@ -539,6 +562,7 @@ def save_raw_file_content(self, file_type: str, data: Dict[str, Any]) -> None:
539562
# Ensure final file has correct permissions
540563
try:
541564
ensure_file_permissions(path_obj, file_mode)
565+
ensure_shared_group_ownership(path_obj)
542566
except OSError as perm_error:
543567
# If we can't set permissions but file was written, log warning but don't fail
544568
self.logger.warning(

src/config_manager_atomic.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
from src.exceptions import ConfigError
1919
from src.logging_config import get_logger
20+
from src.common.permission_utils import ensure_shared_group_ownership
2021

2122

2223
class SaveResultStatus(Enum):
@@ -410,6 +411,13 @@ def _atomic_move(self, source: Path, destination: Path) -> None:
410411
# This is important because temp files may have different permissions
411412
# and we need root service to be able to read config.json
412413
os.chmod(destination, target_mode)
414+
415+
# Also fix group ownership when this save is running as root
416+
# (the display service): 0o640 alone only helps the non-root web
417+
# user read a root-written secrets file if its group already
418+
# matches the web user's group, which isn't guaranteed. See
419+
# permission_utils.ensure_shared_group_ownership for why.
420+
ensure_shared_group_ownership(destination)
413421

414422
except Exception as e:
415423
raise ConfigError(f"Error during atomic move: {e}") from e

0 commit comments

Comments
 (0)