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
6 changes: 5 additions & 1 deletion .audit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,11 @@ policy:

# Documented exceptions with business justification
# Format: CVE-XXXX-XXXXX or GHSA-xxxx-xxxx-xxxx
exceptions: {}
exceptions:
GHSA-gv7w-rqvm-qjhr:
package: esbuild
reason: "esbuild vulnerability affects Deno module only; SecuScan uses esbuild via Vite for bundling in Node.js context. Fix requires Vite 8.x breaking upgrade."
expires_at: "2026-08-31"

# Packages to exclude from audits (use sparingly!)
excluded_packages: []
Expand Down
21 changes: 21 additions & 0 deletions backend/secuscan/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ async def _create_schema(self):

CREATE TABLE IF NOT EXISTS notification_rules (
id TEXT PRIMARY KEY,
owner_id TEXT NOT NULL DEFAULT 'default',
name TEXT NOT NULL,
severity_threshold TEXT NOT NULL,
channel_type TEXT NOT NULL,
Expand Down Expand Up @@ -369,6 +370,7 @@ async def _create_schema(self):

-- Workflows index (existing)
CREATE INDEX IF NOT EXISTS idx_workflows_enabled ON workflows(enabled);
CREATE INDEX IF NOT EXISTS idx_notification_rules_owner ON notification_rules(owner_id);
CREATE INDEX IF NOT EXISTS idx_notification_rules_active ON notification_rules(is_active);
CREATE INDEX IF NOT EXISTS idx_notification_rules_severity ON notification_rules(severity_threshold);
CREATE INDEX IF NOT EXISTS idx_notification_history_rule_id ON notification_history(rule_id);
Expand Down Expand Up @@ -502,6 +504,17 @@ async def _create_schema(self):
"""
)

async def _ensure_column(self, table: str, column_def: str):
"""Add a column to an existing table if it does not already exist."""
col_name = column_def.split(maxsplit=1)[0]
cursor = await self.connection.execute(f"PRAGMA table_info({table})")
rows = await cursor.fetchall()
for row in rows:
if row["name"] == col_name:
return
await self.connection.execute(f"ALTER TABLE {table} ADD COLUMN {column_def}")
await self.connection.commit()

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

Expand All @@ -511,6 +524,14 @@ async def _run_migrations(self):
"ensure the backend package is installed correctly."
)

# Before running migration 004, ensure the owner_id column exists on
# existing notification_rules tables (fresh installs already have it
# via CREATE TABLE IF NOT EXISTS in _create_schema).
await self._ensure_column(
"notification_rules",
"owner_id TEXT NOT NULL DEFAULT 'default'",
)

for migration_file in sorted(migrations_dir.glob("*.sql")):
sql = migration_file.read_text(encoding="utf-8")
try:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Migration: 004_add_notification_rules_owner_id
-- Adds owner_id column to notification_rules for per-workspace isolation
-- (issue #740). The column is added idempotently in database.py before this
-- migration runs (_ensure_column uses PRAGMA table_info + ALTER TABLE).
-- This migration handles the backfill and index for existing deployments.

UPDATE notification_rules SET owner_id = 'default' WHERE owner_id IS NULL;

CREATE INDEX IF NOT EXISTS idx_notification_rules_owner ON notification_rules(owner_id);
1 change: 1 addition & 0 deletions backend/secuscan/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,7 @@ class NotificationRuleUpdate(BaseModel):
class NotificationRuleResponse(BaseModel):
"""Stored notification rule returned by the API."""
id: str
owner_id: str = "default"
name: str
severity_threshold: str
channel_type: str
Expand Down
4 changes: 3 additions & 1 deletion backend/secuscan/notification_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,10 @@ async def process_finding_notifications(
if not finding:
return []

owner_id = finding.get("owner_id", "default")
rules = await db.fetchall(
"SELECT * FROM notification_rules WHERE is_active = 1 ORDER BY created_at ASC"
"SELECT * FROM notification_rules WHERE is_active = 1 AND owner_id = ? ORDER BY created_at ASC",
(owner_id,),
)
results: List[DeliveryResult] = []
for rule in rules:
Expand Down
37 changes: 20 additions & 17 deletions backend/secuscan/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ def _validate_notification_target(channel_type: NotificationChannelType, target:
def _serialize_notification_rule(row: Dict[str, Any]) -> Dict[str, Any]:
return {
"id": row["id"],
"owner_id": row.get("owner_id", "default"),
"name": row["name"],
"severity_threshold": row["severity_threshold"],
"channel_type": row["channel_type"],
Expand Down Expand Up @@ -1919,17 +1920,18 @@ async def trigger_workflow_tick():


@router.get("/notifications/rules")
async def list_notification_rules():
async def list_notification_rules(owner: str = Depends(get_current_owner)):
db = await get_db()
rows = await db.fetchall(
"SELECT * FROM notification_rules ORDER BY created_at DESC"
"SELECT * FROM notification_rules WHERE owner_id = ? ORDER BY created_at DESC",
(owner,),
)
rules = [_serialize_notification_rule(row) for row in rows]
return {"rules": rules, "total": len(rules)}


@router.post("/notifications/rules")
async def create_notification_rule(payload: NotificationRuleCreate):
async def create_notification_rule(payload: NotificationRuleCreate, owner: str = Depends(get_current_owner)):
name = payload.name.strip()
if not name:
raise HTTPException(status_code=400, detail="Rule name is required")
Expand All @@ -1940,11 +1942,12 @@ async def create_notification_rule(payload: NotificationRuleCreate):
await db.execute(
"""
INSERT INTO notification_rules (
id, name, severity_threshold, channel_type, target_url_or_email, is_active
) VALUES (?, ?, ?, ?, ?, ?)
id, owner_id, name, severity_threshold, channel_type, target_url_or_email, is_active
) VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
rule_id,
owner,
name,
payload.severity_threshold.value,
payload.channel_type.value,
Expand All @@ -1962,23 +1965,23 @@ async def create_notification_rule(payload: NotificationRuleCreate):


