2626import logging
2727import os
2828import platform
29+ import threading
2930import uuid
31+ from contextlib import ExitStack
3032from dataclasses import dataclass , field
3133from datetime import datetime , timezone
3234from enum import Enum
4244 keyring = None
4345 KeyringError = Exception
4446
47+ try :
48+ from keyring .errors import PasswordDeleteError
49+ except ImportError : # pragma: no cover (older keyring without this subclass)
50+ PasswordDeleteError = KeyringError
51+
52+ try :
53+ from filelock import FileLock
54+ except ImportError : # pragma: no cover (filelock is a declared dependency)
55+ FileLock = None # type: ignore[misc,assignment]
56+
4557from cryptography .fernet import Fernet , InvalidToken
4658from cryptography .hazmat .primitives import hashes
4759from cryptography .hazmat .primitives .kdf .pbkdf2 import PBKDF2HMAC
5567SALT_FILE_NAME = "salt"
5668DEFAULT_STORAGE_DIR = Path .home () / ".codeframe"
5769
70+ # Per-process memo of completed machine-wide → per-user migrations (#790).
71+ # CredentialManager is constructed per request, but migration is idempotent
72+ # (copy-only), so one run per user storage_dir per process is enough — the
73+ # next process retries anything that failed. Tests that build managers via
74+ # ``CredentialManager.__new__`` bypass __init__ and thus this memo entirely.
75+ # Tests reset it with ``_MIGRATION_COMPLETE.clear()``.
76+ _MIGRATION_COMPLETE : set [Path ] = set ()
77+
78+ # Thread locks serialize concurrent migrations for the same storage root within
79+ # a process. The optional file lock (requires ``filelock``) extends that
80+ # serialization across processes.
81+ _MIGRATION_THREAD_LOCKS : dict [Path , threading .Lock ] = {}
82+
83+
84+ def _get_migration_locks (storage_dir : Path ) -> tuple [threading .Lock , Any | None ]:
85+ """Return the migration locks for ``storage_dir``.
86+
87+ The returned tuple is ``(thread_lock, file_lock_or_none)``. ``file_lock``
88+ is ``None`` when ``filelock`` is not installed; callers fall back to the
89+ thread lock only and log a warning that cross-process serialization is
90+ disabled.
91+ """
92+ lock_path = storage_dir / ".migration.lock"
93+ thread_lock = _MIGRATION_THREAD_LOCKS .setdefault (lock_path , threading .Lock ())
94+ file_lock : Any | None = None
95+ if FileLock is not None :
96+ file_lock = FileLock (str (lock_path ))
97+ return thread_lock , file_lock
98+
5899
59100class CredentialSource (str , Enum ):
60101 """Source of a credential."""
@@ -329,18 +370,42 @@ class CredentialStore:
329370 """Low-level credential storage.
330371
331372 Uses platform keyring as primary storage with encrypted file fallback.
373+
374+ Storage is machine-wide by default (``user_id=None``). With a ``user_id``
375+ the store is scoped to that user (#790): keyring entries live under a
376+ per-user service name and the encrypted-file fallback lives under
377+ ``<storage_dir>/users/<id>/`` with its own salt — the per-directory salt
378+ yields per-user encryption keys from the unchanged key derivation.
332379 """
333380
334- def __init__ (self , storage_dir : Optional [Path ] = None ):
381+ def __init__ (self , storage_dir : Optional [Path ] = None , user_id : Optional [ int ] = None ):
335382 """Initialize credential store.
336383
337384 Args:
338385 storage_dir: Directory for encrypted file storage
386+ user_id: Scope storage to this user; None keeps machine-wide storage
339387 """
340- self .storage_dir = storage_dir or DEFAULT_STORAGE_DIR
388+ self .user_id = user_id
389+ self .storage_dir = self ._resolve_storage_dir (storage_dir , user_id )
390+ self ._keyring_service_name = self ._resolve_keyring_service_name (user_id )
341391 self ._keyring_available = self ._check_keyring ()
342392 self ._fernet : Optional [Fernet ] = None
343393
394+ @staticmethod
395+ def _resolve_storage_dir (storage_dir : Optional [Path ], user_id : Optional [int ]) -> Path :
396+ """Per-user stores live in ``<storage_dir>/users/<id>/``."""
397+ base = storage_dir or DEFAULT_STORAGE_DIR
398+ if user_id is None :
399+ return base
400+ return base / "users" / str (user_id )
401+
402+ @staticmethod
403+ def _resolve_keyring_service_name (user_id : Optional [int ]) -> str :
404+ """Per-user keyring entries are addressed under their own service."""
405+ if user_id is None :
406+ return KEYRING_SERVICE_NAME
407+ return f"{ KEYRING_SERVICE_NAME } -user-{ user_id } "
408+
344409 def _check_keyring (self ) -> bool :
345410 """Check if keyring is available and working."""
346411 if not KEYRING_AVAILABLE :
@@ -411,6 +476,15 @@ def _load_encrypted_store(self) -> dict[str, dict]:
411476 def _save_encrypted_store (self , store : dict [str , dict ]) -> None :
412477 """Save all credentials to encrypted file."""
413478 self .storage_dir .mkdir (parents = True , exist_ok = True )
479+ if self .user_id is not None :
480+ # Per-user dirs are 0700 so other local accounts cannot enumerate
481+ # tenant ids (#790). The machine-wide base dir keeps its default
482+ # perms. Best-effort — Windows does not honor POSIX modes.
483+ for directory in (self .storage_dir , self .storage_dir .parent ):
484+ try :
485+ directory .chmod (0o700 )
486+ except OSError : # pragma: no cover (chmod may fail on Windows)
487+ pass
414488
415489 file_path = self ._get_encrypted_file_path ()
416490 fernet = self ._get_fernet ()
@@ -446,13 +520,13 @@ def store(self, credential: Credential) -> None:
446520 # Try keyring first
447521 if self ._keyring_available :
448522 try :
449- keyring .set_password (KEYRING_SERVICE_NAME , key , data )
523+ keyring .set_password (self . _keyring_service_name , key , data )
450524 logger .debug (f"Stored { key } in keyring" )
451525 return
452526 except Exception as e :
453527 logger .warning (f"Keyring storage failed, using encrypted file: { e } " )
454528 try :
455- keyring .delete_password (KEYRING_SERVICE_NAME , key )
529+ keyring .delete_password (self . _keyring_service_name , key )
456530 except Exception :
457531 pass
458532 self ._keyring_available = False
@@ -479,7 +553,7 @@ def retrieve(self, provider: CredentialProvider) -> Optional[Credential]:
479553 # Try keyring first
480554 if self ._keyring_available :
481555 try :
482- data = keyring .get_password (KEYRING_SERVICE_NAME , key )
556+ data = keyring .get_password (self . _keyring_service_name , key )
483557 if data :
484558 return Credential .from_dict (json .loads (data ))
485559 except (KeyError , TypeError , ValueError , json .JSONDecodeError ) as e :
@@ -509,8 +583,12 @@ def delete(self, provider: CredentialProvider) -> None:
509583 # Try keyring
510584 if self ._keyring_available :
511585 try :
512- keyring .delete_password (KEYRING_SERVICE_NAME , key )
586+ keyring .delete_password (self . _keyring_service_name , key )
513587 logger .debug (f"Deleted { key } from keyring" )
588+ except PasswordDeleteError :
589+ # Not in the keyring (e.g. a file-only entry) — that is
590+ # "nothing to do", not a failure. Continue to file cleanup.
591+ logger .debug (f"{ key } not present in keyring; nothing to delete there" )
514592 except Exception as e :
515593 logger .warning (f"Keyring deletion failed: { e } " )
516594 raise
@@ -553,15 +631,80 @@ class CredentialManager:
553631
554632 Provides environment variable override, storage abstraction,
555633 and credential lifecycle management.
634+
635+ With ``user_id`` the underlying store is scoped to that user (#790); the
636+ first per-user manager also copies any legacy machine-wide entries into
637+ the user's store while leaving the machine-wide source intact.
638+ ``user_id=None`` is the machine-wide store itself and never migrates.
556639 """
557640
558- def __init__ (self , storage_dir : Optional [Path ] = None ):
641+ def __init__ (
642+ self ,
643+ storage_dir : Optional [Path ] = None ,
644+ user_id : Optional [int ] = None ,
645+ migrate : bool = True ,
646+ ):
559647 """Initialize credential manager.
560648
561649 Args:
562650 storage_dir: Directory for credential storage
651+ user_id: Scope credentials to this user; None keeps the machine-wide store
652+ migrate: Whether to run the machine-wide → per-user migration on first
653+ construction for this user. Pass ``False`` from read-only endpoints
654+ so that a plain GET cannot write credentials into a new tenant store
655+ (the admin-scoped PUT/POST paths keep the default ``True``).
656+ """
657+ self ._user_id = user_id
658+ self ._storage_dir = storage_dir
659+ self ._store = CredentialStore (storage_dir , user_id = user_id )
660+ if migrate and user_id is not None :
661+ if self ._store .storage_dir not in _MIGRATION_COMPLETE :
662+ self ._migrate_machine_wide_entries ()
663+ _MIGRATION_COMPLETE .add (self ._store .storage_dir )
664+
665+ def _migrate_machine_wide_entries (self ) -> None :
666+ """Copy legacy machine-wide credentials into this user's store (#790).
667+
668+ Probes every known provider (the keyring backend cannot enumerate, so
669+ the enum is the probe list — same trick as ``list_credentials``), copies
670+ entries the user's store lacks, and leaves the machine-wide entries in
671+ place. Idempotent; first-come-first-served for the per-user copy only.
672+ The machine-wide file + salt are left in place so legacy managers
673+ (CLI/background, ``user_id=None``) continue to work and autoclose keeps
674+ seeing the source credential.
675+
676+ A thread lock and an optional file lock (when ``filelock`` is installed)
677+ serialize concurrent migrations for the same storage root, protecting
678+ the per-user store's read-modify-write save from concurrent first-time
679+ requests for the same user.
563680 """
564- self ._store = CredentialStore (storage_dir )
681+ machine_store = CredentialStore (self ._storage_dir )
682+ if FileLock is None :
683+ logger .warning (
684+ "filelock is not installed; cross-process migration serialization "
685+ "is disabled. Install filelock for multi-process deployments."
686+ )
687+
688+ thread_lock , file_lock = _get_migration_locks (machine_store .storage_dir )
689+ with ExitStack () as stack :
690+ stack .enter_context (thread_lock )
691+ if file_lock is not None :
692+ stack .enter_context (file_lock )
693+
694+ copied : list [str ] = []
695+ for provider in CredentialProvider :
696+ if self ._store .retrieve (provider ) is not None :
697+ continue
698+ credential = machine_store .retrieve (provider )
699+ if credential is None :
700+ continue
701+ self ._store .store (credential )
702+ copied .append (provider .name )
703+ if copied :
704+ logger .info (
705+ f"Copied { len (copied )} legacy credential(s) into user "
706+ f"{ self ._user_id } 's store: { ', ' .join (copied )} "
707+ )
565708
566709 def get_credential (
567710 self ,
0 commit comments