Skip to content

Commit d7eb29f

Browse files
authored
fix(security): owner-scope workspace registry list/delete (#720) (#792)
* fix(security): owner-scope workspace registry list/delete (#720) list_workspaces returned every entry's repo_path and deregister deleted by id with no ownership check, so in hosted mode one tenant could enumerate and delete another's registered workspaces. upsert also overwrote owner_user_id with excluded (None on refresh), nulling a recorded owner. - list_workspaces / deregister_workspace pass auth["user_id"] to list_all(owner_user_id=) / delete(owner_user_id=); auth off (None) keeps current behavior - registry.delete() gains an owner_user_id filter (WHERE id=? AND owner=?) - upsert: owner_user_id = COALESCE(excluded.owner_user_id, existing) Closes #720 * docs: note owner-overwrite invariant in registry upsert SQL (#720 review) * test: allow default JWT secret in owner-scope lifespan test (CI fix, #720) The 'with TestClient(app)' triggers lifespan startup, which rejects the default secret when auth is on; set CODEFRAME_ALLOW_INSECURE_SECRET=1 in the fixture so CI (no AUTH_SECRET) passes. Passed locally only via ambient AUTH_SECRET.
1 parent 823dad1 commit d7eb29f

4 files changed

Lines changed: 169 additions & 10 deletions

File tree

codeframe/platform_store/repositories/workspace_registry_repository.py

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,12 @@ def upsert(
6161
-- COALESCE so a refresh that omits name/tech_stack (None) keeps the
6262
-- previously-stored value instead of nulling it.
6363
name = COALESCE(excluded.name, workspaces_registry.name),
64-
owner_user_id = excluded.owner_user_id,
64+
-- COALESCE so a refresh that omits the owner (None) keeps the
65+
-- recorded owner instead of nulling it (#720). A refresh with a
66+
-- *different* non-None owner still overwrites; that's safe only
67+
-- because path-based tenant isolation (#655) stops user B from
68+
-- re-registering a path they don't own.
69+
owner_user_id = COALESCE(excluded.owner_user_id, workspaces_registry.owner_user_id),
6570
tech_stack = COALESCE(excluded.tech_stack, workspaces_registry.tech_stack),
6671
last_opened_at = excluded.last_opened_at
6772
""",
@@ -149,16 +154,30 @@ def update_last_opened(self, workspace_id: str) -> None:
149154
)
150155
self._commit()
151156

152-
def delete(self, workspace_id: str) -> bool:
157+
def delete(self, workspace_id: str, owner_user_id: Optional[int] = None) -> bool:
153158
"""Deregister a workspace (registry-only — never touches disk files).
154159
160+
Args:
161+
workspace_id: Registry entry id to remove.
162+
owner_user_id: When provided (auth enabled), only an entry owned by
163+
this user is removed — a tenant cannot delete another's entry
164+
(#720). When ``None`` (auth disabled/local), no owner filter is
165+
applied and behavior is unchanged.
166+
155167
Returns:
156-
True if a row was removed, False if the id was not found.
168+
True if a row was removed, False if the id was not found (or not
169+
owned by ``owner_user_id`` when supplied).
157170
"""
158-
cursor = self._execute(
159-
"DELETE FROM workspaces_registry WHERE id = ?",
160-
(workspace_id,),
161-
)
171+
if owner_user_id is not None:
172+
cursor = self._execute(
173+
"DELETE FROM workspaces_registry WHERE id = ? AND owner_user_id = ?",
174+
(workspace_id, owner_user_id),
175+
)
176+
else:
177+
cursor = self._execute(
178+
"DELETE FROM workspaces_registry WHERE id = ?",
179+
(workspace_id,),
180+
)
162181
self._commit()
163182
return cursor.rowcount > 0
164183

codeframe/ui/routers/workspace_v2.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,10 @@ def _register_workspace(
188188

189189
@router.get("", response_model=WorkspaceListResponse)
190190
@rate_limit_standard()
191-
async def list_workspaces(request: Request) -> WorkspaceListResponse:
191+
async def list_workspaces(
192+
request: Request,
193+
auth: dict = Depends(require_auth),
194+
) -> WorkspaceListResponse:
192195
"""List all registered workspaces (issue #601).
193196
194197
Returns server-side registry entries ordered by recency, each annotated with
@@ -210,7 +213,9 @@ async def list_workspaces(request: Request) -> WorkspaceListResponse:
210213
),
211214
)
212215

213-
entries = registry.list_all()
216+
# Owner-scope the listing when auth is enabled (#720): a tenant sees only
217+
# its own registered workspaces. auth off → user_id None → all entries.
218+
entries = registry.list_all(owner_user_id=auth.get("user_id"))
214219
# path_exists is a per-entry blocking stat() inside an async handler. The
215220
# registry is recency-scoped and small in practice, so the event-loop cost is
216221
# negligible; revisit (e.g. run_in_executor) only if the list grows large.
@@ -511,6 +516,7 @@ async def check_workspace_exists(
511516
async def deregister_workspace(
512517
request: Request,
513518
workspace_id: str,
519+
auth: dict = Depends(require_auth),
514520
) -> Response:
515521
"""Deregister a workspace from the server-side registry (issue #601).
516522
@@ -536,7 +542,9 @@ async def deregister_workspace(
536542
),
537543
)
538544

539-
deleted = registry.delete(workspace_id)
545+
# Owner-scope the delete (#720): a tenant cannot deregister another's entry.
546+
# auth off → user_id None → no owner filter (unchanged local behavior).
547+
deleted = registry.delete(workspace_id, owner_user_id=auth.get("user_id"))
540548
if not deleted:
541549
raise HTTPException(
542550
status_code=404,

tests/platform_store/test_workspace_registry_repository.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,3 +148,50 @@ def test_delete_existing_returns_true(self, db):
148148

149149
def test_delete_missing_returns_false(self, db):
150150
assert db.workspace_registry.delete("does-not-exist") is False
151+
152+
153+
@pytest.fixture
154+
def db_two_users(db):
155+
"""Registry DB with two real users (owner_user_id FKs to users.id)."""
156+
db.conn.execute(
157+
"""
158+
INSERT OR REPLACE INTO users (
159+
id, email, name, hashed_password,
160+
is_active, is_superuser, is_verified, email_verified
161+
) VALUES
162+
(1, 'a@x.co', 'A', '!DISABLED!', 1, 0, 1, 1),
163+
(2, 'b@x.co', 'B', '!DISABLED!', 1, 0, 1, 1)
164+
"""
165+
)
166+
db.conn.commit()
167+
return db
168+
169+
170+
class TestOwnerScoping:
171+
"""Issue #720 / P0.9: registry must be owner-scoped so one tenant cannot
172+
enumerate or delete another's workspaces, and a refresh must not null the
173+
recorded owner."""
174+
175+
def test_upsert_preserves_owner_on_ownerless_refresh(self, db_two_users):
176+
db = db_two_users
177+
db.workspace_registry.upsert(repo_path="/p/alpha", name="alpha", owner_user_id=1)
178+
# A later refresh that omits the owner (None) must keep owner=1.
179+
db.workspace_registry.upsert(repo_path="/p/alpha", name="alpha", owner_user_id=None)
180+
assert db.workspace_registry.get_by_path("/p/alpha")["owner_user_id"] == 1
181+
182+
def test_delete_is_owner_scoped(self, db_two_users):
183+
db = db_two_users
184+
entry = db.workspace_registry.upsert(repo_path="/p/a", name="a", owner_user_id=1)
185+
wid = entry["id"]
186+
# User 2 cannot delete user 1's entry.
187+
assert db.workspace_registry.delete(wid, owner_user_id=2) is False
188+
assert db.workspace_registry.get_by_id(wid) is not None
189+
# Owner 1 can.
190+
assert db.workspace_registry.delete(wid, owner_user_id=1) is True
191+
assert db.workspace_registry.get_by_id(wid) is None
192+
193+
def test_delete_without_owner_filter_unchanged(self, db_two_users):
194+
db = db_two_users
195+
entry = db.workspace_registry.upsert(repo_path="/p/a", name="a", owner_user_id=1)
196+
# auth-off path (owner_user_id=None): no filter, deletes by id.
197+
assert db.workspace_registry.delete(entry["id"]) is True
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
"""Router-level owner scoping for the workspace registry (#720 / P0.9).
2+
3+
With auth enabled, `GET /api/v2/workspaces` and `DELETE /api/v2/workspaces/{id}`
4+
must be owner-scoped: user B cannot see or deregister user A's registered
5+
workspace.
6+
"""
7+
8+
import importlib
9+
10+
import pytest
11+
from fastapi.testclient import TestClient
12+
13+
from codeframe.auth.manager import reset_auth_engine
14+
from codeframe.platform_store.database import Database
15+
from tests.conftest import create_test_jwt_token
16+
17+
pytestmark = pytest.mark.v2
18+
19+
20+
@pytest.fixture
21+
def app_with_user_a_workspace(tmp_path, monkeypatch):
22+
"""Auth-on server; users 1 & 2 seeded; one workspace owned by user 1."""
23+
db_path = tmp_path / "state.db"
24+
monkeypatch.setenv("DATABASE_PATH", str(db_path))
25+
monkeypatch.setenv("CODEFRAME_AUTH_REQUIRED", "true")
26+
# The `with TestClient(app)` below runs the lifespan startup, which rejects
27+
# the default JWT secret unless this escape hatch is set (self-hosted only).
28+
# create_test_jwt_token signs with the same default secret the server then
29+
# validates with, so tokens stay valid.
30+
monkeypatch.setenv("CODEFRAME_ALLOW_INSECURE_SECRET", "1")
31+
monkeypatch.delenv("AUTH_SECRET", raising=False)
32+
reset_auth_engine()
33+
34+
db = Database(db_path)
35+
db.initialize()
36+
db.conn.execute(
37+
"""
38+
INSERT OR REPLACE INTO users (id, email, name, hashed_password,
39+
is_active, is_superuser, is_verified, email_verified)
40+
VALUES (1,'a@x.co','A','!DISABLED!',1,0,1,1),
41+
(2,'b@x.co','B','!DISABLED!',1,0,1,1)
42+
"""
43+
)
44+
db.conn.commit()
45+
entry = db.workspace_registry.upsert(
46+
repo_path="/home/a/projects/alpha", name="alpha", owner_user_id=1
47+
)
48+
db.close()
49+
50+
from codeframe.ui import server
51+
52+
importlib.reload(server)
53+
yield server.app, entry["id"]
54+
reset_auth_engine()
55+
56+
57+
def _bearer(user_id):
58+
return {"Authorization": f"Bearer {create_test_jwt_token(user_id)}"}
59+
60+
61+
def _list_paths(client, user_id):
62+
r = client.get("/api/v2/workspaces", headers=_bearer(user_id))
63+
assert r.status_code == 200, r.text
64+
return {w["repo_path"] for w in r.json()["workspaces"]}
65+
66+
67+
def test_user_b_cannot_list_user_a_workspace(app_with_user_a_workspace):
68+
app, _ = app_with_user_a_workspace
69+
with TestClient(app) as client: # context manager runs startup (wires app.state.db)
70+
assert "/home/a/projects/alpha" not in _list_paths(client, 2)
71+
72+
73+
def test_user_a_sees_own_workspace(app_with_user_a_workspace):
74+
app, _ = app_with_user_a_workspace
75+
with TestClient(app) as client:
76+
assert "/home/a/projects/alpha" in _list_paths(client, 1)
77+
78+
79+
def test_user_b_cannot_delete_user_a_workspace(app_with_user_a_workspace):
80+
app, wid = app_with_user_a_workspace
81+
with TestClient(app) as client:
82+
r = client.delete(f"/api/v2/workspaces/{wid}", headers=_bearer(2))
83+
assert r.status_code == 404 # not owned → not found for user B
84+
# ...and user A can still delete it.
85+
assert client.delete(f"/api/v2/workspaces/{wid}", headers=_bearer(1)).status_code == 204

0 commit comments

Comments
 (0)