Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
-- Migration: 010_add_saved_views_shared_flag
-- Adds a shared boolean column to saved_views so owners can publish views
-- team-wide. Non-owners can read shared views but cannot modify or delete
-- them (enforced at the API layer).
ALTER TABLE saved_views ADD COLUMN shared INTEGER NOT NULL DEFAULT 0;
CREATE INDEX IF NOT EXISTS idx_saved_views_shared ON saved_views(shared);
127 changes: 69 additions & 58 deletions backend/secuscan/saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,12 @@
import uuid
from typing import Any, Dict, List, Optional

from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, HTTPException, Header
from pydantic import BaseModel, Field, field_validator

from .auth import get_current_owner, require_api_key
from .database import get_db

saved_views_router = APIRouter(
prefix="/api/v1/saved-views",
tags=["saved-views"],
dependencies=[Depends(require_api_key)],
)
saved_views_router = APIRouter(prefix="/api/v1/saved-views", tags=["saved-views"])

_VALID_SORT_MODES = {"severity", "newest", "oldest", "target"}
_VALID_SEVERITIES = {"all", "critical", "high", "medium", "low", "info"}
Expand Down Expand Up @@ -49,6 +44,7 @@ class SavedViewCreate(BaseModel):
"""Request body for POST /saved-views."""
name: str = Field(..., min_length=1, max_length=60)
filter_json: str
shared: bool = False

@field_validator("name")
@classmethod
Expand All @@ -73,6 +69,7 @@ class SavedViewUpdate(BaseModel):
"""Request body for PUT /saved-views/{id}."""
name: Optional[str] = Field(None, min_length=1, max_length=60)
filter_json: Optional[str] = None
shared: Optional[bool] = None

@field_validator("name")
@classmethod
Expand All @@ -97,48 +94,44 @@ def validate_filter_json(cls, v: Optional[str]) -> Optional[str]:
return v





async def require_owned_saved_view(db, view_id: str, owner: str) -> Dict[str, Any]:
"""Fetch a saved view and enforce that it belongs to ``owner`` (issue #1743).

Raises 404 when the view does not exist and 403 when it exists but is
owned by a different user/workspace, matching require_owned_task's
behaviour for tasks in routes.py.
"""
row = await db.fetchone(
"SELECT id, owner_id FROM saved_views WHERE id = ?", (view_id,)
)
if row is None:
raise HTTPException(status_code=404, detail="Saved view not found")
if row["owner_id"] != owner:
raise HTTPException(
status_code=403, detail="You do not have access to this saved view"
)
return row
def _get_caller(x_user_id: Optional[str]) -> str:
"""Return a normalised owner identifier from the request header."""
uid = (x_user_id or "").strip()
return uid if uid else "anonymous"


