Skip to content

Commit 6aa19a3

Browse files
authored
feat(db): implement P0 database TODOs - issue deps & audit logging (#216)
* feat(db): implement P0 database TODOs - issue deps & audit logging (#207) Completes high-priority database integration items: Issue Dependency Parsing: - Add depends_on TEXT column to issues table schema - Parse depends_on JSON field in get_issues_with_tasks() - Handle NULL, invalid JSON, and non-list values gracefully - Add depends_on to ALLOWED_ISSUE_FIELDS whitelist - Add migration support for existing databases Project Audit Logging: - Add user_id and ip_address params to update_project() - Add user_id and ip_address params to delete_project() - Log PROJECT_UPDATED event with updated_fields metadata - Log PROJECT_DELETED event with project name preservation - Skip audit logging for system operations (no user_id) Tests: - 16 TDD tests covering all new functionality - Verified 72 existing tests still pass (backward compatible) * refactor(db): address code review feedback for database TODOs Security: - Add SQL identifier validation in _add_column_if_not_exists() to prevent SQL injection (validates alphanumeric + underscore pattern) Code Quality: - Extract _parse_depends_on() helper method for centralized JSON parsing - Document local imports explaining circular dependency avoidance - Document audit logging transaction behavior (data consistency over audit) All 16 tests still passing. * fix(db): ensure type consistency and audit gating - _parse_depends_on: Coerce JSON values to strings for consistent API contract (json.loads may return ints, but callers expect List[str]) - delete_project: Only emit PROJECT_DELETED audit when rows_affected > 0 (mirrors update_project behavior, avoids logging no-op deletions)
1 parent 8e279ab commit 6aa19a3

File tree

4 files changed

+591
-11
lines changed

4 files changed

+591
-11
lines changed

codeframe/persistence/repositories/issue_repository.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"priority",
3030
"workflow_step",
3131
"completed_at",
32+
"depends_on",
3233
}
3334

3435

@@ -154,6 +155,9 @@ def get_issues_with_tasks(self, project_id: int, include_tasks: bool = False) ->
154155
for issue_row in issue_rows:
155156
issue_dict = dict(issue_row)
156157

158+
# Parse depends_on from JSON using centralized helper
159+
depends_on = self._parse_depends_on(issue_dict.get("depends_on"))
160+
157161
# Format issue according to API contract
158162
formatted_issue = {
159163
"id": str(issue_dict["id"]),
@@ -162,7 +166,7 @@ def get_issues_with_tasks(self, project_id: int, include_tasks: bool = False) ->
162166
"description": issue_dict["description"] or "",
163167
"status": issue_dict["status"],
164168
"priority": issue_dict["priority"],
165-
"depends_on": [], # TODO: Parse from database if stored
169+
"depends_on": depends_on,
166170
"proposed_by": "agent", # Default for now
167171
"created_at": self._ensure_rfc3339(issue_dict["created_at"]),
168172
"updated_at": self._ensure_rfc3339(issue_dict["created_at"]), # Use created_at for now
@@ -306,6 +310,33 @@ def get_issue_with_task_counts(self, issue_id: int) -> Optional[IssueWithTaskCou
306310

307311

308312

313+
def _parse_depends_on(self, depends_on_str: Optional[str]) -> List[str]:
314+
"""Parse depends_on JSON string into a list of dependency IDs.
315+
316+
The depends_on field is stored as a JSON array of issue/task IDs.
317+
IDs may be stored as integers or strings in the JSON; this method
318+
coerces all values to strings for consistency with API contracts.
319+
Handles NULL values, invalid JSON, and non-list JSON gracefully.
320+
321+
Args:
322+
depends_on_str: JSON string from database, or None
323+
324+
Returns:
325+
List of dependency IDs as strings, or empty list if parsing fails
326+
"""
327+
if not depends_on_str:
328+
return []
329+
try:
330+
parsed = json.loads(depends_on_str)
331+
# Ensure it's a list - non-list JSON returns empty list
332+
if isinstance(parsed, list):
333+
# Coerce all values to strings for consistent API contract
334+
return [str(x) for x in parsed]
335+
return []
336+
except (json.JSONDecodeError, TypeError):
337+
# Invalid JSON returns empty list
338+
return []
339+
309340
def _row_to_issue(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Issue:
310341
"""Convert a database row to an Issue object.
311342

codeframe/persistence/repositories/project_repository.py

Lines changed: 61 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -191,15 +191,20 @@ def list_projects(self) -> List[Dict[str, Any]]:
191191

192192

193193

194-
def update_project(self, project_id: int, updates: Dict[str, Any]) -> int:
194+
def update_project(
195+
self,
196+
project_id: int,
197+
updates: Dict[str, Any],
198+
user_id: Optional[int] = None,
199+
ip_address: Optional[str] = None,
200+
) -> int:
195201
"""Update project fields.
196202
197-
TODO(Issue #132): Add audit logging for PROJECT_UPDATED event.
198-
Requires adding user_id parameter and updating all callers.
199-
200203
Args:
201204
project_id: Project ID to update
202205
updates: Dictionary of fields to update
206+
user_id: ID of user performing update (for audit logging)
207+
ip_address: Client IP address (for audit logging)
203208
204209
Returns:
205210
Number of rows affected
@@ -238,24 +243,70 @@ def update_project(self, project_id: int, updates: Dict[str, Any]) -> int:
238243
cursor.execute(query, values)
239244
self.conn.commit()
240245

241-
return cursor.rowcount
246+
rows_affected = cursor.rowcount
242247

248+
# Log project update if user_id is provided
249+
# NOTE: Audit logging happens after commit, so if audit fails, the update
250+
# is already persisted. This is intentional - we prefer data consistency
251+
# over audit completeness. Failed audits are logged but don't roll back.
252+
if user_id is not None and rows_affected > 0:
253+
# Import locally to avoid circular dependency: audit_logger -> database -> project_repository
254+
from codeframe.lib.audit_logger import AuditLogger, AuditEventType
255+
audit = AuditLogger(self._database if self._database else self)
256+
audit.log_project_event(
257+
event_type=AuditEventType.PROJECT_UPDATED,
258+
user_id=user_id,
259+
project_id=project_id,
260+
ip_address=ip_address,
261+
metadata={"updated_fields": list(updates.keys())},
262+
)
243263

264+
return rows_affected
244265

245-
def delete_project(self, project_id: int) -> None:
246-
"""Delete a project.
247266

248-
TODO(Issue #132): Add audit logging for PROJECT_DELETED event.
249-
Requires adding user_id parameter to distinguish user-initiated
250-
deletions from automatic cleanup operations.
267+
268+
def delete_project(
269+
self,
270+
project_id: int,
271+
user_id: Optional[int] = None,
272+
ip_address: Optional[str] = None,
273+
) -> None:
274+
"""Delete a project.
251275
252276
Args:
253277
project_id: Project ID to delete
278+
user_id: ID of user performing deletion (for audit logging)
279+
ip_address: Client IP address (for audit logging)
254280
"""
281+
# Get project name before deletion for audit log
282+
project_name = None
283+
if user_id is not None:
284+
project = self.get_project(project_id)
285+
if project:
286+
project_name = project.get("name")
287+
255288
cursor = self.conn.cursor()
256289
cursor.execute("DELETE FROM projects WHERE id = ?", (project_id,))
257290
self.conn.commit()
258291

292+
rows_affected = cursor.rowcount
293+
294+
# Log project deletion if user_id is provided and deletion actually occurred
295+
# NOTE: Audit logging happens after commit, so if audit fails, the deletion
296+
# is already persisted. This is intentional - we prefer data consistency
297+
# over audit completeness. Failed audits are logged but don't roll back.
298+
if user_id is not None and rows_affected > 0:
299+
# Import locally to avoid circular dependency: audit_logger -> database -> project_repository
300+
from codeframe.lib.audit_logger import AuditLogger, AuditEventType
301+
audit = AuditLogger(self._database if self._database else self)
302+
audit.log_project_event(
303+
event_type=AuditEventType.PROJECT_DELETED,
304+
user_id=user_id,
305+
project_id=project_id,
306+
ip_address=ip_address,
307+
metadata={"name": project_name} if project_name else None,
308+
)
309+
259310

260311

261312
def _row_to_project(self, row: Union[sqlite3.Row, aiosqlite.Row]) -> Project:

codeframe/persistence/schema_manager.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,69 @@ def create_schema(self) -> None:
6363
# Create all indexes
6464
self._create_indexes(cursor)
6565

66+
# Apply schema migrations for existing databases
67+
self._apply_migrations(cursor)
68+
6669
self.conn.commit()
6770

6871
# Ensure default admin user exists
6972
self._ensure_default_admin_user()
7073

74+
def _apply_migrations(self, cursor: sqlite3.Cursor) -> None:
75+
"""Apply schema migrations for existing databases.
76+
77+
Handles adding new columns to existing tables.
78+
These are idempotent - safe to run multiple times.
79+
"""
80+
# Migration: Add depends_on column to issues table (cf-207)
81+
self._add_column_if_not_exists(
82+
cursor, "issues", "depends_on", "TEXT"
83+
)
84+
85+
def _add_column_if_not_exists(
86+
self,
87+
cursor: sqlite3.Cursor,
88+
table_name: str,
89+
column_name: str,
90+
column_type: str,
91+
default_value: str = None,
92+
) -> None:
93+
"""Add a column to a table if it doesn't exist.
94+
95+
Args:
96+
cursor: SQLite cursor
97+
table_name: Table to modify
98+
column_name: Column to add
99+
column_type: SQLite column type
100+
default_value: Optional default value for the column
101+
102+
Raises:
103+
ValueError: If table_name, column_name, or column_type contain invalid characters
104+
"""
105+
# SECURITY: Validate identifiers to prevent SQL injection.
106+
# Only alphanumeric + underscore allowed (standard SQL identifier rules).
107+
import re
108+
identifier_pattern = re.compile(r'^[a-zA-Z_][a-zA-Z0-9_]*$')
109+
for name, value in [("table_name", table_name), ("column_name", column_name), ("column_type", column_type)]:
110+
if not identifier_pattern.match(value):
111+
raise ValueError(f"Invalid SQL identifier for {name}: {value}")
112+
113+
# Check if column exists
114+
cursor.execute(f"PRAGMA table_info({table_name})")
115+
columns = {row[1] for row in cursor.fetchall()}
116+
117+
if column_name not in columns:
118+
# Add the column
119+
if default_value is not None:
120+
cursor.execute(
121+
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type} DEFAULT {default_value}"
122+
)
123+
else:
124+
cursor.execute(
125+
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type}"
126+
)
127+
logger.info(f"Added column {column_name} to {table_name}")
128+
71129
def _create_auth_tables(self, cursor: sqlite3.Cursor) -> None:
72130
"""Create authentication tables (fastapi-users compatible)."""
73131
cursor.execute(
@@ -198,6 +256,7 @@ def _create_issue_task_tables(self, cursor: sqlite3.Cursor) -> None:
198256
status TEXT CHECK(status IN ('pending', 'in_progress', 'completed', 'failed')),
199257
priority INTEGER CHECK(priority BETWEEN 0 AND 4),
200258
workflow_step INTEGER,
259+
depends_on TEXT,
201260
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
202261
completed_at TIMESTAMP,
203262
UNIQUE(project_id, issue_number)

0 commit comments

Comments
 (0)