Aug 17 - Fix customer inspector out-of-scope issue assign
This commit is contained in:
+113
-9
@@ -608,11 +608,10 @@ def execute(inspection_id):
|
||||
flash('Draft saved. You can continue filling in the form later.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
staff_for_flag_issue = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'external_inspector',
|
||||
'project_manager', 'auditor']),
|
||||
User.active == True,
|
||||
).order_by(User.full_name, User.username).all()
|
||||
# Scoped to this inspection's contract — see _assignable_staff_for().
|
||||
# Must match flag_issue()'s choices exactly or the offcanvas silently
|
||||
# fails to save (rule 60).
|
||||
staff_for_flag_issue = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
return render_template('inspections/execute.html',
|
||||
inspection=inspection,
|
||||
@@ -923,6 +922,95 @@ def view(inspection_id):
|
||||
|
||||
# ── Flag issue during inspection ──────────────────────────────────────────────
|
||||
|
||||
#: Internal roles that are NOT contract-scoped — they work across the whole
|
||||
#: organisation, so they are offered regardless of which contract the
|
||||
#: inspection belongs to. Only ever shown to our own people.
|
||||
_ORG_WIDE_ASSIGNEE_ROLES = ('director', 'project_manager', 'auditor')
|
||||
|
||||
|
||||
def _assignable_staff_for(inspection, actor):
|
||||
"""Users `actor` may assign an issue to, for THIS inspection.
|
||||
|
||||
The candidate list is scoped by the inspection's CONTRACT, not taken
|
||||
org-wide. Two distinct problems this fixes:
|
||||
|
||||
1. **Cross-customer leak.** A Customer Inspector could assign an issue to
|
||||
anyone in the system — including another client's Customer Inspector.
|
||||
The assignee is notified by email and in-app with the facility name and
|
||||
issue description, so this handed one customer's data to another. It is
|
||||
a leak whoever flags the issue, so the contract scope is applied to the
|
||||
two inspector roles for EVERY actor, not just customer ones.
|
||||
|
||||
2. An external account should not see our internal org chart at all. For a
|
||||
customer-side actor the list is their co-workers on shared contracts —
|
||||
inspectors assigned to this inspection's contract — and nothing else.
|
||||
|
||||
Rules applied:
|
||||
* inspector / external_inspector -> only those holding an
|
||||
InspectorAssignment on this inspection's contract (the same rows
|
||||
get_inspector_scope() reads, so the list can never disagree with what
|
||||
the assignee can actually open).
|
||||
* director / project_manager / auditor -> org-wide, but offered ONLY to
|
||||
our own staff. These roles carry no InspectorAssignment rows, so
|
||||
contract-scoping them would remove them entirely and break the normal
|
||||
"escalate to the contract manager" flow.
|
||||
* inactive accounts are never offered.
|
||||
|
||||
A facility with no contract yields no contract-scoped candidates; that is
|
||||
fail-closed and correct — an external actor then gets an empty list and can
|
||||
only leave the issue unassigned.
|
||||
|
||||
Used by BOTH the offcanvas dropdown in execute() and the choices that
|
||||
validate the POST in flag_issue(). They MUST stay identical: a value the UI
|
||||
offers but the choices reject fails `validate_on_submit()`, and the
|
||||
offcanvas JS treats the resulting 200 as success — the issue is silently
|
||||
never saved (rule 60's failure mode, which is exactly what the two
|
||||
hand-maintained lists were already doing to project_manager and auditor).
|
||||
"""
|
||||
from app.models.inspector_assignment import InspectorAssignment
|
||||
|
||||
# `is_customer_account` is used here to WITHHOLD internal staff from an
|
||||
# external account — the narrowing direction, which rule 89 permits. It
|
||||
# must never be used to grant a customer-side account anything.
|
||||
actor_is_external = bool(actor) and actor.is_customer_account
|
||||
|
||||
project_id = inspection.facility.project_id if inspection.facility else None
|
||||
|
||||
candidates = []
|
||||
if project_id:
|
||||
candidates = (
|
||||
User.query
|
||||
.join(InspectorAssignment, InspectorAssignment.user_id == User.id)
|
||||
.filter(
|
||||
InspectorAssignment.project_id == project_id,
|
||||
User.role.in_(User.INSPECTOR_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
if not actor_is_external:
|
||||
candidates += (
|
||||
User.query
|
||||
.filter(
|
||||
User.role.in_(_ORG_WIDE_ASSIGNEE_ROLES),
|
||||
User.active == True,
|
||||
)
|
||||
.order_by(User.full_name, User.username)
|
||||
.all()
|
||||
)
|
||||
|
||||
# The join can repeat a user across assignment rows; dedupe by id, keeping
|
||||
# a stable display order.
|
||||
seen, out = set(), []
|
||||
for u in candidates:
|
||||
if u.id not in seen:
|
||||
seen.add(u.id)
|
||||
out.append(u)
|
||||
out.sort(key=lambda u: (u.display_name or '').lower())
|
||||
return out
|
||||
|
||||
@bp.route('/<int:inspection_id>/flag-issue', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def flag_issue(inspection_id):
|
||||
@@ -935,13 +1023,14 @@ def flag_issue(inspection_id):
|
||||
return redirect(url_for('inspections.index'))
|
||||
|
||||
form = IssueForm()
|
||||
staff = User.query.filter(
|
||||
User.role.in_(['director', 'inspector', 'external_inspector'])
|
||||
).order_by(User.username).all()
|
||||
# SAME list the offcanvas rendered — this is what actually validates the
|
||||
# POST, so it is also the security boundary: a crafted assigned_to for
|
||||
# someone outside this contract fails validation rather than being stored.
|
||||
staff = _assignable_staff_for(inspection, current_user)
|
||||
|
||||
form.facility_id.choices = [(inspection.facility_id, inspection.facility.name)]
|
||||
form.assigned_to.choices = [(0, '— Unassigned —')] + [
|
||||
(u.id, u.username + (' (Customer)' if u.is_external_inspector else ''))
|
||||
(u.id, u.display_name + (' (Customer)' if u.is_external_inspector else ''))
|
||||
for u in staff
|
||||
]
|
||||
|
||||
@@ -1008,6 +1097,21 @@ def flag_issue(inspection_id):
|
||||
flash('Issue logged successfully.', 'success')
|
||||
return redirect(url_for('inspections.execute', inspection_id=inspection_id))
|
||||
|
||||
# A failed POST must NOT come back 200. The flag-issue offcanvas treats
|
||||
# `res.ok` as success and reloads the page, so a 200 here means the issue
|
||||
# is silently discarded with the user believing it was logged — the exact
|
||||
# failure rule 60 describes. Returning 400 routes it to the JS error branch
|
||||
# so the reason is shown and the form stays open with its input intact.
|
||||
if request.method == 'POST':
|
||||
if form.assigned_to.errors:
|
||||
# Most likely an assignee outside this inspection's contract:
|
||||
# either a stale page rendered before the assignment changed, or a
|
||||
# crafted id. Say something actionable rather than "invalid choice".
|
||||
flash('That person cannot be assigned to an issue on this contract. '
|
||||
'Reopen the panel to refresh the list.', 'danger')
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection), 400
|
||||
|
||||
return render_template('inspections/flag_issue.html',
|
||||
form=form, inspection=inspection)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user