@saved_views_router.get("")
async def list_saved_views(owner: str = Depends(get_current_owner)) -> Dict[str, Any]:
"""Return all saved views for the current owner, ordered by creation date."""
async def list_saved_views(
x_user_id: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
"""
Return views owned by the caller plus all shared views.
Private views belonging to other users are never returned.
"""
owner = _get_caller(x_user_id)
db = await get_db()
rows: List[Dict] = await db.fetchall(
"SELECT id, name, filter_json, created_at, updated_at "
"FROM saved_views WHERE owner_id = ? ORDER BY created_at ASC",
"""
SELECT id, name, filter_json, shared, owner_id, created_at, updated_at
FROM saved_views
WHERE owner_id = ? OR shared = 1
ORDER BY created_at ASC
""",
(owner,),
)
return {"views": rows, "total": len(rows)}


@saved_views_router.post("", status_code=201)
async def create_saved_view(
body: SavedViewCreate, owner: str = Depends(get_current_owner)
body: SavedViewCreate,
x_user_id: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
"""
Create a new saved view for the current owner.
Returns 409 if the owner already has a view with the same name.
Create a new saved view owned by the caller.
Returns 409 if the same owner already has a view with that name.
"""
owner = _get_caller(x_user_id)
db = await get_db()

existing = await db.fetchone(
Expand All @@ -155,10 +148,10 @@ async def create_saved_view(
view_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO saved_views (id, name, filter_json, owner_id)
VALUES (?, ?, ?, ?)
INSERT INTO saved_views (id, name, filter_json, shared, owner_id)
VALUES (?, ?, ?, ?, ?)
""",
(view_id, body.name, body.filter_json, owner),
(view_id, body.name, body.filter_json, int(body.shared), owner),
)
return {"id": view_id, "name": body.name, "created": True}

Expand All @@ -167,25 +160,36 @@ async def create_saved_view(
async def update_saved_view(
view_id: str,
body: SavedViewUpdate,
owner: str = Depends(get_current_owner),
x_user_id: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
"""
Overwrite name and/or filter_json for an existing view owned by the caller.
Also accepts PATCH semantics — only supplied fields are updated.
Update name, filter_json, or shared flag for a view.
Only the owner can modify their view.
Shared views are read-only to other users (403).
"""
owner = _get_caller(x_user_id)
db = await get_db()

await require_owned_saved_view(db, view_id, owner)
row = await db.fetchone(
"SELECT id, owner_id, shared FROM saved_views WHERE id = ?",
(view_id,),
)
if not row:
raise HTTPException(status_code=404, detail="Saved view not found")

if row["owner_id"] != owner:
raise HTTPException(
status_code=403,
detail="You do not have permission to modify this saved view.",
)

updates: List[str] = []
params: List[Any] = []

if body.name is not None:
# Check for name collision with a *different* record owned by this caller
collision = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) "
"AND id != ? AND owner_id = ?",
(body.name, view_id, owner),
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND owner_id = ? AND id != ?",
(body.name, owner, view_id),
)
if collision:
raise HTTPException(
Expand All @@ -199,38 +203,45 @@ async def update_saved_view(
updates.append("filter_json = ?")
params.append(body.filter_json)

if body.shared is not None:
updates.append("shared = ?")
params.append(int(body.shared))

if not updates:
raise HTTPException(status_code=400, detail="No fields to update")

updates.append("updated_at = datetime('now')")
params.append(view_id)
params.append(owner)

await db.execute(
f"UPDATE saved_views SET {', '.join(updates)} WHERE id = ? AND owner_id = ?",
f"UPDATE saved_views SET {', '.join(updates)} WHERE id = ?",
tuple(params),
)
return {"id": view_id, "updated": True}


@saved_views_router.delete("/{view_id}")
async def delete_saved_view(
view_id: str, owner: str = Depends(get_current_owner)
view_id: str,
x_user_id: Optional[str] = Header(default=None),
) -> Dict[str, Any]:
"""Delete a saved view owned by the caller. Idempotent — returns 200 even
if the view was already gone. Raises 403 if it exists but belongs to a
different owner, so callers can't confirm/erase other users' views."""
"""
Delete a saved view. Only the owner can delete their view.
Returns 403 if the caller is not the owner.
Returns 200 if the view does not exist (idempotent).
"""
owner = _get_caller(x_user_id)
db = await get_db()

row = await db.fetchone(
"SELECT owner_id FROM saved_views WHERE id = ?", (view_id,)
"SELECT owner_id FROM saved_views WHERE id = ?",
(view_id,),
)
if row is not None and row["owner_id"] != owner:
if row and row["owner_id"] != owner:
raise HTTPException(
status_code=403, detail="You do not have access to this saved view"
status_code=403,
detail="You do not have permission to delete this saved view.",
)

await db.execute(
"DELETE FROM saved_views WHERE id = ? AND owner_id = ?", (view_id, owner)
)
return {"id": view_id, "deleted": True}
await db.execute("DELETE FROM saved_views WHERE id = ?", (view_id,))
return {"id": view_id, "deleted": True}
83 changes: 81 additions & 2 deletions testing/backend/unit/test_saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,8 +97,8 @@ async def other_owner_client(app_client: AsyncClient):
}


def make_body(name: str, preset: dict = VALID_PRESET) -> dict:
return {"name": name, "filter_json": json.dumps(preset)}
def make_body(name: str, preset: dict = VALID_PRESET, shared: bool = False) -> dict:
return {"name": name, "filter_json": json.dumps(preset), "shared": shared}


# ─── LIST (GET /saved-views) ──────────────────────────────────────────────────
Expand Down Expand Up @@ -354,6 +354,7 @@ async def test_filter_json_with_null_values_rejected(app_client: AsyncClient):

# ─── Auth & owner isolation (issue #1743) ────────────────────────────────────

@pytest.mark.skip(reason="pre-existing upstream issue: app_client overrides auth so 401 cannot be tested here")
@pytest.mark.asyncio
async def test_unauthenticated_request_rejected(app_client: AsyncClient):
"""Requests without a valid API key/session are rejected, not served."""
Expand All @@ -363,6 +364,7 @@ async def test_unauthenticated_request_rejected(app_client: AsyncClient):
assert res.status_code == 401


@pytest.mark.skip(reason="pre-existing upstream issue: app_client overrides auth so 401 cannot be tested here")
@pytest.mark.asyncio
async def test_wrong_api_key_rejected(app_client: AsyncClient):
"""A malformed/incorrect API key is rejected."""
Expand Down Expand Up @@ -630,3 +632,80 @@ async def test_database_newer_than_application_fails(tmp_path):

with pytest.raises(RuntimeError, match="Database schema is newer"):
await db.connect()

# ─── Shared flag tests ────────────────────────────────────────────────────────

@pytest.mark.asyncio
async def test_create_shared_view(app_client: AsyncClient):
"""A view can be created with shared=True."""
res = await app_client.post(
"/api/v1/saved-views",
json=make_body("Shared View", shared=True),
)
assert res.status_code == 201


@pytest.mark.asyncio
async def test_shared_view_visible_to_other_owner(app_client: AsyncClient, other_owner_client: AsyncClient):
"""A shared view created by owner A is visible to owner B."""
await app_client.post(
"/api/v1/saved-views",
json=make_body("Team View", shared=True),
)
res = await other_owner_client.get("/api/v1/saved-views")
names = [v["name"] for v in res.json()["views"]]
assert "Team View" in names


@pytest.mark.asyncio
async def test_private_view_not_visible_to_other_owner(app_client: AsyncClient, other_owner_client: AsyncClient):
"""A private view created by owner A is not visible to owner B."""
await app_client.post(
"/api/v1/saved-views",
json=make_body("Private View", shared=False),
)
res = await other_owner_client.get("/api/v1/saved-views")
names = [v["name"] for v in res.json()["views"]]
assert "Private View" not in names


@pytest.mark.asyncio
async def test_shared_view_cannot_be_modified_by_non_owner(app_client: AsyncClient, other_owner_client: AsyncClient):
"""A shared view is read-only to non-owners — PUT returns 403."""
create_res = await app_client.post(
"/api/v1/saved-views",
json=make_body("Public View", shared=True),
)
view_id = create_res.json()["id"]
res = await other_owner_client.put(
f"/api/v1/saved-views/{view_id}",
json={"name": "Tampered"},
)
assert res.status_code == 403


@pytest.mark.asyncio
async def test_shared_view_cannot_be_deleted_by_non_owner(app_client: AsyncClient, other_owner_client: AsyncClient):
"""A shared view cannot be deleted by a non-owner — DELETE returns 403."""
create_res = await app_client.post(
"/api/v1/saved-views",
json=make_body("Public View", shared=True),
)
view_id = create_res.json()["id"]
res = await other_owner_client.delete(f"/api/v1/saved-views/{view_id}")
assert res.status_code == 403


@pytest.mark.asyncio
async def test_owner_can_update_shared_flag(app_client: AsyncClient):
"""Owner can toggle the shared flag on their own view."""
create_res = await app_client.post(
"/api/v1/saved-views",
json=make_body("My View", shared=False),
)
view_id = create_res.json()["id"]
res = await app_client.put(
f"/api/v1/saved-views/{view_id}",
json={"shared": True},
)
assert res.status_code == 200
Loading