Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/backend/InvenTree/stock/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import common.settings
import InvenTree.helpers
import InvenTree.permissions
from common.settings import get_global_setting
import stock.serializers as StockSerializers
from build.models import Build
from build.serializers import BuildSerializer
Expand Down Expand Up @@ -261,6 +262,41 @@ def get_serializer_context(self):
return ctx


class StockReconcile(CreateAPI):
"""API endpoint for performing stock reconciliation (cycle counting).

Accepts a list of stock items with their physically counted quantities
and adjusts the recorded stock levels accordingly. This is intended for
mobile barcode-scanner workflows where a warehouse associate walks through
a location and submits counted values for each item.
"""

queryset = StockItem.objects.none()
serializer_class = StockSerializers.StockReconciliationSerializer
role_required = 'stock.change'

def get_serializer_context(self):
"""Extend serializer context with request."""
context = super().get_serializer_context()
context['request'] = self.request
return context

def create(self, request, *args, **kwargs):
"""Perform the stock reconciliation and return results."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
results = serializer.save()

return Response(
{
'success': True,
'items_processed': len(results),
'results': results,
},
status=status.HTTP_200_OK,
)


class StockLocationFilter(FilterSet):
"""Base class for custom API filters for the StockLocation endpoint."""

Expand Down Expand Up @@ -1701,6 +1737,7 @@ def create(self, request, *args, **kwargs):
path('assign/', StockAssign.as_view(), name='api-stock-assign'),
path('merge/', StockMerge.as_view(), name='api-stock-merge'),
path('change_status/', StockChangeStatus.as_view(), name='api-stock-change-status'),
path('reconcile/', StockReconcile.as_view(), name='api-stock-reconcile'),
# StockItemTestResult API endpoints
path(
'test/',
Expand Down
146 changes: 146 additions & 0 deletions src/backend/InvenTree/stock/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1684,6 +1684,15 @@ def save(self):
stock_item = item['pk']
quantity = item['quantity']

# Enforce ownership controls: user must own the stock item
if get_global_setting('STOCK_OWNERSHIP_CONTROL'):
if not stock_item.check_ownership(request.user):
raise ValidationError(
_('User does not have ownership of stock item {item}').format(
item=stock_item.pk
)
)

# Optional fields
extra = {}

Expand Down Expand Up @@ -1857,6 +1866,143 @@ def save(self):
)


class StockReconciliationItemSerializer(serializers.Serializer):
"""Serializer for a single item in a stock reconciliation request."""

class Meta:
"""Metaclass options."""

fields = ['pk', 'counted_quantity']

pk = serializers.PrimaryKeyRelatedField(
queryset=StockItem.objects.all(),
many=False,
allow_null=False,
required=True,
label='stock_item',
help_text=_('StockItem primary key value'),
)

counted_quantity = InvenTreeDecimalField(
required=True,
label=_('Counted Quantity'),
help_text=_('Physical count of this stock item during reconciliation'),
)

def validate_counted_quantity(self, counted_quantity):
"""Validate the counted quantity."""
if counted_quantity < 0:
raise ValidationError(_('Counted quantity must not be negative'))

return counted_quantity


class StockReconciliationSerializer(serializers.Serializer):
"""Serializer for performing a full stock reconciliation (cycle count).

Accepts a list of stock items with their physically-counted quantities
and adjusts the recorded stock levels to match. Designed for use with
handheld barcode scanners and mobile inventory-audit workflows.
"""

class Meta:
"""Metaclass options."""

fields = ['items', 'location', 'notes']

items = StockReconciliationItemSerializer(many=True)

location = serializers.PrimaryKeyRelatedField(
queryset=StockLocation.objects.all(),
many=False,
required=True,
allow_null=False,
label=_('Location'),
help_text=_('Stock location being reconciled'),
)

notes = serializers.CharField(
required=False,
allow_blank=True,
label=_('Notes'),
help_text=_('Reconciliation notes'),
)

def validate(self, data):
"""Ensure items list is not empty."""
super().validate(data)

items = data.get('items', [])

if len(items) == 0:
raise ValidationError(
_('A list of stock items must be provided for reconciliation')
)

location = data.get('location')

# TODO: enforce STOCK_OWNERSHIP_CONTROL — check location.check_ownership(request.user)

# Verify that each item actually belongs to the specified location
for item in items:
stock_item = item['pk']
if stock_item.location and stock_item.location.pk != location.pk:
raise ValidationError(
_(
'Stock item {item} is not located in {location}'
).format(item=stock_item.pk, location=location.name)
)

return data

def save(self):
"""Perform the stock reconciliation.

