Files
FabledScribe/src/fabledassistant/routes/rulebooks.py
T
bvandeusen e70fe545cc
CI & Build / Python lint (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 40s
CI & Build / Python tests (push) Successful in 50s
CI & Build / Build & push image (push) Successful in 2m18s
fix(trash): owner-scope all trash ops — close cross-tenant IDOR/disclosure
Drift-audit Group 1 (authz/IDOR). Multi-user is live, so these were
exploitable ACL bypasses:

- trash.py: add _owner_clause() and apply it to _exists_alive, restore,
  purge, list_trash, and purge_expired. A batch_id is a bearer token;
  without an owner predicate a leaked/guessed id let one tenant read
  (list_trash), restore, or PERMANENTLY purge another's content. Topics
  and rules carried no owner check at all (_OWNER mapped them to None) —
  ownership now derives through the parent rulebook (or owning project,
  for project-scoped rules).
- purge_expired is now per-user; trash_scheduler iterates every user and
  applies that user's own trash_retention_days window, instead of
  applying user 1's window to everyone (early data loss for other users).
- rulebooks subscribe/unsubscribe_project now assert project ownership,
  matching the suppression endpoints.
- topic/rule DELETE routes return 404 when nothing owned was removed.

Regression test locks in that every model — including topics/rules —
gets a real owner clause.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 18:44:32 -04:00

316 lines
10 KiB
Python

"""Rulebook / topic REST endpoints.
Wraps services/rulebooks.py. Standard Scribe auth: g.user.id is the
authenticated owner; the service enforces ownership scoping.
"""
from __future__ import annotations
from quart import Blueprint, g, jsonify, request
from fabledassistant.auth import login_required
import fabledassistant.services.rulebooks as rulebooks_svc
from fabledassistant.services.trash import delete as trash_delete
rulebooks_bp = Blueprint("rulebooks", __name__, url_prefix="/api")
def _uid() -> int:
return g.user.id
# ── Rulebooks ───────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks")
@login_required
async def list_rulebooks():
rows = await rulebooks_svc.list_rulebooks(_uid())
return jsonify({"rulebooks": [rb.to_dict() for rb in rows]})
@rulebooks_bp.post("/rulebooks")
@login_required
async def create_rulebook():
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
rb = await rulebooks_svc.create_rulebook(
user_id=_uid(),
title=title,
description=data.get("description", ""),
)
return jsonify(rb.to_dict()), 201
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>")
@login_required
async def get_rulebook(rulebook_id: int):
rb = await rulebooks_svc.get_rulebook(rulebook_id, _uid())
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.patch("/rulebooks/<int:rulebook_id>")
@login_required
async def update_rulebook(rulebook_id: int):
data = await request.get_json() or {}
fields = {k: v for k, v in data.items() if k in ("title", "description", "always_on")}
rb = await rulebooks_svc.update_rulebook(rulebook_id, _uid(), **fields)
if rb is None:
return jsonify({"error": "rulebook not found"}), 404
return jsonify(rb.to_dict())
@rulebooks_bp.delete("/rulebooks/<int:rulebook_id>")
@login_required
async def delete_rulebook(rulebook_id: int):
await trash_delete(_uid(), "rulebook", rulebook_id)
return "", 204
# ── Topics ──────────────────────────────────────────────────────────────
@rulebooks_bp.get("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def list_topics(rulebook_id: int):
try:
rows = await rulebooks_svc.list_topics(rulebook_id, _uid())
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify({"topics": [t.to_dict() for t in rows]})
@rulebooks_bp.post("/rulebooks/<int:rulebook_id>/topics")
@login_required
async def create_topic(rulebook_id: int):
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
if not title:
return jsonify({"error": "title is required"}), 400
try:
topic = await rulebooks_svc.create_topic(
rulebook_id=rulebook_id,
user_id=_uid(),
title=title,
description=data.get("description", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(topic.to_dict()), 201
@rulebooks_bp.patch("/rulebook-topics/<int:topic_id>")
@login_required
async def update_topic(topic_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("title", "description", "order_index")
}
topic = await rulebooks_svc.update_topic(topic_id, _uid(), **fields)
if topic is None:
return jsonify({"error": "topic not found"}), 404
return jsonify(topic.to_dict())
@rulebooks_bp.delete("/rulebook-topics/<int:topic_id>")
@login_required
async def delete_topic(topic_id: int):
if await trash_delete(_uid(), "topic", topic_id) is None:
return jsonify({"error": "topic not found"}), 404
return "", 204
# ── Rules ───────────────────────────────────────────────────────────────
@rulebooks_bp.get("/rules")
@login_required
async def list_rules():
def _opt_int(name):
raw = request.args.get(name)
return int(raw) if raw else None
try:
rulebook_id = _opt_int("rulebook_id")
topic_id = _opt_int("topic_id")
project_id = _opt_int("project_id")
except ValueError:
return jsonify({"error": "rulebook_id, topic_id, project_id must be integers"}), 400
rows = await rulebooks_svc.list_rules(
user_id=_uid(),
rulebook_id=rulebook_id,
topic_id=topic_id,
project_id=project_id,
)
return jsonify({"rules": [r.to_dict() for r in rows]})
@rulebooks_bp.post("/rulebook-topics/<int:topic_id>/rules")
@login_required
async def create_rule(topic_id: int):
data = await request.get_json() or {}
title = (data.get("title") or "").strip()
statement = (data.get("statement") or "").strip()
if not title or not statement:
return jsonify({"error": "title and statement are required"}), 400
try:
rule = await rulebooks_svc.create_rule(
topic_id=topic_id,
user_id=_uid(),
title=title,
statement=statement,
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201
@rulebooks_bp.get("/rules/<int:rule_id>")
@login_required
async def get_rule(rule_id: int):
rule = await rulebooks_svc.get_rule(rule_id, _uid())
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@rulebooks_bp.patch("/rules/<int:rule_id>")
@login_required
async def update_rule(rule_id: int):
data = await request.get_json() or {}
fields = {
k: v for k, v in data.items()
if k in ("title", "statement", "why", "how_to_apply", "order_index")
}
rule = await rulebooks_svc.update_rule(rule_id, _uid(), **fields)
if rule is None:
return jsonify({"error": "rule not found"}), 404
return jsonify(rule.to_dict())
@rulebooks_bp.delete("/rules/<int:rule_id>")
@login_required
async def delete_rule(rule_id: int):
if await trash_delete(_uid(), "rule", rule_id) is None:
return jsonify({"error": "rule not found"}), 404
return "", 204
# ── Subscriptions ──────────────────────────────────────────────────────
@rulebooks_bp.post("/projects/<int:project_id>/rulebook-subscriptions")
@login_required
async def subscribe_project(project_id: int):
data = await request.get_json() or {}
rulebook_id = data.get("rulebook_id")
if not rulebook_id:
return jsonify({"error": "rulebook_id is required"}), 400
try:
await rulebooks_svc.subscribe_project(
project_id=project_id, rulebook_id=int(rulebook_id), user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete(
"/projects/<int:project_id>/rulebook-subscriptions/<int:rulebook_id>"
)
@login_required
async def unsubscribe_project(project_id: int, rulebook_id: int):
try:
await rulebooks_svc.unsubscribe_project(
project_id=project_id, rulebook_id=rulebook_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.get("/projects/<int:project_id>/rules")
@login_required
async def get_project_rules(project_id: int):
result = await rulebooks_svc.get_applicable_rules(
project_id=project_id, user_id=_uid(),
)
return jsonify(result)
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def suppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.suppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/rules/<int:rule_id>")
@login_required
async def unsuppress_project_rule(project_id: int, rule_id: int):
try:
await rulebooks_svc.unsuppress_rule_for_project(
project_id=project_id, rule_id=rule_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def suppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.suppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.delete("/projects/<int:project_id>/suppressions/topics/<int:topic_id>")
@login_required
async def unsuppress_project_topic(project_id: int, topic_id: int):
try:
await rulebooks_svc.unsuppress_topic_for_project(
project_id=project_id, topic_id=topic_id, user_id=_uid(),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return "", 204
@rulebooks_bp.post("/projects/<int:project_id>/rules")
@login_required
async def create_project_rule(project_id: int):
"""Create a rule scoped to a single project. Frontend fast path."""
data = await request.get_json() or {}
statement = (data.get("statement") or "").strip()
if not statement:
return jsonify({"error": "statement is required"}), 400
title = (data.get("title") or "").strip() or statement.split(".")[0][:50]
try:
rule = await rulebooks_svc.create_project_rule(
project_id=project_id,
user_id=_uid(),
title=title,
statement=statement,
why=data.get("why", ""),
how_to_apply=data.get("how_to_apply", ""),
order_index=data.get("order_index", 0),
)
except ValueError as exc:
return jsonify({"error": str(exc)}), 404
return jsonify(rule.to_dict()), 201