Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f526576
[feature] Made disabled organizations readonly but deletable #522
pandafy Jul 13, 2026
e93cdb4
[fix] Fixed review comments
pandafy Jul 14, 2026
c392364
[fix] Fixes by @coderabbitai
pandafy Jul 15, 2026
260285e
[docs] Updated docs
pandafy Jul 15, 2026
4233571
[fix] Fixed selenium tests
pandafy Jul 15, 2026
840e88b
[fix] Fixed selenium tests
pandafy Jul 15, 2026
02b39e6
[fix] Requested changes
pandafy Aug 3, 2026
c3d57f9
[fix] Fixed tests
pandafy Aug 5, 2026
8282f40
[fix] Fixed failing tests
pandafy Aug 5, 2026
6c7c9fe
[fix] Fixed DisabledOrgReadonlyMixin
pandafy Aug 5, 2026
9f2e2b3
[fix] Fixed QA issues
pandafy Aug 5, 2026
d40a45b
[fix] Fixed inline exclusion
pandafy Aug 5, 2026
d429af6
[fix] Made requested changes
pandafy Aug 6, 2026
aca37af
[fix] Made requested changes
pandafy Aug 6, 2026
a3ea340
[fix] Removed AI slop
pandafy Aug 7, 2026
2e870ef
[ci] Fixed QA issues
pandafy Aug 10, 2026
f954402
[feature] Added singals for organizations disabled and enabled
pandafy Aug 10, 2026
4229797
[chores] Fixed failing tests
pandafy Aug 13, 2026
2a6ebf4
[fix] Fixes by @coderabbitai
pandafy Aug 14, 2026
2e8a645
[chores] Fixed QA issues
pandafy Aug 14, 2026
4387384
[tests] Added failing tests
nemesifier Aug 14, 2026
84b2980
[tests] More failing tests
nemesifier Aug 14, 2026
77d640b
[fix] Made requested changes
pandafy Aug 17, 2026
03ab8b2
[fix] Fixed selenium tests
pandafy Aug 17, 2026
ec88b54
[chores] Fixed selenium test
pandafy Aug 17, 2026
1a8f4d1
[docs] Updated heading and anchor tag
pandafy Aug 17, 2026
e02cde3
[docs] Added disabled organization points in AGENTS.md
pandafy Aug 18, 2026
72a6878
[fix] Made requested changes
pandafy Aug 18, 2026
e77da02
[fix] Filter out objects from the disabled organization in Autocomple…
pandafy Aug 19, 2026
e7674e3
[chores] Updated wording for handling disabled org in AGENTS.md
pandafy Aug 19, 2026
71d34bf
[fix] Fixed tests
pandafy Aug 20, 2026
e393e9b
[fix] Fixes to UserAdmin inlines
pandafy Aug 21, 2026
02f2ce1
[fix] Fixed bug in MultitenantReadOnlyInlineFormSet
pandafy Aug 21, 2026
abbad50
[fix] Fixed MultitenantAdminMixin.get_formsets
pandafy Aug 21, 2026
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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ jobs:
pip install -U -r requirements-test.txt
sudo npm install -g prettier
pip install -e .[rest]
pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-users/tarball/issues/522-disabled-org
pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-controller/tarball/issues/1393-disabled-org
pip install --upgrade --force-reinstall --no-deps --no-cache-dir https://github.com/openwisp/openwisp-monitoring/tarball/issues/811-disabled-org
pip install -U ${{ matrix.django-version }}

- name: QA checks
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ If instructions conflict, repository config and CI workflows win first, official
- Cached lookups must check permission and organization scope on every request. Changed endpoints need cross-organization regression tests.
- If you change swapped-model behavior, tenant isolation, auth flows, or admin/API permissions, cover both package-level and integration tests.
- Changes to HTTP REST API endpoints or Django REST Framework serializers must include tests for permissions, input validation, filtering or pagination when supported, and organization or tenant boundaries where applicable.
- Objects belonging to a disabled organization must be readable and deletable; creation and updates must be blocked across all relevant write paths. This applies to objects with either a direct or chained/nested relationship to the organization. No other operations should be permitted, except for ordinary cleanup operations.

## Troubleshooting

Expand Down
48 changes: 48 additions & 0 deletions docs/developer/admin-utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,54 @@ This class has two important attributes:
<https://github.com/openwisp/openwisp-firmware-upgrader/search?q=multitenant_parent>`_
for a real-world example.