Adjust quantities to match the physical count and record
the reconciliation event in the stock tracking history.
"""
request = self.context['request']

data = self.validated_data
items = data['items']
notes = data.get('notes', '')

results = []

with transaction.atomic():
for item in items:
stock_item = item['pk']
counted = item['counted_quantity']

previous_quantity = stock_item.quantity
difference = counted - previous_quantity

if difference == 0:
results.append({
'pk': stock_item.pk,
'status': 'unchanged',
'quantity': float(stock_item.quantity),
})
continue

# Use the built-in stocktake method to record the adjustment
stock_item.stocktake(
counted,
request.user,
notes=f'Reconciliation: {notes}' if notes else 'Stock reconciliation',
)
Comment on lines +1989 to +1993
Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing Authorization in Stock Reconciliation (Severity: LOW)

Insecure direct object references allow unauthorized users to adjust stock items. The stocktake method in serializers.py performs adjustments without verifying the user's permissions for each individual stock item, which could lead to unauthorized modifications of inventory data.
View details in ZeroPath

Suggested fix

Unable to apply as inline suggestion. Download .diff and apply from repo root with git apply 64d56011.diff

diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py
--- a/src/backend/InvenTree/stock/serializers.py
+++ b/src/backend/InvenTree/stock/serializers.py
@@ -1986,6 +1986,8 @@
                     continue
 
                 # Use the built-in stocktake method to record the adjustment
+                if not request.user.has_perm('stock.change_stockitem', stock_item):
+                    raise ValidationError(_('User does not have permission to reconcile stock item {item}').format(item=stock_item.pk))
                 stock_item.stocktake(
                     counted,
                     request.user,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What specific data can a user adjust, here?

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this code path, the user can adjust stock item quantities during reconciliation. Specifically, the serializer iterates stock items and calls stock_item.stocktake(counted, request.user, …) without a per‑item permission check, which records an adjustment for that stock item’s quantity (and related stocktake metadata like notes). So an unauthorized user could modify the inventory quantity for any targeted StockItem in the reconciliation payload.

If you want, I can trace the exact request fields (counted, item IDs, etc.) from the serializer to confirm the full set of mutable fields in this operation.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any prerequisites to hitting this endpoint? Any roles/permissions required?

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the issue context, the only guarantee here is that request.user exists (so the endpoint is authenticated), but there’s no per‑item authorization check before calling stock_item.stocktake(...) in serializers.py:1989–1993. That means any authenticated user who can reach the stock reconciliation endpoint can adjust stock items they don’t own/aren’t authorized for, unless upstream view permissions block them.

I don’t see explicit endpoint-level permission requirements in the issue data. If you can point me to the specific view/action (e.g., DRF viewset/route for stock reconciliation), I can verify the exact permission classes and whether the endpoint requires a specific role (e.g., stock.change_stockitem).

In this PR, the recommended fix is to enforce:

request.user.has_perm('stock.change_stockitem', stock_item)

(or the new stock_item.check_ownership path noted in the assessment). This makes the endpoint safe even if the endpoint-level permissions are broad.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, just checked in, and this app isn't actually deployed yet. Can you mark this as an accepted risk for now?

Copy link

@zeropath-ai-demo zeropath-ai-demo bot Feb 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status updated to ACCEPTED_RISK for issue 64d56011-8f11-4c58-9852-78d368101b31 with the noted reason (app not deployed yet).

If/when deployment is planned, I recommend revisiting and either patching or moving it back to review.


results.append({
'pk': stock_item.pk,
'status': 'adjusted',
'previous_quantity': float(previous_quantity),
'counted_quantity': float(counted),
'difference': float(difference),
})

return results


class StockItemSerialNumbersSerializer(InvenTree.serializers.InvenTreeModelSerializer):
"""Serializer for extra serial number information about a stock item."""

Expand Down