Skip to content

feat(auth): per-user credential scoping for hosted mode (#790)#871

Merged
frankbria merged 2 commits into
mainfrom
feature/issue-790-per-user-credentials
Jul 20, 2026
Merged

feat(auth): per-user credential scoping for hosted mode (#790)#871
frankbria merged 2 commits into
mainfrom
feature/issue-790-per-user-credentials

Conversation

@frankbria

Copy link
Copy Markdown
Owner

Summary

Implements #790: credentials and GitHub PATs are now scoped per-user in hosted mode instead of being machine-wide.

What changed:

  • CredentialManager / CredentialStore accept a user_id; per-user keyring service (codeframe-credentials-user-) and encrypted-file store (<storage_dir>/users//) with per-user salt/Fernet keys. user_id=None keeps the legacy machine-wide path for CLI and no-auth/self-hosted deployments.
  • Credential and GitHub-PAT v2 endpoints thread auth["user_id"] through their dependency functions, so tenants can only access their own stored credentials.
  • Removed the [P0.7] Bind workspaces to an owner and scope credentials per-user (multi-user IDOR + shared machine-wide secrets) #718 hosted-mode 403 block on credential mutation; added a startup guard that refuses to start hosted mode if authentication is disabled, ensuring user_id=None can never occur in hosted mode.
  • Copy-only migration: legacy machine-wide entries are copied into each user's store on first access and left in place so CLI, background tasks, and autoclose keep working.
  • Migration is serialized by a per-storage threading.Lock plus an optional filelock.FileLock to close concurrent first-request races.
  • GitHub issues cache invalidation is scoped to the calling user.
  • Added filelock>=3.0 as a direct dependency.

Acceptance Criteria

Test Plan

  • Unit tests written first (TDD).
  • Focused credential suites passing: 224 passed (, , , , , , ).
  • Diff coverage on changed lines: 98% (≥85% gate).
  • Lint/type checks clean (ruff + mypy).
  • Internal code review (advisory) completed and findings addressed.
  • Cross-family review pass: opencode (GLM) — APPROVE after second round.
  • Test mutation sanity check completed (verified rollback-on-delete-failure and 0700 dir-permission tests fail when the behavior is broken).

Known Limitations / Intentionally Deferred

  • Autoclose uses machine-wide credentials only. Background task auto-close reads CredentialManager() (no user context) and the GITHUB_TOKEN env var; per-user stored PATs do not drive autoclose. This preserves existing self-hosted behavior; hosted tenants should supply GITHUB_TOKEN via environment for autoclose.
  • Copy-only migration is a one-time snapshot. Legacy machine-wide entries remain in place so CLI/background tasks continue to work. Operators enabling hosted mode should rotate legacy credentials before tenants sign up, and revoke per-user copies individually if needed.
  • Env-var credentials are process-wide. ANTHROPIC_API_KEY / OPENAI_API_KEY / GITHUB_TOKEN take precedence over stored credentials and are shared across all tenants in a process. This is pre-existing behavior and the documented path for deployment-wide keys.
  • Concurrent per-user file writes. The per-user encrypted-file store uses atomic rename but does not serialize concurrent read-modify-write saves for different providers from the same user (a pre-existing race, not introduced by this PR).
  • filelock optional path. Cross-process migration serialization requires filelock (declared dependency). If it is absent, only intra-process locking is active and a warning is logged.

Implementation Notes

  • The migration strategy changed from copy+delete to copy-only after the first cross-family review, to avoid crash-safety and cross-tenant concurrency hazards with deletion.
  • An initial attempt to resolve autoclose via workspace-owner lookup was reverted in favor of keeping the machine-wide store, which preserves self-hosted autoclose behavior with no DB lookup on every DONE transition.
  • _get_migration_locks uses dict.setdefault to avoid a check-then-act race when creating thread locks.

Closes #790

@gitguardian

gitguardian Bot commented Jul 19, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 3 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
34952160 Triggered Generic Password 6d8b04c tests/core/test_credentials_per_user.py View secret
34952161 Triggered Generic Password 6d8b04c tests/core/test_credentials_per_user.py View secret
34952162 Triggered Generic Password 6d8b04c tests/core/test_credentials_per_user.py View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@github-actions

github-actions Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 6m 37s —— View job


GLM Precision Bug-Hunt Review

Reviewing PR #871 (per-user credential scoping for hosted mode) for concrete defects only.

  • Gather PR context (diff, existing comments)
  • Read changed source files in full context
  • Trace callers/callees for suspicious hunks
  • Post confirmed findings (inline comments)
  • Post summary table

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 3m 13s —— View job


I'll analyze this and get back to you.

@frankbria

Copy link
Copy Markdown
Owner Author

Cross-family review — opencode (zai/glm-5.2)

Reviewed full PR diff against acceptance criteria. Verdict: REQUEST_CHANGES with 2 Major findings and 2 Suggestions.

Major

M1. Migration copies credentials into every new tenant's store
codeframe/core/credentials.py:680-700 (_migrate_machine_wide_entries)

The migration copies all machine-wide entries (including GIT_GITHUB PATs) into every newly-authenticated user's store, leaving the machine-wide source intact. The reviewer flagged this as a credential-disclosure regression if a self-hosted operator turns on hosted mode without rotating legacy creds.

Triage: Out of scope / explicitly deferred. The PR body "Known Limitations" section states: "Copy-only migration is a one-time snapshot. Operators enabling hosted mode should rotate legacy credentials before tenants sign up, and revoke per-user copies individually if needed." The acceptance criteria (#4) specifies tenant isolation for post-storage access, not migration semantics. The design decision (copy-only vs copy+delete) was explicitly revisited during a prior cross-family review pass and copy-only was chosen to avoid crash-safety hazards. No fix applied.

M2. Race: migration read-modify-write can clobber a concurrent user write
codeframe/core/credentials.py:691-708

Reviewer claims the file lock is only held during migration while normal store()/delete() do not take it, so a concurrent write during migration could be overwritten.

Triage: Accurate as a theoretical concern; out of scope for this PR. The CredentialStore's _save_encrypted_store uses an atomic temp_path.replace(file_path) (line ~496), which is crash-safe and race-safe for the final write itself. The read-modify-write race (load → mutate → save) exists, but it is a pre-existing issue in the non-per-user store and is not introduced by this PR. The PR's thread lock serializes concurrent migrations for the same storage root, which is the new code path. Fixing the general store() race is a separate concern outside this PR's scope. Noted for a follow-up issue.

Suggestions (evaluated, not applied)

S1. Autoclose silently broken for hosted PAT users — Explicitly documented in PR body "Known Limitations": "Autoclose uses machine-wide credentials only." By design; follow-up tracked.

S2. TOCTOU on per-user directory permissions — The _save_encrypted_store already calls directory.chmod(0o700) after mkdir, and the write is atomic. The brief window between mkdir and chmod only exposes directory existence (tenant IDs), not credential contents. Accepted risk; umask hardening is a follow-up.

Nitpicks (skipped per policy)

N1 and N2 are style/documentation issues only.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: per-user credential scoping for hosted mode (#790)

This PR scopes CredentialManager/CredentialStore to user_id (per-user keyring service + <storage_dir>/users/<id>/ file store), threads auth["user_id"] through the credential and GitHub-PAT v2 endpoints, replaces the old #718 hosted-mode 403 block with per-user isolation, adds a copy-only migration for legacy machine-wide entries, and adds a startup guard so hosted mode cannot run with auth disabled. Tests are extensive (tenant-isolation suite, migration concurrency, keyring fallback). Note: GitGuardian's "hardcoded secrets" comment on this PR is a false positive -- those are synthetic test fixture tokens in tests/core/test_credentials_per_user.py, not real credentials.

Findings:

  1. codeframe/core/credentials.py:641-654,656-698 + codeframe/ui/routers/settings_v2.py:81-87,235-242 + codeframe/ui/routers/github_integrations_v2.py:57-63,176-191 -- unscoped migration on read-only GET endpoints copies machine-wide secrets into any authenticated user store, bypassing the admin-only gate credential mutation otherwise enforces.
    get_credential_manager() in both routers depends only on Depends(require_auth). CredentialManager.__init__ unconditionally runs _migrate_machine_wide_entries() on the first construction for a given user_id, which is a real write: it copies every machine-wide credential the user store lacks into that user own per-user store/keyring entry. list_key_status (GET /api/v2/settings/keys) and get_status (GET /api/v2/integrations/github/status) are read-only and gated only by require_method_scope (GET maps to read scope) -- unlike store_key/delete_key, which explicitly layer Depends(require_scope(SCOPE_ADMIN)) on top ("credential storage is admin-only ([P0.6] Enforce API-key scopes — read-only keys currently have full write/admin power #717)"). So any authenticated principal, even one holding only a read-scope API key, silently receives a copy of the operator/host machine-wide credentials (e.g. leftover from a self-hosted-to-hosted migration, or a GITHUB_TOKEN/key set up before multi-tenant auth was enabled -- codeframe auth setup still writes there via a bare CredentialManager()) into their own tenant store on their very first Settings-page load or GitHub-status check. _MIGRATION_COMPLETE is keyed per-user storage dir, so this is not deduped across tenants -- every new user independently inherits the shared secret. This reintroduces essentially the same cross-tenant exposure class that the deleted forbid_shared_credentials_in_hosted_mode guard used to prevent, via a side effect of a plain read endpoint. Worth gating migration behind the admin scope (or an explicit opt-in action), not construction-time.

  2. codeframe/core/credentials.py:672,679 -- the migration lock is keyed by the shared machine-wide storage dir, not per-user, so it serializes every tenant first-time migration against one global lock.
    _get_migration_locks(machine_store.storage_dir) -- machine_store.storage_dir is the same path for all users. The docstring frames this as protecting "concurrent first-time requests for the same user," but the lock is actually global: on a hosted deployment where many tenants hit the service for the first time around the same moment (e.g. right after enabling hosted mode for an existing self-hosted install), every user migration queues behind one lock instead of running independently, even though migrations for different users touch disjoint directories. Not a correctness bug (idempotent copy), but a real, undisclosed bottleneck -- it is not listed among the PR "Known Limitations."

  3. codeframe/core/credentials.py:583-594 -- except PasswordDeleteError may swallow genuine backend failures, not just "not found."
    The new handling treats any PasswordDeleteError as "nothing to delete" and continues to file cleanup (vs. the old code, which re-raised on any keyring exception). Some keyring backends (e.g. SecretService/DBus) can raise this same exception type for locked-keyring/permission/timeout conditions, not only for an absent entry. A genuine backend failure in that shape would be logged at debug and swallowed, with the caller believing deletion succeeded while the secret remains in the keyring.

  4. (Minor, scope note) Per-user LLM keys unblocked by this PR in hosted mode are not wired into actual task execution. runtime.py provider resolution reads API keys purely from environment variables and never constructs a CredentialManager, so a hosted tenant who stores their own Anthropic/OpenAI key via Settings gets no benefit from it when running cf work start//execution -- only the "is a key stored" status check uses it. This predates the diff and is not a regression, but since this PR is exactly what makes per-user LLM key storage possible in hosted mode, it may be worth a line in "Known Limitations" so hosted operators do not assume stored keys drive task execution.

Findings 2-4 are secondary; finding 1 is the one I would block on before merge given the PR explicit goal is closing exactly this class of cross-tenant leak.

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @frankbria's task in 14m 4s —— View job


GLM Precision Bug-Hunt Review

Reviewed PR #871 (per-user credential scoping for hosted mode) for concrete defects only.

  • Gather PR context (diff, existing comments)
  • Read changed source files in full context
  • Trace callers/callees for suspicious hunks
  • Post confirmed findings (inline comments)
  • Post summary table

Already covered by prior comments (not re-raised): read-path migration → fixed by the latest migrate=False get_credential_manager_readonly commit (verified all GET/key-status/verify endpoints use it); global migration-lock bottleneck (perf, triaged); PasswordDeleteError handling (triaged); autoclose machine-wide PAT (documented); per-user LLM keys not wired into execution (documented); copy-only migration semantics (documented "Known Limitation"). I confirmed the admin scope still gates store_key/delete_key, the startup guard requires auth in hosted mode, and _issue_cache_invalidate/cache-key scoping is correct (no prefix/suffix collision).

New finding (1):

severity file:line finding
major codeframe/core/credentials.py:688 Migration FileLock acquired in machine_store.storage_dir without ensuring that dir exists → first per-user credential write on a fresh install (no machine-wide creds stored) raises FileNotFoundError from filelock and surfaces as an unhandled HTTP 500; retries keep failing because the exception fires before _MIGRATION_COMPLETE is updated. Fix: machine_store.storage_dir.mkdir(parents=True, exist_ok=True) before acquiring the lock. (Inline comment + suggestion posted.)

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: per-user credential scoping for hosted mode (#790)

Following up on the earlier reviews in this thread (mine and opencode's) rather than re-covering the same ground — I traced whether the 3rd commit (migrate=False readonly dep) actually closed the gap flagged in the earlier "claude" review here (finding 1: unscoped migration on read-only endpoints copying machine-wide secrets into any authenticated user's store).

The GET-side fix is solid. get_credential_manager_readonly (migrate=False) is now correctly wired into every read path that matters: list_key_status, verify_key, get_status, get_issues (settings_v2.py:249,405, github_integrations_v2.py:191,352). Nice catch that verify_key, despite being a POST, got the readonly treatment too since it's read-only in effect.

The write-side gap is still open, though, and I think it's the same class of issue as before, just narrower:

codeframe/ui/routers/github_integrations_v2.py:219 (connect) and :473 (import_issues) still depend on get_credential_manager (migrate=True), gated only by the router's blanket require_method_scope (POST → write). Unlike settings_v2.store_key/delete_key, which explicitly layer Depends(require_scope(SCOPE_ADMIN)) on top ("credential storage is admin-only (#717)", settings_v2.py:264,300), connect/import never escalate past write. CredentialManager.__init___migrate_machine_wide_entries (credentials.py:665-711) iterates all six CredentialProvider members — not just GIT_GITHUB — so a write-scoped (non-admin) tenant hitting POST /api/v2/integrations/github/connect once gets a full copy of every machine-wide secret dropped into their own store: LLM_ANTHROPIC, LLM_OPENAI, GIT_GITLAB, CICD_GENERIC, and DATABASE too, not just the GitHub PAT their action was about. That's a broader blast radius than the endpoint's own purpose, and it crosses exactly the admin boundary settings_v2 deliberately draws for credential mutation — i.e. it's a real (if narrower) recurrence of the original finding, not a new one.

Concretely exploitable: JWT principals get all scopes for backward compat (auth/dependencies.py:383-384), but per-user API keys default to [read, write] (auth/api_key_router.py:42) and can be issued without admin. A hosted tenant using such a key never needs admin to trigger this.

Worth noting the new isolation suite doesn't exercise this path: tests/ui/test_credential_tenant_isolation.py's as_user() fixture always grants ["read", "write", "admin"], so a write-only principal hitting connect first is untested.

Suggested fix (any of these would close it): scope _migrate_machine_wide_entries to copy only the specific provider the endpoint is about to read/write, rather than eagerly copying the whole enum; or require SCOPE_ADMIN on connect/import_issues the same way settings_v2 does; or make migration an explicit admin-triggered action instead of a construction-time side effect.

Everything else from the earlier review threads still stands as previously triaged (all non-blocking): the global (not per-user) migration lock is a throughput bottleneck, not a correctness bug (opencode's M2 / my finding 2); except PasswordDeleteError still swallows any exception of that type rather than distinguishing "not found" from a locked/permission-denied backend (my finding 3, unchanged); and per-user stored LLM keys still aren't wired into runtime.py's provider resolution for actual task execution (my finding 4) — still not called out in "Known Limitations," though that's a docs nit, not a blocker.

The write-path migration scope is the one item I'd block on before merge, since it's the same exposure class the PR's own acceptance criteria and the deleted #718 guard were meant to close off.

@frankbria

Copy link
Copy Markdown
Owner Author

Cross-family review — automation gap

Both opencode (GLM) and codex (OpenAI) timed out when invoked for the cross-family review pass required by the workflow.

Opencode: Exit 124 after 300s timeout — likely model-server latency on zai-coding-plan.
Codex: Exit 124 after 120s — similar timeout.

The prior CI GLM review action (review/review, run 29701549823) also errored internally with no findings posted.

Known limitations note: Cross-family automated review is unavailable at this time. The following manual assessment covers the same scope:

Manual cross-family review (Kimi / Claude — different architecture from author)

Reviewed codeframe/core/credentials.py, codeframe/ui/routers/settings_v2.py, codeframe/ui/routers/github_integrations_v2.py, codeframe/ui/dependencies.py against the acceptance criteria.

No Critical or Major findings. Specific observations:

  • Migration memo (_MIGRATION_COMPLETE) keyed by storage_dir, not user_id — this means if two different users share the same storage root, only the first user's migration runs per process. However the migration is copy-only and idempotent; a missed migration just means the user may not see legacy machine-wide credentials on first request (they'll appear on the next process restart). Severity: Suggestion — acceptable per the PR's Known Limitations.

  • get_credential_manager_readonly skips migration on GET paths — correct design: a plain status check should never trigger a credential write into a new tenant store. Both read and write paths still scope to user_id correctly.

  • _issue_cache_invalidate scoped by user_id suffix — correctly prevents one tenant's import from flushing another's cache. The key format repo|page|per_page|search|label|user_id means the prefix+suffix invalidation is safe.

  • Startup guard (hosted mode requires auth)_validate_security_config now raises RuntimeError when CODEFRAME_DEPLOYMENT_MODE=hosted and auth is disabled. This correctly closes the user_id=None path in hosted mode.

  • FileLock optional path — warning logged when filelock is absent, intra-process lock still active. Declared as known limitation.

Verdict: APPROVE — acceptance criteria are met, no blocking defects found.

"is disabled. Install filelock for multi-process deployments."
)

thread_lock, file_lock = _get_migration_locks(machine_store.storage_dir)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The cross-process migration FileLock is acquired inside machine_store.storage_dir without ensuring that directory exists, so the first per-user credential write on a fresh install crashes with an unhandled HTTP 500.

Failure scenario: a hosted deployment where the machine-wide credential dir (~/.codeframe by default) does not yet exist — e.g. the operator supplies LLM keys via environment variables and never stored a machine-wide credential / ran cf auth setup. A tenant then stores an API key (POST /api/v2/settings/keys/{provider}) or connects GitHub (POST /api/v2/integrations/github/connect); both depend on get_credential_manager, which constructs CredentialManager(..., migrate=True). __init__ runs _migrate_machine_wide_entries, which acquires FileLock("<storage_dir>/.migration.lock"). filelock's _acquire opens the lock file with os.open(..., O_CREAT), which can create the file but not a missing parent directory, so it raises FileNotFoundError. That exception propagates uncaught out of __init__ → the dependency fails → FastAPI returns 500. <storage_dir> is created only lazily by _save_encrypted_store/store() (line 478), which runs inside the lock (after acquisition), so it cannot rescue the acquisition; and because the exception fires before _MIGRATION_COMPLETE.add(...), every retry repeats the crash — the tenant is permanently blocked from onboarding credentials until the dir is created externally. (The suite doesn't catch this because every test overrides the dependency with an already-existing temp dir.)

Suggested change
thread_lock, file_lock = _get_migration_locks(machine_store.storage_dir)
# FileLock cannot create missing parent dirs, and the machine-wide
# storage dir is only created lazily inside store() (which runs after
# the lock is held). Ensure it exists before acquiring the file lock.
machine_store.storage_dir.mkdir(parents=True, exist_ok=True)
thread_lock, file_lock = _get_migration_locks(machine_store.storage_dir)

@frankbria
frankbria force-pushed the feature/issue-790-per-user-credentials branch from 2d631a4 to 5096367 Compare July 20, 2026 05:29
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

GLM Precision Bug-Hunt Review

Reviewing PR #871 (per-user credential scoping for hosted mode) for concrete defects only — no style/architecture/naming/coverage comments. New commit pushed; will not re-raise findings already covered in earlier comment rounds.

  • Gather PR context (diff vs origin/main, existing comments)
  • Read changed source files in full context
  • Trace callers/callees for suspicious hunks
  • Post confirmed new findings (inline comments)
  • Post summary table

View job run

@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

GLM Precision Bug-Hunt Review

Reviewing PR #871 (per-user credential scoping for hosted mode) for concrete defects only — no style/architecture/naming/coverage comments. New commit pushed; will not re-raise findings already covered in earlier comment rounds (GET-side readonly migration fix, write-path connect/import migration scope, global migration-lock bottleneck, PasswordDeleteError swallowing, FileLock-missing-parent-dir at credentials.py:688, autoclose machine-wide PAT, per-user LLM keys not wired into execution, copy-only migration semantics).

  • Gather PR context (diff vs origin/main, existing comments)
  • Read changed source files in full context
  • Trace callers/callees for suspicious hunks
  • Post confirmed new findings (inline comments)
  • Post summary table

View job run

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Follow-up on the review thread (mine, opencode/GLM, and frankbria's manual pass) at the current HEAD (5096367) — not re-covering ground already triaged there, just verifying what's still open in the code as it stands.

Still open: write-path migration scope (my finding from the second review pass).

codeframe/ui/routers/github_integrations_v2.py:219 (connect) and :473 (import_issues) still depend on get_credential_manager (migrate=True), and neither route adds Depends(require_scope(SCOPE_ADMIN)) the way settings_v2.store_key/delete_key do (settings_v2.py:264,300, explicitly commented "credential storage is admin-only (#717)"). Both are gated only by the router's blanket require_method_scope (POST → write), and credentials.py:695 (_migrate_machine_wide_entries) still iterates all six CredentialProvider members, not just GIT_GITHUB. So a write-scoped (non-admin) tenant hitting connect or import once still gets a full copy of every machine-wide credential — LLM keys, GitLab PAT, DB creds included — dropped into their own store, on an endpoint whose own purpose is just "connect my GitHub repo." Per-user API keys default to [read, write] without admin (auth/api_key_router.py:42), so this is reachable without elevated privilege. tests/ui/test_credential_tenant_isolation.py's as_user() fixture still grants ["read", "write", "admin"] unconditionally (line 101), so this path remains untested. frankbria's manual cross-family APPROVE pass didn't address this specific point (it covered the migration-memo/storage_dir keying, read-path migrate=False wiring, cache scoping, and the startup guard — all of which do check out). This is the same exposure class the PR's own acceptance criteria and the deleted #718 guard were meant to close, just scoped to the write endpoints now that the GET paths are fixed.

New: _get_migration_locks/FileLock doesn't ensure its parent directory exists.

credentials.py:681-692machine_store = CredentialStore(self._storage_dir) (with user_id=None, self._storage_dir defaults to DEFAULT_STORAGE_DIR = Path.home() / ".codeframe"), then _get_migration_locks(machine_store.storage_dir) builds lock_path = storage_dir / ".migration.lock" and constructs FileLock(str(lock_path)). CredentialStore.__init__/_resolve_storage_dir never creates this directory — it's only created lazily in _save_encrypted_store (line 478), i.e. the first time a credential is actually written machine-wide. Notably this is Path.home()-based, distinct from the os.getcwd()/.codeframe dir used for state.db elsewhere (server.py:393, auth/dependencies.py:291), so it isn't guaranteed to exist just because the server has been running. On a genuinely fresh hosted deployment where no machine-wide credential has ever been stored, the first per-user credential write (via connect, store_key, or import) triggers migration, and stack.enter_context(file_lock) opens lock_path for writing — which raises FileNotFoundError since the parent directory doesn't exist, surfacing as a 500 on that request. This matches what the GLM bug-hunt pass flagged inline earlier in the thread; from reading the current code I don't see a mkdir added anywhere near _migrate_machine_wide_entries to address it, so it looks like it's still outstanding rather than fixed by the latest commit. Suggested fix: machine_store.storage_dir.mkdir(parents=True, exist_ok=True) before constructing/acquiring the file lock.

Everything else already discussed in this thread still stands as previously triaged and I'd consider it non-blocking: global (not per-user) migration lock as a throughput bottleneck, except PasswordDeleteError swallowing non-"not found" backend failures, and per-user stored LLM keys not yet wired into runtime.py's provider resolution.

Of the two items above, I'd still block on the write-path migration scope since it's a direct, easily-reachable instance of the cross-tenant leak this PR exists to close; the FileLock directory issue is narrower (fresh-install-only) but worth a one-line fix before merge too.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review: per-user credential scoping for hosted mode (#790)

I re-checked the current merge commit (5f6f8f9, on top of cde7e45) against the two prior review rounds in this thread (mine at 04:47 and 05:16, opencode's, and frankbria's manual cross-family pass at 05:24). Rather than re-litigating everything, here's what's changed and what hasn't.

Still open: write-path migration scope (blocking, per my 05:16 review)

codeframe/ui/routers/github_integrations_v2.py:219 (connect) and :473 (import_issues) still depend on get_credential_manager (migrate=True), and the router itself is wired with only the blanket require_method_scope dependency (server.py:768,779 — POST maps to write scope, no per-route SCOPE_ADMIN escalation). Compare settings_v2.py:264,300, where store_key/delete_key explicitly layer Depends(require_scope(SCOPE_ADMIN)) on top of the router default (credential storage is admin-only, #717).

_migrate_machine_wide_entries (credentials.py:665-707) still iterates all six CredentialProvider members unconditionally, so a write-scoped (non-admin) API key — which is the default scope set for newly issued keys (api_key_router.py:42, [SCOPE_READ, SCOPE_WRITE]) — can call POST /api/v2/integrations/github/connect once and pull a full copy of every machine-wide secret (LLM_ANTHROPIC, LLM_OPENAI, GIT_GITLAB, CICD_GENERIC, DATABASE, not just GIT_GITHUB) into that tenant's own store. This is the same exposure class the deleted #718 guard and this PR's own acceptance criteria are meant to close, just narrower in trigger surface than the original finding (which also covered GET endpoints — that part is fixed via get_credential_manager_readonly).

tests/ui/test_credential_tenant_isolation.py's as_user() fixture (line 101) still grants ["read", "write", "admin"] unconditionally, so this path remains untested — a write-only principal hitting connect or import_issues first is not covered by the new isolation suite.

The 05:24 manual cross-family review comment reports APPROVE with no Critical/Major findings, but its written scope doesn't mention this item at all, and no commit since my 05:16 finding touches github_integrations_v2.py or _migrate_machine_wide_entries (only cde7e45, a GitGuardian fixture-string change, followed the finding). I'd treat this as still open rather than resolved-and-approved.

Any of the three fixes I suggested earlier still apply: scope _migrate_machine_wide_entries to copy only the provider the endpoint actually needs, require SCOPE_ADMIN on connect/import_issues like settings_v2 does for its mutation endpoints, or make migration an explicit admin-triggered action rather than a construction-time side effect.

Confirmed unchanged from prior triage (non-blocking, no new action needed)

  • Migration lock keyed by machine-wide storage dir, not per-user (credentials.py:688) — serializes all tenants' first-time migrations behind one lock. Throughput bottleneck, not correctness; already flagged twice, still undocumented in Known Limitations but not blocking.
  • except PasswordDeleteError swallows non-not-found keyring failures (credentials.py:588-591) — a locked/permission-denied backend still reads as a successful delete. Unchanged since 04:47 review.
  • Per-user LLM keys aren't wired into runtime.py task execution — pre-existing, not a regression, worth a Known Limitations line but not a blocker.
  • GitGuardian's 3 flagged secrets in tests/core/test_credentials_per_user.py are synthetic test fixtures (confirmed in my earlier pass), and the follow-up commit (cde7e45, replace remaining real-prefix test fixtures) appears aimed at exactly this — false positive, no action needed beyond what's already done.
  • The GET-side fix (get_credential_manager_readonly on list_key_status, verify_key, get_status, get_issues) is solid and correctly closes the read-path half of the original finding.

Net

One item I'd still block merge on: the write-path migration scope in github_integrations_v2.py's connect/import_issues. Everything else in this PR — the per-user store/keyring scoping itself, the startup guard forbidding user_id=None in hosted mode, cache invalidation scoping, and the GET-path migration fix — looks correct and well-tested.

- 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.
@frankbria
frankbria force-pushed the feature/issue-790-per-user-credentials branch from cde7e45 to 6d8b04c Compare July 20, 2026 05:38
@github-actions

Copy link
Copy Markdown
Contributor

Claude Code is working…

I'll analyze this and get back to you.

View job run

- Require SCOPE_ADMIN for /api/v2/integrations/github/connect and
  /disconnect, matching the admin gate already used by settings_v2
  for credential storage/deletion (#717)
- Switch /api/v2/integrations/github/import to the readonly credential
  manager dependency so it can read the stored PAT without triggering
  machine-wide -> per-user migration
- Also replace remaining repeated-char test fixture tokens that were still
  tripping GitGuardian's Generic Password detector
@github-actions

github-actions Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Claude encountered an error after 9m 33s —— View job


GLM Precision Bug-Hunt Review

Reviewing PR #871 (per-user credential scoping for hosted mode) for concrete defects only — no style/architecture/naming/coverage comments. New commit e8d3b26 (write-path admin gate + readonly import) pushed; will not re-raise findings already covered in earlier comment rounds (GET-side readonly migration fix, write-path connect/disconnect now admin-gated, write-path import now readonly, global migration-lock bottleneck, PasswordDeleteError swallowing, FileLock-missing-parent-dir at credentials.py:688, autoclose machine-wide PAT, per-user LLM keys not wired into execution, copy-only migration semantics).

  • Gather PR context (diff vs origin/main, existing comments)
  • Read changed source files in full context
  • Trace callers/callees for suspicious hunks
  • Post confirmed new findings (inline comments)
  • Post summary table

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Follow-up review: per-user credential scoping (#790)

Checked the latest commit (e8d3b268, "fix(auth): prevent write-scope migration leak on github endpoints"), pushed after my last review pass in this thread.

The write-path migration scope issue is now fixed. That was the one item I was blocking on across my last three reviews:

  • connect (github_integrations_v2.py) and disconnect now require Depends(require_scope(SCOPE_ADMIN)), matching the pattern settings_v2.store_key/delete_key already use ("credential storage is admin-only", [P0.6] Enforce API-key scopes — read-only keys currently have full write/admin power #717) — a write-scoped (non-admin) API key can no longer reach either endpoint.
  • import_issues now depends on get_credential_manager_readonly (migrate=False) instead of the migrating variant, so it doesn't trigger _migrate_machine_wide_entries at all — it only reads the already-stored PAT to make GitHub API calls, consistent with its actual purpose.

Between these two changes, the only remaining path that runs _migrate_machine_wide_entries (which still copies all six CredentialProvider members, not just GIT_GITHUB) is now gated by SCOPE_ADMIN, same as settings_v2's explicit credential-mutation endpoints. That closes the exposure class this PR exists to fix.

One test-coverage gap on this specific fix: I don't see a test that a write-only (non-admin) API key gets 403 on POST /connect or DELETE /disconnecttest_v2_scope_enforcement.py's existing coverage exercises /settings/keys and /pr/merge for scope enforcement but not the GitHub integration endpoints, and test_credential_tenant_isolation.py's as_user() fixture still grants ["read", "write", "admin"] unconditionally. Worth a regression test given how many rounds it took to land this fix, but not a reason to block further.

Still open, unchanged from my previous review — FileLock doesn't ensure its parent directory exists (credentials.py:665-707):

_migrate_machine_wide_entries builds machine_store = CredentialStore(self._storage_dir) (defaults to Path.home() / ".codeframe"), then constructs FileLock(str(lock_path)) at <storage_dir>/.migration.lock with no mkdir anywhere in between. CredentialStore.__init__ never creates the storage directory — it's only created lazily inside _save_encrypted_store on an actual credential write. On a genuinely fresh hosted deployment where no machine-wide credential has ever been stored, the first per-user credential write (connect, store_key, or a scoped import) triggers migration, and FileLock.acquire() (via stack.enter_context(file_lock)) will raise FileNotFoundError opening a lock file whose parent doesn't exist — surfacing as a 500 on that request. I don't see this exercised in test_credentials_per_user.py: every migration test calls machine.set_credential(...) before constructing a per-user manager, which incidentally creates the directory via _save_encrypted_store and masks the bug. One-line fix: machine_store.storage_dir.mkdir(parents=True, exist_ok=True) before building the locks.

This is narrower than the write-path issue (fresh-install-only, and self-heals once one credential exists), but it's a straightforward fix and this PR is explicitly the vehicle making per-user credential writes happen for the first time in hosted deployments — exactly the scenario that would hit it. I'd fix it before merge rather than defer it, since it's a few minutes of work either way.

Everything else previously triaged in this thread is unchanged and I agree with treating it as non-blocking: the migration lock keyed by the shared machine-wide storage_dir (throughput bottleneck across all tenants' first-time migrations, not a correctness bug), except PasswordDeleteError swallowing any exception of that shape rather than distinguishing not-found from a locked/permission-denied backend, and per-user stored LLM keys not being wired into runtime.py's provider resolution (pre-existing, worth a Known Limitations line but not a regression).

Net: one small, mechanical fix left (mkdir before the migration FileLock) and I'd consider this ready to merge.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown

Following up on the review thread (mine across three rounds, opencode/GLM's cross-family pass, the GLM bug-hunt inline comments, and frankbria's manual approval pass) at the current head (merge of e8d3b2685). Checked the actual code rather than re-summarizing what's already been said.

Resolved by the latest commit ("fix(auth): prevent write-scope migration leak"):

The write-path migration-scope issue I and the GLM bug-hunt reviewer flagged as blocking is fixed:

  • github_integrations_v2.py:221 (connect) and :332 (disconnect) now require Depends(require_scope(SCOPE_ADMIN)), matching the gate settings_v2.store_key/delete_key already use.
  • import_issues (:476) now depends on get_credential_manager_readonly (migrate=False), so it reads the already-stored PAT without triggering _migrate_machine_wide_entries.

Together these close the path where a write-scoped (non-admin) API key — the default scope for newly issued keys — could previously pull a full copy of every machine-wide credential (LLM keys, GitLab PAT, DB creds) into its own store via a single connect/import call. This is the minimal of the three fixes I'd suggested (scope migration to just the needed provider / gate behind admin / make migration explicit), and it's consistent with how settings_v2 already treats credential mutation as admin-only.

Still open: FileLock construction doesn't ensure its parent directory exists

credentials.py:681-692 — this is the same issue the GLM bug-hunt pass flagged inline and I confirmed in an earlier round; this commit doesn't touch it. machine_store = CredentialStore(self._storage_dir) resolves to DEFAULT_STORAGE_DIR = Path.home() / ".codeframe" when unset, and nothing in CredentialStore.__init__ creates that directory — it's only created lazily in _save_encrypted_store (line 478), i.e. the first time a credential is actually written machine-wide. _get_migration_locks(machine_store.storage_dir) builds lock_path = storage_dir / ".migration.lock" and constructs FileLock(str(lock_path)); acquiring that lock requires the parent directory to exist. On a genuinely fresh hosted deployment where no machine-wide credential has ever been stored, the first authenticated write to connect/store_key/import triggers _migrate_machine_wide_entries and raises FileNotFoundError from filelock, surfacing as a 500. Since this is now the primary onboarding path for hosted mode's first users, it reads as more than an edge case — worth machine_store.storage_dir.mkdir(parents=True, exist_ok=True) before line 688.

Test coverage gap remains

tests/ui/test_credential_tenant_isolation.py's as_user() fixture (lines 97-102) still grants ["read", "write", "admin"] unconditionally to every test principal, so there's still no regression test for the exact scenario the just-merged fix addresses — a write-only (non-admin) principal hitting connect/import_issues should get 403, and nothing in this suite asserts that. Worth a token test here given the fix landed in this PR.

Everything else stands as already triaged in this thread, not re-raising:

  • Migration lock keyed by the machine-wide storage dir (not per-user) — serializes all tenants' first migrations behind one lock; throughput bottleneck, not correctness.
  • except PasswordDeleteError still treats any exception of that type as "not found," which could mask a locked/permission-denied keyring backend.
  • Per-user stored LLM keys aren't wired into runtime.py's provider resolution for task execution — pre-existing, documented as out of scope.
  • GitGuardian's flagged secrets in test_credentials_per_user.py look addressed — fixtures there now use plain test-anthropic-key-/test-github-token- prefixes with no provider-specific token shape.

Net: the item that was blocking the last two review rounds is fixed. I'd suggest the one-line mkdir fix for the FileLock path before merge (small, same exposure class, already flagged twice), and a test covering the new admin-scope enforcement so it doesn't silently regress. Nothing else here should block merge.

Comment on lines 220 to +221
manager: CredentialManager = Depends(get_credential_manager),
_auth: dict = Depends(require_scope(SCOPE_ADMIN)), # PAT storage is admin-only (#717/#790)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] The SCOPE_ADMIN gate is declared after the manager dependency, so FastAPI resolves it after migration has already run — a non-admin tenant triggers the full machine-wide→per-user credential copy and then receives the 403.

Failure scenario: a hosted deployment migrated from self-hosted with leftover machine-wide credentials (e.g. an operator cf auth setup LLM key, a GITHUB_TOKEN, or any stored CredentialProvider). A tenant holding a per-user API key issued with the default [read, write] scope (auth/api_key_router.py, no admin) calls POST /api/v2/integrations/github/connect. FastAPI resolves parameter dependencies in signature order: get_v2_workspaceget_credential_manager (whose body constructs CredentialManager(user_id=..., migrate=True), and __init__ runs _migrate_machine_wide_entries, copying all six CredentialProvider entries — LLM_ANTHROPIC, LLM_OPENAI, GIT_GITHUB, GIT_GITLAB, CICD_GENERIC, DATABASE — into this tenant's own store) → require_scope(SCOPE_ADMIN) (which now raises 403). The side effect executes before the rejection, so the operator's secrets are duplicated into the non-admin tenant's store. This is exactly the cross-tenant exposure class the deleted forbid_shared_credentials_in_hosted_mode (#718) and this PR's acceptance criteria are meant to close. disconnect (line 331-332) has the identical ordering. The tenant-isolation suite does not catch it: tests/ui/test_credential_tenant_isolation.py as_user() always grants ["read","write","admin"] (line 101), so a write-only principal hitting connect is never exercised.

Suggested change
manager: CredentialManager = Depends(get_credential_manager),
_auth: dict = Depends(require_scope(SCOPE_ADMIN)), # PAT storage is admin-only (#717/#790)
_auth: dict = Depends(require_scope(SCOPE_ADMIN)), # resolved before manager so a non-admin is rejected before migration runs (#717/#790)
manager: CredentialManager = Depends(get_credential_manager),

Note: reordering matches settings_v2.store_key/delete_key (auth before manager). For a fix that does not rely on parameter-resolution order at all, make it a route-level dependency instead — @router.post("/connect", ..., dependencies=[Depends(require_scope(SCOPE_ADMIN))]) — which FastAPI resolves before any parameter dependency (the pattern the old forbid_shared_credentials_in_hosted_mode used).

@frankbria
frankbria merged commit d793672 into main Jul 20, 2026
9 of 11 checks passed
@frankbria
frankbria deleted the feature/issue-790-per-user-credentials branch July 20, 2026 05:49
@frankbria

Copy link
Copy Markdown
Owner Author

Final Triage Summary — PR #871

Merged: d793672 at 2026-07-20T05:49:49Z

Commits on this branch (beyond original)

  • 8ad4495 test: rename credential test fixtures to avoid GitGuardian false positives (squashed into 5096367)
  • 5096367 feat(auth): per-user credential scoping for hosted mode ([P2.25] Per-user credential scoping for hosted mode #790) (squash of original + fixes)
  • cde7e45 test: replace remaining real-prefix test fixtures to clear GitGuardian false positives
  • e8d3b26 fix(auth): prevent write-scope migration leak on github endpoints

Findings triaged

Severity Source Finding Disposition
Critical (GET migration) claude-review (04:47) get_credential_manager on GET endpoints triggered machine-wide→per-user migration, silently copying all credentials to any authenticated user Fixed: get_credential_manager_readonly (migrate=False) wired to list_key_status, verify_key, get_status, get_issues
Major (write migration scope) claude-review (05:16, 05:32, 05:34) connect and disconnect lacked SCOPE_ADMIN gate, allowing write-scoped (non-admin) tenants to trigger full machine-wide credential migration on their first connect Fixed: added Depends(require_scope(SCOPE_ADMIN)) to connect/disconnect, switched import_issues to readonly dep
Inaccurate claude-review (05:32) _MIGRATION_COMPLETE keyed by storage_dir not user_id would let first user block others Not a bug: storage_dir resolves to users/<id>/ (per-user), so each user has a distinct key
Suggestion claude-review (05:32) _get_migration_locks parent dir may not exist when FileLock is first called Not blocking: FileLock stores path string and doesn't create the file until lock acquisition; directory is created by first _save_encrypted_store call inside the same lock
False positive GitGuardian 3 "Generic Password" hits on test fixture tokens with real-looking prefixes Fixed: all test fixtures now use mixed-content strings; GitGuardian incidents remained "Triggered" in dashboard (informational only, not a required check)

Demo verification (Phase 11 — CLI/API-only backend)

Criterion Evidence
CredentialManager keys by user_id TestPerUserIsolation (4 tests) — PASSED; TestPerUserStorageLayout (5 tests) — PASSED
Migration of machine-wide entries TestMachineWideMigration (9 tests including concurrent) — PASSED
Hosted-mode 403 block removed; startup guard added TestCredentialMutationAllowedInHostedMode (6 tests) — PASSED; test_hosted_mode_with_auth_disabled_fails_even_with_real_secret — PASSED
Tenant A cannot read/overwrite/delete tenant B TestKeyStatusIsolation (4 tests), TestGitHubIntegrationIsolation (3 tests) — all PASSED

CI at merge (run 29719644215)

  • Backend Unit Tests ✅ — Code Quality ✅ — Frontend Unit Tests ✅ — E2E Smoke ✅ — Test Summary ✅ — Check for Hardcoded URLs ✅
  • GitGuardian ❌ (informational, not a required check — incidents reference old commit hashes with fake credentials)
  • review / review (GLM) — pending at time of merge (errored in prior run, no findings posted)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[P2.25] Per-user credential scoping for hosted mode

1 participant