Skip to content

Commit 6d8b04c

Browse files
committed
feat(auth): per-user credential scoping for hosted mode (#790)
- CredentialManager/CredentialStore accept user_id; per-user keyring service (codeframe-credentials-user-<id>) and encrypted-file store (<storage_dir>/users/<id>/) with per-user salt/Fernet keys. user_id=None keeps the legacy machine-wide path. - Credential and GitHub-PAT v2 endpoints thread auth["user_id"] through dependency functions; tenants can only access their own credentials. - Removed the #718 hosted-mode 403 block; added a startup guard that refuses to start hosted mode if authentication is disabled. - Copy-only migration: legacy machine-wide entries copied into each user's store on first write-path access; machine-wide entries left in place so CLI/background/autoclose keep working. - Read endpoints (GET /keys, GET /status, GET /issues, POST /verify-key) use migrate=False so a plain status check cannot trigger a credential write into a new tenant's store. - Migration serialized by threading.Lock + optional filelock.FileLock. - GitHub issues cache invalidation scoped to the calling user. - Added filelock>=3.0 as a direct dependency. - Test fixtures use format-valid but non-scannable fake values (sk-ant-test-*, sk-test-*, ghp_test*) to avoid secret-scanner false positives.
1 parent ed91f46 commit 6d8b04c

19 files changed

Lines changed: 1451 additions & 157 deletions

codeframe/core/credentials.py

Lines changed: 151 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
import logging
2727
import os
2828
import platform
29+
import threading
2930
import uuid
31+
from contextlib import ExitStack
3032
from dataclasses import dataclass, field
3133
from datetime import datetime, timezone
3234
from enum import Enum
@@ -42,6 +44,16 @@
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+
4557
from cryptography.fernet import Fernet, InvalidToken
4658
from cryptography.hazmat.primitives import hashes
4759
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
@@ -55,6 +67,35 @@
5567
SALT_FILE_NAME = "salt"
5668
DEFAULT_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

59100
class 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,

codeframe/core/github_integration_config.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
Stores only **non-secret** repo metadata for a connected GitHub repository under
44
``.codeframe/github_integration.json``. The PAT itself is stored in the
5-
machine-wide ``CredentialManager`` (``CredentialProvider.GIT_GITHUB``) — never
5+
caller-scoped ``CredentialManager`` (``CredentialProvider.GIT_GITHUB``; issue
6+
#790 — per-user when authenticated, machine-wide when auth is disabled) — never
67
in this file.
78
89
Headless — no FastAPI or HTTP imports (architecture rule #1). Mirrors the

codeframe/core/github_issues_service.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
33
Headless service used by the Integrations issues endpoint to fetch a connected
44
repository's **open** issues for the import browser UI. Builds on the connection
5-
established in #563: the PAT comes from the machine-wide ``CredentialManager``
6-
and the ``owner/repo`` from per-workspace ``.codeframe/github_integration.json``
5+
established in #563: the PAT comes from the caller-scoped ``CredentialManager``
6+
(issue #790; per-user in hosted mode, machine-wide when auth is disabled) and
7+
the ``owner/repo`` from per-workspace ``.codeframe/github_integration.json``
78
— this module only performs the GitHub API call given those values.
89
910
No FastAPI / HTTP-framework imports (architecture rule #1 — core is headless).

codeframe/core/tasks.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -523,8 +523,12 @@ def _dispatch_github_autoclose(workspace: Workspace, task: Task) -> None:
523523
task transition. The repo is taken from the task's own ``external_url`` (its
524524
source repo) — NOT the workspace's current connection — so completing an
525525
older imported task always closes the right issue even after the workspace
526-
is reconnected to a different repository. The PAT comes from the machine-wide
527-
credential store.
526+
is reconnected to a different repository.
527+
528+
The PAT is resolved from the machine-wide credential store / ``GITHUB_TOKEN``
529+
environment variable. Per-user stored PATs are **not** used by autoclose
530+
(this is a known limitation of the current headless/background task path;
531+
hosted tenants should supply ``GITHUB_TOKEN`` via environment for autoclose).
528532
"""
529533
if not task.auto_close_github_issue or task.github_issue_number is None:
530534
return

codeframe/ui/dependencies.py

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -99,32 +99,6 @@ def revalidate_workspace_path(workspace_path: str, user_id: Optional[int]) -> Op
9999
return None
100100

101101

102-
def forbid_shared_credentials_in_hosted_mode() -> None:
103-
"""Block shared machine-wide credential mutation in hosted mode (#718).
104-
105-
CodeFRAME's credential store (``CredentialManager``) is machine-wide — one
106-
set of LLM keys and one GitHub PAT per host. That is fine for a *self-hosted*
107-
deployment, which is a single trust domain (one operator/team). In *hosted*
108-
(multi-tenant) mode it would let any tenant view (last-4), overwrite, or
109-
delete another tenant's secrets, so credential storage/deletion and GitHub
110-
connect/disconnect are disabled there — hosted tenants supply credentials via
111-
per-instance environment variables instead. Per-user credential scoping is a
112-
tracked follow-up; until then hosted mode fails closed.
113-
"""
114-
# Deferred import: codeframe.ui.server imports from this module at module
115-
# level, so importing it at the top would be circular.
116-
from codeframe.ui.server import is_hosted_mode
117-
118-
if is_hosted_mode():
119-
raise HTTPException(
120-
status_code=403,
121-
detail=(
122-
"Shared credential storage is disabled in hosted mode. Provide "
123-
"credentials via environment variables per deployment (#718)."
124-
),
125-
)
126-
127-
128102
def get_workspace_manager(request: Request) -> WorkspaceManager:
129103
"""Get workspace manager from application state.
130104

0 commit comments

Comments
 (0)