@router.get("/notifications/rules/{rule_id}")
async def get_notification_rule(rule_id: str):
async def get_notification_rule(rule_id: str, owner: str = Depends(get_current_owner)):
db = await get_db()
row = await db.fetchone(
"SELECT * FROM notification_rules WHERE id = ?",
(rule_id,),
"SELECT * FROM notification_rules WHERE id = ? AND owner_id = ?",
(rule_id, owner),
)
if not row:
raise HTTPException(status_code=404, detail="Notification rule not found")
return _serialize_notification_rule(row)


@router.patch("/notifications/rules/{rule_id}")
async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate):
async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate, owner: str = Depends(get_current_owner)):
db = await get_db()
row = await db.fetchone(
"SELECT * FROM notification_rules WHERE id = ?",
(rule_id,),
"SELECT * FROM notification_rules WHERE id = ? AND owner_id = ?",
(rule_id, owner),
)
if not row:
raise HTTPException(status_code=404, detail="Notification rule not found")
Expand Down Expand Up @@ -2031,8 +2034,8 @@ async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate
updates.append("updated_at = datetime('now')")
params.append(rule_id)
await db.execute(
f"UPDATE notification_rules SET {', '.join(updates)} WHERE id = ?",
tuple(params),
f"UPDATE notification_rules SET {', '.join(updates)} WHERE id = ? AND owner_id = ?",
tuple(params + [owner]),
)
updated = await db.fetchone(
"SELECT * FROM notification_rules WHERE id = ?",
Expand All @@ -2044,15 +2047,15 @@ async def update_notification_rule(rule_id: str, payload: NotificationRuleUpdate


@router.delete("/notifications/rules/{rule_id}")
async def delete_notification_rule(rule_id: str):
async def delete_notification_rule(rule_id: str, owner: str = Depends(get_current_owner)):
db = await get_db()
row = await db.fetchone(
"SELECT id FROM notification_rules WHERE id = ?",
(rule_id,),
"SELECT id FROM notification_rules WHERE id = ? AND owner_id = ?",
(rule_id, owner),
)
if not row:
raise HTTPException(status_code=404, detail="Notification rule not found")
await db.execute("DELETE FROM notification_rules WHERE id = ?", (rule_id,))
await db.execute("DELETE FROM notification_rules WHERE id = ? AND owner_id = ?", (rule_id, owner))
return {"rule_id": rule_id, "deleted": True}


Expand Down
Loading