r"""Cross-platform best-effort directory-permission hardening for sensitive files.
App-owned sensitive plaintext files must be restricted to the operator's user
account. Browser session state is persisted through secure objects and does not
use this plaintext-file helper.
POSIX: ``chmod 0o700`` is sufficient. Windows: the DACL is rewritten in
process to what ``icacls.exe /inheritance:r /grant:r <user>:<rights>`` would
leave -- inherited ACEs stripped and the DACL protected, explicit ACEs kept,
and the operator's same-flag allow ACE replaced by (or joined by) a
full-control grant. The operator is resolved from ``DOMAIN\\user`` and then
``user`` so it works on standalone machines and domain-joined hosts.
The Windows branch reads ``USERDOMAIN`` as operating-system ambient context
only. It does not read Cadrumo configuration or make permission tightening an
authorization decision.
"""
from __future__ import annotations
import getpass
import os
import stat
from pathlib import Path
from typing import TYPE_CHECKING, Final
from .logging import get_logger
if TYPE_CHECKING:
from _win32typing import PyACL, PySECURITY_DESCRIPTOR
_log = get_logger(__name__)
# Named so grep surfaces every read of the operator's domain qualifier.
_USERDOMAIN_ENV_VAR: Final[str] = "USERDOMAIN"
def _operator_account_candidates() -> list[str]:
username = getpass.getuser()
# os.environ.get allowlist: USERDOMAIN is a Windows OS-integration
# variable, not AEAT-prefixed config, so it is read from the environment
# rather than from Settings.
userdomain = os.environ.get(_USERDOMAIN_ENV_VAR)
return [f"{userdomain}\\{username}", username] if userdomain else [username]
def _dacl_of(descriptor: PySECURITY_DESCRIPTOR) -> PyACL | None:
"""Return the descriptor's DACL, or ``None`` for a NULL DACL.
The published stub declares a ``PyACL`` return, but a NULL DACL -- no DACL
at all, which grants everyone access -- comes back as ``None``.
"""
return descriptor.GetSecurityDescriptorDacl()
def _grant_operator_full_control(path: Path, account: str, *, inheritable: bool) -> None:
"""Rewrite ``path``'s DACL as ``icacls /inheritance:r /grant:r account:F`` leaves it.
Inherited ACEs are dropped and the DACL is protected. Explicit ACEs keep
their order; the account's explicit allow ACE carrying the same
inheritance flags is replaced in place by the full-control grant, which is
otherwise appended. The account's deny ACEs and its allow ACEs with other
flags are kept, which is where this differs from ``SetEntriesInAcl``'s
``SET_ACCESS``. Only plain allow and deny ACEs are copied; any other
explicit ACE type raises and is logged by the best-effort caller.
"""
import ntsecuritycon
import win32security
operator_sid = win32security.LookupAccountName(None, account)[0]
grant_flags = (
win32security.OBJECT_INHERIT_ACE | win32security.CONTAINER_INHERIT_ACE
if inheritable
else win32security.NO_INHERITANCE
)
descriptor = win32security.GetNamedSecurityInfo(
str(path), win32security.SE_FILE_OBJECT, win32security.DACL_SECURITY_INFORMATION
)
current = _dacl_of(descriptor)
rewritten = win32security.ACL()
granted = False
aces = () if current is None else tuple(current.GetAce(index) for index in range(current.GetAceCount()))
for ace in aces:
(ace_type, ace_flags), mask, sid = ace[0], ace[1], ace[-1]
if ace_flags & win32security.INHERITED_ACE:
continue
if ace_type == ntsecuritycon.ACCESS_ALLOWED_ACE_TYPE and sid == operator_sid and ace_flags == grant_flags:
if not granted:
rewritten.AddAccessAllowedAceEx(
win32security.ACL_REVISION, grant_flags, ntsecuritycon.FILE_ALL_ACCESS, operator_sid
)
granted = True
elif ace_type == ntsecuritycon.ACCESS_ALLOWED_ACE_TYPE:
rewritten.AddAccessAllowedAceEx(win32security.ACL_REVISION, ace_flags, mask, sid)
elif ace_type == ntsecuritycon.ACCESS_DENIED_ACE_TYPE:
rewritten.AddAccessDeniedAceEx(win32security.ACL_REVISION, ace_flags, mask, sid)
else:
raise ValueError(f"explicit ACE type {ace_type} cannot be carried over")
if not granted:
rewritten.AddAccessAllowedAceEx(
win32security.ACL_REVISION, grant_flags, ntsecuritycon.FILE_ALL_ACCESS, operator_sid
)
win32security.SetNamedSecurityInfo(
str(path),
win32security.SE_FILE_OBJECT,
win32security.DACL_SECURITY_INFORMATION | win32security.PROTECTED_DACL_SECURITY_INFORMATION,
None,
None,
rewritten,
None,
)
def _windows_restrict_to_operator(path: Path, *, inheritable: bool) -> None:
r"""Strip inherited ACEs from ``path`` and grant the operator full control.
The ONE Windows ACL implementation. ``inheritable`` selects the grant: a
directory takes object- and container-inherit so every file and
subdirectory created inside it inherits the restriction, a file takes a
non-inheritable grant.
The DACL is rewritten in process. This used to spawn ``icacls.exe`` --
twice per call wherever ``USERDOMAIN`` does not resolve -- at ~28 ms per
spawn. Directory inheritance is still the intended shape: hardening the
containing directory once and letting the kernel apply the ACL to new
children keeps per-file writes free of any ACL call.
Best-effort by contract: every error is swallowed and logged, because a
hardening side-effect must never abort the flow that triggered it.
"""
try:
# Imported here: pywin32 costs several milliseconds to import, and
# only a process that actually hardens a directory should pay it.
import pywintypes
failure = "no operator account candidate was tried"
for candidate in _operator_account_candidates():
try:
_grant_operator_full_control(path, candidate, inheritable=inheritable)
except pywintypes.error as error:
# Mirrors the retired icacls loop: any failure for one account
# spelling moves on to the next.
failure = f"{candidate}: {error.strerror}"
continue
return
_log.warning("restrict permissions: failed to harden Windows ACLs on %s: %s", path, failure)
except Exception:
_log.warning("restrict permissions: best-effort hardening failed on %s", path, exc_info=True)
[docs]
def restrict_directory_permissions(path: Path) -> None:
"""Best-effort restrict ``path`` and everything later created inside it.
The O(1) confidentiality boundary for a storage tree. POSIX applies mode
``0o700``; Windows strips inherited ACEs and grants the operator
``(OI)(CI)F``, so files written into the tree afterwards inherit the
restriction with no per-write cost.
Call this when a storage root or namespace directory is created. Do NOT
call a per-file variant on the write path to achieve the same thing --
see :func:`_windows_restrict_to_operator` for the measured reason.
"""
if os.name == "nt": # pragma: no cover - Windows-specific
_windows_restrict_to_operator(path, inheritable=True)
return
if os.name != "posix":
return
try:
# 0o700 (owner-only) is the intended confidentiality boundary for a
# secrets/financial-data storage root, not a weaker default to relax.
os.chmod(path, stat.S_IRWXU)
except OSError:
_log.debug("restrict_directory_permissions: chmod failed on %s", path, exc_info=True)
__all__ = ["restrict_directory_permissions"]