feat: implement 8 OpenSpec proposals for security, reliability, and UX improvements
## Security Enhancements (P0) - Add input validation with max_length and numeric range constraints - Implement WebSocket token authentication via first message - Add path traversal prevention in file storage service ## Permission Enhancements (P0) - Add project member management for cross-department access - Implement is_department_manager flag for workload visibility ## Cycle Detection (P0) - Add DFS-based cycle detection for task dependencies - Add formula field circular reference detection - Display user-friendly cycle path visualization ## Concurrency & Reliability (P1) - Implement optimistic locking with version field (409 Conflict on mismatch) - Add trigger retry mechanism with exponential backoff (1s, 2s, 4s) - Implement cascade restore for soft-deleted tasks ## Rate Limiting (P1) - Add tiered rate limits: standard (60/min), sensitive (20/min), heavy (5/min) - Apply rate limits to tasks, reports, attachments, and comments ## Frontend Improvements (P1) - Add responsive sidebar with hamburger menu for mobile - Improve touch-friendly UI with proper tap target sizes - Complete i18n translations for all components ## Backend Reliability (P2) - Configure database connection pool (size=10, overflow=20) - Add Redis fallback mechanism with message queue - Add blocker check before task deletion ## API Enhancements (P3) - Add standardized response wrapper utility - Add /health/ready and /health/live endpoints - Implement project templates with status/field copying ## Tests Added - test_input_validation.py - Schema and path traversal tests - test_concurrency_reliability.py - Optimistic locking and retry tests - test_backend_reliability.py - Connection pool and Redis tests - test_api_enhancements.py - Health check and template tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,17 @@ class FormulaError(Exception):
|
||||
|
||||
class CircularReferenceError(FormulaError):
|
||||
"""Exception raised when circular references are detected in formulas."""
|
||||
pass
|
||||
|
||||
def __init__(self, message: str, cycle_path: Optional[List[str]] = None):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.cycle_path = cycle_path or []
|
||||
|
||||
def get_cycle_description(self) -> str:
|
||||
"""Get a human-readable description of the cycle."""
|
||||
if not self.cycle_path:
|
||||
return ""
|
||||
return " -> ".join(self.cycle_path)
|
||||
|
||||
|
||||
class FormulaService:
|
||||
@@ -140,24 +150,43 @@ class FormulaService:
|
||||
field_id: str,
|
||||
references: Set[str],
|
||||
visited: Optional[Set[str]] = None,
|
||||
path: Optional[List[str]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Check for circular references in formula fields.
|
||||
|
||||
Raises CircularReferenceError if a cycle is detected.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_id: Project ID to scope the query
|
||||
field_id: The current field being validated
|
||||
references: Set of field names referenced in the formula
|
||||
visited: Set of visited field IDs (for cycle detection)
|
||||
path: Current path of field names (for error reporting)
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if path is None:
|
||||
path = []
|
||||
|
||||
# Get the current field's name
|
||||
current_field = db.query(CustomField).filter(
|
||||
CustomField.id == field_id
|
||||
).first()
|
||||
|
||||
current_field_name = current_field.name if current_field else "unknown"
|
||||
|
||||
# Add current field to path if not already there
|
||||
if current_field_name not in path:
|
||||
path = path + [current_field_name]
|
||||
|
||||
if current_field:
|
||||
if current_field.name in references:
|
||||
cycle_path = path + [current_field.name]
|
||||
raise CircularReferenceError(
|
||||
f"Circular reference detected: field cannot reference itself"
|
||||
f"Circular reference detected: field '{current_field.name}' cannot reference itself",
|
||||
cycle_path=cycle_path
|
||||
)
|
||||
|
||||
# Get all referenced formula fields
|
||||
@@ -173,22 +202,199 @@ class FormulaService:
|
||||
|
||||
for field in formula_fields:
|
||||
if field.id in visited:
|
||||
# Found a cycle
|
||||
cycle_path = path + [field.name]
|
||||
raise CircularReferenceError(
|
||||
f"Circular reference detected involving field '{field.name}'"
|
||||
f"Circular reference detected: {' -> '.join(cycle_path)}",
|
||||
cycle_path=cycle_path
|
||||
)
|
||||
|
||||
visited.add(field.id)
|
||||
new_path = path + [field.name]
|
||||
|
||||
if field.formula:
|
||||
nested_refs = FormulaService.extract_field_references(field.formula)
|
||||
if current_field and current_field.name in nested_refs:
|
||||
cycle_path = new_path + [current_field.name]
|
||||
raise CircularReferenceError(
|
||||
f"Circular reference detected: '{field.name}' references the current field"
|
||||
f"Circular reference detected: {' -> '.join(cycle_path)}",
|
||||
cycle_path=cycle_path
|
||||
)
|
||||
FormulaService._check_circular_references(
|
||||
db, project_id, field_id, nested_refs, visited
|
||||
db, project_id, field_id, nested_refs, visited, new_path
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def build_formula_dependency_graph(
|
||||
db: Session,
|
||||
project_id: str
|
||||
) -> Dict[str, Set[str]]:
|
||||
"""
|
||||
Build a dependency graph for all formula fields in a project.
|
||||
|
||||
Returns a dict where keys are field names and values are sets of
|
||||
field names that the key field depends on.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_id: Project ID to scope the query
|
||||
|
||||
Returns:
|
||||
Dict mapping field names to their dependencies
|
||||
"""
|
||||
graph: Dict[str, Set[str]] = {}
|
||||
|
||||
formula_fields = db.query(CustomField).filter(
|
||||
CustomField.project_id == project_id,
|
||||
CustomField.field_type == "formula",
|
||||
).all()
|
||||
|
||||
for field in formula_fields:
|
||||
if field.formula:
|
||||
refs = FormulaService.extract_field_references(field.formula)
|
||||
# Only include custom field references (not builtin fields)
|
||||
custom_refs = refs - FormulaService.BUILTIN_FIELDS
|
||||
graph[field.name] = custom_refs
|
||||
else:
|
||||
graph[field.name] = set()
|
||||
|
||||
return graph
|
||||
|
||||
@staticmethod
|
||||
def detect_formula_cycles(
|
||||
db: Session,
|
||||
project_id: str
|
||||
) -> List[List[str]]:
|
||||
"""
|
||||
Detect all cycles in the formula dependency graph for a project.
|
||||
|
||||
This is useful for auditing or cleanup operations.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
project_id: Project ID to check
|
||||
|
||||
Returns:
|
||||
List of cycles, where each cycle is a list of field names
|
||||
"""
|
||||
graph = FormulaService.build_formula_dependency_graph(db, project_id)
|
||||
|
||||
if not graph:
|
||||
return []
|
||||
|
||||
cycles: List[List[str]] = []
|
||||
visited: Set[str] = set()
|
||||
found_cycles: Set[Tuple[str, ...]] = set()
|
||||
|
||||
def dfs(node: str, path: List[str], in_path: Set[str]):
|
||||
"""DFS to find cycles."""
|
||||
if node in in_path:
|
||||
# Found a cycle
|
||||
cycle_start = path.index(node)
|
||||
cycle = path[cycle_start:] + [node]
|
||||
# Normalize for deduplication
|
||||
normalized = tuple(sorted(cycle[:-1]))
|
||||
if normalized not in found_cycles:
|
||||
found_cycles.add(normalized)
|
||||
cycles.append(cycle)
|
||||
return
|
||||
|
||||
if node in visited:
|
||||
return
|
||||
|
||||
if node not in graph:
|
||||
return
|
||||
|
||||
visited.add(node)
|
||||
in_path.add(node)
|
||||
path.append(node)
|
||||
|
||||
for neighbor in graph.get(node, set()):
|
||||
dfs(neighbor, path.copy(), in_path.copy())
|
||||
|
||||
path.pop()
|
||||
in_path.discard(node)
|
||||
|
||||
for start_node in graph.keys():
|
||||
if start_node not in visited:
|
||||
dfs(start_node, [], set())
|
||||
|
||||
return cycles
|
||||
|
||||
@staticmethod
|
||||
def validate_formula_with_details(
|
||||
formula: str,
|
||||
project_id: str,
|
||||
db: Session,
|
||||
current_field_id: Optional[str] = None,
|
||||
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
|
||||
"""
|
||||
Validate a formula expression with detailed error information.
|
||||
|
||||
Similar to validate_formula but returns cycle path on circular reference errors.
|
||||
|
||||
Args:
|
||||
formula: The formula expression to validate
|
||||
project_id: Project ID to scope field lookups
|
||||
db: Database session
|
||||
current_field_id: Optional ID of the field being edited (for self-reference check)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message, cycle_path)
|
||||
"""
|
||||
if not formula or not formula.strip():
|
||||
return False, "Formula cannot be empty", None
|
||||
|
||||
# Extract field references
|
||||
references = FormulaService.extract_field_references(formula)
|
||||
|
||||
if not references:
|
||||
return False, "Formula must reference at least one field", None
|
||||
|
||||
# Validate syntax by trying to parse
|
||||
try:
|
||||
# Replace field references with dummy numbers for syntax check
|
||||
test_formula = formula
|
||||
for ref in references:
|
||||
test_formula = test_formula.replace(f"{{{ref}}}", "1")
|
||||
|
||||
# Try to parse and evaluate with safe operations
|
||||
FormulaService._safe_eval(test_formula)
|
||||
except Exception as e:
|
||||
return False, f"Invalid formula syntax: {str(e)}", None
|
||||
|
||||
# Separate builtin and custom field references
|
||||
custom_references = references - FormulaService.BUILTIN_FIELDS
|
||||
|
||||
# Validate custom field references exist and are numeric types
|
||||
if custom_references:
|
||||
fields = db.query(CustomField).filter(
|
||||
CustomField.project_id == project_id,
|
||||
CustomField.name.in_(custom_references),
|
||||
).all()
|
||||
|
||||
found_names = {f.name for f in fields}
|
||||
missing = custom_references - found_names
|
||||
|
||||
if missing:
|
||||
return False, f"Unknown field references: {', '.join(missing)}", None
|
||||
|
||||
# Check field types (must be number or formula)
|
||||
for field in fields:
|
||||
if field.field_type not in ("number", "formula"):
|
||||
return False, f"Field '{field.name}' is not a numeric type", None
|
||||
|
||||
# Check for circular references
|
||||
if current_field_id:
|
||||
try:
|
||||
FormulaService._check_circular_references(
|
||||
db, project_id, current_field_id, references
|
||||
)
|
||||
except CircularReferenceError as e:
|
||||
return False, str(e), e.cycle_path
|
||||
|
||||
return True, None, None
|
||||
|
||||
@staticmethod
|
||||
def _safe_eval(expression: str) -> Decimal:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user