Disabled Organization Write Protection
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

``MultitenantAdminMixin`` also blocks changes to any object belonging to a
:ref:`disabled organization <users_disabled_organization>`, while still
allowing it to be viewed and deleted. This also applies to superusers. For
models whose organization is reached through a parent, the mixin follows
``multitenant_parent`` to protect those objects too.

This is controlled by the ``disabled_organization_write_protection`` class
attribute, which defaults to ``True``. Set it to ``False`` on a specific
``ModelAdmin`` to opt out:

.. code-block:: python

from django.contrib import admin
from openwisp_users.multitenancy import MultitenantAdminMixin


class BookAdmin(MultitenantAdminMixin, admin.ModelAdmin):
disabled_organization_write_protection = False
# other attributes

The ``organization`` form field excludes disabled organizations for
everyone, including superusers. An opted-out admin keeps the object's
current disabled organization selectable so it can be saved.

The same protection applies to **inlines** attached to a disabled object.
The parent admin denies adding and changing inline rows but keeps deletion
available, even when an inline does not use the mixin. Set
``disabled_organization_write_protection = False`` on the parent admin or
on an individual inline to opt out.

Custom admin actions are also blocked for disabled-organization objects,
except for deletion actions. To allow a specific lifecycle action while
keeping the rest of the protection enabled, list its name in
``disabled_organization_action_exclusions``:

.. code-block:: python

class DeviceAdmin(MultitenantAdminMixin, admin.ModelAdmin):
disabled_organization_action_exclusions = ("deactivate_device",)

The mixin resolves an object's organization from ``organization`` by
default and follows ``multitenant_parent`` when configured. Admins using
another relation can override ``get_object_organization()`` to return the
related organization.

``MultitenantOrgFilter``
------------------------

Expand Down
68 changes: 67 additions & 1 deletion docs/developer/django-rest-framework-utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,72 @@ organization managers or owners to view shared objects in read-only mode.

Standard users will not be able to view or list shared objects.

``DisabledOrgReadOnly``
~~~~~~~~~~~~~~~~~~~~~~~

**Full python path**:
``openwisp_users.api.permissions.DisabledOrgReadOnly``.

This object-level permission class blocks updates to objects belonging to
a :ref:`disabled organization <users_disabled_organization>`. ``GET``,
``HEAD``, ``OPTIONS`` and ``DELETE`` remain allowed.

The object's organization is resolved through the view's
``organization_field`` attribute, which defaults to ``"organization"``. An
invalid relation path denies the write instead of failing open. Views that
are not organization-scoped must opt out explicitly.

.. important::

``DisabledOrgReadOnly`` guards **updates only**. It implements
``has_object_permission``, which DRF does not call on ``POST``, so it
does **not** block creation for a disabled organization. The
serializer must exclude disabled organizations from its
``organization`` field, for example by using
``FilterSerializerByOrgMembership``, ``FilterSerializerByOrgManaged``
or ``FilterSerializerByOrgOwned`` mixin, or ``Organization.active``. A
plain ``ModelSerializer`` can still create an object for a disabled
organization.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

A view can opt out of this guard by setting
``allow_disabled_organization_writes = True``:

.. code-block:: python

from openwisp_users.api.permissions import DisabledOrgReadOnly
from rest_framework.generics import RetrieveUpdateDestroyAPIView


class SubnetView(RetrieveUpdateDestroyAPIView):
permission_classes = (DisabledOrgReadOnly,)
allow_disabled_organization_writes = True

``DisabledOrgReadOnly`` is already included in ``ProtectedAPIMixin``'s
default ``permission_classes`` (see below), so views that use
``ProtectedAPIMixin`` get this guard automatically without any extra
configuration.

.. note::

