Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 55 additions & 60 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,6 @@ async def connect(self):
conn.row_factory = aiosqlite.Row
await conn.execute("PRAGMA foreign_keys = ON")
await self._create_schema()
await self._ensure_schema_migrations_table()
await self._validate_schema_version()
await self._run_migrations()

async def disconnect(self):
Expand Down Expand Up @@ -658,6 +656,60 @@ async def _create_schema(self):
except Exception as e:
print(f"Failed to add 'schedule_timezone' to workflows: {e}")

# Saved views table migration: ensure owner_id and composite unique exist
try:
saved_views_columns = await self.fetchall("PRAGMA table_info(saved_views)")
if saved_views_columns:
existing_sv_cols = {col["name"] for col in saved_views_columns}
if "owner_id" not in existing_sv_cols:
try:
await self.execute(
"ALTER TABLE saved_views ADD COLUMN owner_id TEXT NOT NULL DEFAULT 'default'"
)
existing_sv_cols.add("owner_id")
print("Added missing column 'owner_id' to saved_views table.")
except Exception as e:
print(f"Failed to add 'owner_id' to saved_views: {e}")

sv_schema = await self.fetchone(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='saved_views'"
)
if sv_schema and "owner_id" in existing_sv_cols:
ddl = sv_schema["sql"]
has_old_unique = "name TEXT NOT NULL UNIQUE" in ddl
has_composite = "UNIQUE(owner_id, name)" in ddl
if has_old_unique or not has_composite:
old_fk = await self.fetchone("PRAGMA foreign_keys")
if old_fk:
await self.execute("PRAGMA foreign_keys = OFF")
try:
await self.connection.executescript("""
CREATE TABLE saved_views_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
owner_id TEXT NOT NULL DEFAULT 'default',
filter_json TEXT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
updated_at TIMESTAMP NOT NULL DEFAULT (datetime('now')),
UNIQUE(owner_id, name)
);
INSERT INTO saved_views_new
(id, name, owner_id, filter_json, created_at, updated_at)
SELECT
id, name, COALESCE(owner_id, 'default'),
filter_json, created_at, updated_at
FROM saved_views;
DROP TABLE saved_views;
ALTER TABLE saved_views_new RENAME TO saved_views;
""")
await self.connection.commit()
print("Replaced saved_views UNIQUE(name) constraint with UNIQUE(owner_id, name).")
finally:
if old_fk:
await self.execute("PRAGMA foreign_keys = ON")
except sqlite3.OperationalError:
pass # Table may not exist yet if migrations haven't run

# Notification rules table migration: ensure owner_id exists
notif_columns = await self.fetchall("PRAGMA table_info(notification_rules)")
existing_notif_cols = {col["name"] for col in notif_columns}
Expand Down Expand Up @@ -703,54 +755,6 @@ async def _create_schema(self):
)


