BE-PR-009: restrict all story/* task types to Proposal Accept workflow
- Expand RESTRICTED_TYPE_SUBTYPES to include story/feature, story/improvement,
story/refactor, and story/None (all story subtypes)
- Add FULLY_RESTRICTED_TYPES fast-path set for entire-type blocking
- Update _validate_task_type_subtype to block all story types via general
create endpoint with clear error message directing to Proposal Accept
- Add type/subtype validation to PATCH /tasks/{id} to prevent changing
existing tasks to story/* type via update
- Internal Proposal Accept flow unaffected (creates tasks directly via ORM)
This commit is contained in:
@@ -66,15 +66,22 @@ TASK_SUBTYPE_MAP = {
|
|||||||
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
|
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
|
||||||
|
|
||||||
|
|
||||||
"""P9.6 — type+subtype combos that may NOT be created via general create endpoints.
|
"""P9.6 / BE-PR-009 — type+subtype combos that may NOT be created via general
|
||||||
feature story → must come from propose accept
|
endpoints. All story/* subtypes are restricted; they must come from Proposal
|
||||||
release maintenance → must come from controlled milestone/release flow
|
Accept. maintenance/release must come from the milestone release flow.
|
||||||
"""
|
"""
|
||||||
RESTRICTED_TYPE_SUBTYPES = {
|
RESTRICTED_TYPE_SUBTYPES = {
|
||||||
("story", "feature"),
|
("story", "feature"),
|
||||||
|
("story", "improvement"),
|
||||||
|
("story", "refactor"),
|
||||||
|
("story", None), # story with no subtype is also blocked
|
||||||
("maintenance", "release"),
|
("maintenance", "release"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Convenience set: task types whose *entire* type is restricted regardless of subtype.
|
||||||
|
# Used for a fast-path check so we don't need to enumerate every subtype.
|
||||||
|
FULLY_RESTRICTED_TYPES = {"story"}
|
||||||
|
|
||||||
|
|
||||||
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, *, allow_restricted: bool = False):
|
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, *, allow_restricted: bool = False):
|
||||||
if task_type is None:
|
if task_type is None:
|
||||||
@@ -84,13 +91,23 @@ def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None,
|
|||||||
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
|
allowed = TASK_SUBTYPE_MAP.get(task_type, set())
|
||||||
if task_subtype and task_subtype not in allowed:
|
if task_subtype and task_subtype not in allowed:
|
||||||
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
|
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
|
||||||
# P9.6: block restricted combos unless explicitly allowed (e.g. propose accept, internal create)
|
# P9.6 / BE-PR-009: block restricted combos unless explicitly allowed
|
||||||
if not allow_restricted and (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES:
|
# (e.g. Proposal Accept, internal create)
|
||||||
raise HTTPException(
|
if not allow_restricted:
|
||||||
status_code=400,
|
# Fast-path: entire type is restricted (all story/* combos)
|
||||||
detail=f"Cannot create {task_type}/{task_subtype} task via general create. "
|
if task_type in FULLY_RESTRICTED_TYPES:
|
||||||
f"Use the appropriate workflow (propose accept / milestone release setup)."
|
raise HTTPException(
|
||||||
)
|
status_code=400,
|
||||||
|
detail=f"Cannot create '{task_type}' tasks via general endpoints. "
|
||||||
|
f"Use the Proposal Accept workflow instead.",
|
||||||
|
)
|
||||||
|
# Specific type+subtype combos (e.g. maintenance/release)
|
||||||
|
if (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Cannot create {task_type}/{task_subtype} task via general create. "
|
||||||
|
f"Use the appropriate workflow (Proposal Accept / milestone release setup).",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
|
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
|
||||||
@@ -383,6 +400,16 @@ def update_task(task_id: str, task_update: schemas.TaskUpdate, db: Session = Dep
|
|||||||
detail="Only the current assignee or an admin can edit this task",
|
detail="Only the current assignee or an admin can edit this task",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# BE-PR-009: prevent changing task_type to a restricted type via PATCH
|
||||||
|
new_task_type = update_data.get("task_type")
|
||||||
|
new_task_subtype = update_data.get("task_subtype", task.task_subtype)
|
||||||
|
if new_task_type is not None:
|
||||||
|
_validate_task_type_subtype(new_task_type, new_task_subtype)
|
||||||
|
elif "task_subtype" in update_data:
|
||||||
|
# subtype changed but type unchanged — validate the combo
|
||||||
|
current_type = task.task_type.value if hasattr(task.task_type, "value") else (task.task_type or "issue")
|
||||||
|
_validate_task_type_subtype(current_type, new_task_subtype)
|
||||||
|
|
||||||
# Legacy general permission check (covers project membership etc.)
|
# Legacy general permission check (covers project membership etc.)
|
||||||
ensure_can_edit_task(db, current_user.id, task)
|
ensure_can_edit_task(db, current_user.id, task)
|
||||||
if "status" in update_data:
|
if "status" in update_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user