``Organization.active`` (django-organizations' ``ActiveOrgManager``)
is the canonical queryset for active organizations: use
``Organization.active.all()`` when writing custom code that needs to
select from or filter active organizations, instead of filtering
``Organization.objects`` manually.

``ProtectedAPIMixin``
---------------------

**Full python path**: ``openwisp_users.api.mixins.ProtectedAPIMixin``.

This mixin provides a set of authentication and permission classes that
are commonly used across various OpenWISP modules API views.
are commonly used across various OpenWISP modules API views, including
``DisabledOrgReadOnly`` (see above).

If a view overrides ``permission_classes`` entirely instead of extending
``ProtectedAPIMixin.permission_classes``, it will not inherit
``DisabledOrgReadOnly`` (or any future addition to the mixin's defaults)
automatically, and must re-declare it explicitly if the guard is needed.

Usage example:

Expand Down Expand Up @@ -255,6 +314,13 @@ and ``FilterSerializerByOrgOwned`` can be used to solve this issue.

These serializers do not allow non-superusers to create shared objects.

.. _multi_tenant_serializers_disabled_org:

These serializers also exclude :ref:`disabled organizations
<users_disabled_organization>` from the ``organization`` field for all
users, including superusers. Submitting a disabled organization's primary
key returns a validation error.

Usage example:

.. code-block:: python
Expand Down
33 changes: 33 additions & 0 deletions docs/developer/misc-utils.rst
Original file line number Diff line number Diff line change
Expand Up @@ -311,3 +311,36 @@ Add the validator to the ``AUTH_PASSWORD_VALIDATORS`` Django setting:
"NAME": "openwisp_users.password_validation.PasswordReuseValidator",
},
]

Signals
-------

.. include:: /partials/signals-note.rst

``organization_disabled``
~~~~~~~~~~~~~~~~~~~~~~~~~

**Path**: ``openwisp_users.signals.organization_disabled``

**Arguments**:

- ``instance``: the organization instance that was disabled

Emitted after an organization's ``is_active`` field changes from ``True``
to ``False`` and the change has been committed to the database.

This signal is not emitted when an organization is created.

``organization_enabled``
~~~~~~~~~~~~~~~~~~~~~~~~

**Path**: ``openwisp_users.signals.organization_enabled``

**Arguments**:

- ``instance``: the organization instance that was enabled

Emitted after an organization's ``is_active`` field changes from ``False``
to ``True`` and the change has been committed to the database.

This signal is not emitted when an organization is created.
42 changes: 42 additions & 0 deletions docs/user/basic-concepts.rst
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,48 @@ instance of the platform.
`django-organizations
<https://github.com/bennylope/django-organizations>`_ third-party app.

.. _users_disabled_organization:

Disabled Organization
---------------------

An organization is disabled when its **Is active** flag is unchecked on
the "Change organization" page or through the REST API. Superusers and
organization managers can disable an organization, subject to the usual
permission requirements for editing it.

A disabled organization retains its users, memberships, and related
objects. Superusers can still read and delete that data, but:

- **No new object can be created for a disabled organization**, and
**existing objects belonging to it cannot be modified**. For the
organization itself, only **Is active** can be changed and its owner can
be unassigned while it is disabled.
- Deleting objects, including the organization itself, remains allowed.
- The organization is hidden from **organization selection widgets** but
remains available in admin **list filters**.
- Re-enabling a disabled organization is allowed **only for superusers**.
Once an organization is disabled, its managers lose access to it (a
disabled organization is no longer part of the organizations they
manage), so they can no longer edit it, including re-enabling it. A
superuser must re-enable the organization before its managers regain
access.

.. note::

In the REST API, updating an object in a disabled organization returns
HTTP 400 or 403, depending on the endpoint, with an error message.

.. note::

Re-enabling an organization and editing its other fields must be done
in **two separate steps**, matching the admin interface (which locks
every field except **Is active** while the organization is disabled).
First re-enable the organization (change only **Is active**), then
edit its other fields or assign an owner. A single request that both
re-enables the organization and changes another field (or assigns an
owner) is rejected.

Organization Membership and Roles
---------------------------------

Expand Down
8 changes: 6 additions & 2 deletions docs/user/rest-api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,9 @@ Change User Detail

When editing organization memberships, the organization manager flag
is represented internally by the ``is_admin`` field in the
``organization_users`` payload.
``organization_users`` payload. For an existing membership, omitting
``is_admin`` leaves it unchanged, changing its value updates the role,
and sending its current value removes the membership.

Patch User Detail
~~~~~~~~~~~~~~~~~
Expand All @@ -369,7 +371,9 @@ Patch User Detail

When patching organization memberships, the organization manager flag
is represented internally by the ``is_admin`` field in the
``organization_users`` payload.
``organization_users`` payload. For an existing membership, omitting
``is_admin`` leaves it unchanged, changing its value updates the role,
and sending its current value removes the membership.

Delete User
~~~~~~~~~~~
Expand Down
Loading
Loading