async def _ensure_schema_migrations_table(self):
"""Create the migration tracking table if it does not already exist."""
await self.connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT (datetime('now'))
)
"""
)
await self.connection.commit()

async def _applied_migrations(self) -> set[str]:
"""Return the set of migration filenames already applied."""
rows = await self.fetchall(
"SELECT version FROM schema_migrations"
)
return {row["version"] for row in rows}

async def _validate_schema_version(self):
"""Ensure the database was not created by a newer application."""

applied = await self._applied_migrations()

available = {
migration.name
for migration in (Path(__file__).parent / "migrations").glob("*.sql")
}

unknown = applied - available

if unknown:
raise RuntimeError(
"Database schema is newer than this application. "
f"Unknown migration(s): {', '.join(sorted(unknown))}"
)

async def _record_migration(self, version: str):
"""Record a successfully applied migration."""
await self.execute(
"""
INSERT INTO schema_migrations(version)
VALUES (?)
""",
(version,),
)


async def _run_migrations(self):
migrations_dir = Path(__file__).parent / "migrations"

Expand All @@ -760,22 +764,13 @@ async def _run_migrations(self):
"ensure the backend package is installed correctly."
)

applied = await self._applied_migrations()

for migration_file in sorted(migrations_dir.glob("*.sql")):
migration_name = migration_file.name

if migration_name in applied:
continue

sql = migration_file.read_text(encoding="utf-8")

try:
await self.connection.executescript(sql)
await self._record_migration(migration_name)
except Exception as exc:
raise RuntimeError(
f"Migration {migration_name} failed — startup aborted: {exc}"
f"Migration {migration_file.name} failed — startup aborted: {exc}"
) from exc

await self._backfill_risk_scores()
Expand Down
94 changes: 25 additions & 69 deletions backend/secuscan/saved_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,13 @@
import uuid
from typing import Any, Dict, List, Optional

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

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

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 @@ -100,50 +96,29 @@ def validate_filter_json(cls, v: Optional[str]) -> Optional[str]:



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


@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(owner_id: str = Depends(get_current_owner)) -> Dict[str, Any]:
"""Return all saved views ordered by creation date."""
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",
(owner,),
(owner_id,)
)
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)
) -> Dict[str, Any]:
async def create_saved_view(body: SavedViewCreate, owner_id: str = Depends(get_current_owner)) -> 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.
Returns 409 if a view with the same name already exists.
"""
db = await get_db()


existing = await db.fetchone(
"SELECT id FROM saved_views WHERE LOWER(name) = LOWER(?) AND owner_id = ?",
(body.name, owner),
"SELECT id FROM saved_views WHERE owner_id = ? AND LOWER(name) = LOWER(?)", (owner_id, body.name)
)
if existing:
raise HTTPException(
Expand All @@ -155,37 +130,34 @@ async def create_saved_view(
view_id = str(uuid.uuid4())
await db.execute(
"""
INSERT INTO saved_views (id, name, filter_json, owner_id)
INSERT INTO saved_views (id, name, owner_id, filter_json)
VALUES (?, ?, ?, ?)
""",
(view_id, body.name, body.filter_json, owner),
(view_id, body.name, owner_id, body.filter_json),
)
return {"id": view_id, "name": body.name, "created": True}


@saved_views_router.put("/{view_id}")
async def update_saved_view(
view_id: str,
body: SavedViewUpdate,
owner: str = Depends(get_current_owner),
) -> Dict[str, Any]:
async def update_saved_view(view_id: str, body: SavedViewUpdate, owner_id: str = Depends(get_current_owner)) -> Dict[str, Any]:
"""
Overwrite name and/or filter_json for an existing view owned by the caller.
Overwrite name and/or filter_json for an existing view.
Also accepts PATCH semantics — only supplied fields are updated.
"""
db = await get_db()

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

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

if body.name is not None:
# Check for name collision with a *different* record owned by this caller
# Check for name collision with a *different* record
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 owner_id = ? AND LOWER(name) = LOWER(?) AND id != ?",
(owner_id, body.name, view_id),
)
if collision:
raise HTTPException(
Expand All @@ -204,33 +176,17 @@ async def update_saved_view(

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 = ?",
tuple(params),
tuple(params) + (owner_id,),
)
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)
) -> 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."""
async def delete_saved_view(view_id: str, owner_id: str = Depends(get_current_owner)) -> Dict[str, Any]:
"""Delete a saved view by id. Idempotent — returns 200 even if not found."""
db = await get_db()

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

await db.execute(
"DELETE FROM saved_views WHERE id = ? AND owner_id = ?", (view_id, owner)
)
await db.execute("DELETE FROM saved_views WHERE id = ? AND owner_id = ?", (view_id, owner_id))
return {"id": view_id, "deleted": True}
Binary file added scratch/cli_changes.patch
Binary file not shown.
Binary file added scratch/test_cli_changes.patch
Binary file not shown.
Binary file added scratch/test_saved_views_changes.patch
Binary file not shown.
Loading
Loading