Skip to content

Multi-Tenancy and Authorization

Goal

Prismio enforces tenant isolation at the application layer with secure defaults:

  • A user can belong to many organizations.
  • Each membership has a role and status.
  • Each request runs in exactly one active organization.
  • UI and API share the same authorization path.
  • Organization scoping is server-side and automatic where possible.

Core Components

Models

Defined in prismio/organizations/models.py:

  • Organization: tenant entity.
  • Role: role record (owner, admin, etc).
  • RolePermission: codename permissions attached to each role.
  • OrganizationMembership: user + organization + role + status.
  • MembershipStatus: active, invited, disabled.
  • OrganizationOwnedModel (abstract): base class for tenant-owned data.
  • Project: reference tenant-owned model for API examples.

Query Scoping

Defined in prismio/organizations/managers.py:

  • OrganizationQuerySet.for_organization(organization)

Tenant-owned models now use a scoped default manager during requests with an active organization context. In those requests, MyModel.objects.all() is automatically scoped to the active organization.

For defense in depth, keep explicit scoping in sensitive paths:

MyModel.objects.for_organization(request.organization)

When truly cross-organization access is required (for example, internal admin and maintenance tasks), use the explicit unscoped manager:

MyModel.unscoped_objects.all()

OrganizationOwnedModel uses this manager so tenant-owned models can do:

MyModel.objects.for_organization(request.organization)

Request Context

Defined in prismio/organizations/services.py:

  • OrganizationContext dataclass:
  • organization
  • membership
  • role
  • resolve_organization_context(user=..., organization_id=...)

The resolver validates:

  1. Authenticated user
  2. Organization exists and is active
  3. Membership exists
  4. Membership status is active
  5. Role exists and is active

Permission Service

Defined in prismio/organizations/permissions.py:

  • user_has_permission(org_context, codename)
  • require_permission(org_context, codename)
  • require_object_in_active_org(obj, org_context)

Suggested codename style:

  • projects.view
  • projects.create
  • projects.update
  • projects.delete
  • members.invite
  • billing.manage

Middleware and Active Organization

prismio/organizations/middleware.py:

  • Reads request.session["active_organization_id"].
  • Resolves context with resolve_organization_context.
  • Attaches to request:
  • request.organization_context
  • request.organization
  • request.membership
  • Sets request-local organization context used by scoped managers.
  • Clears invalid organization session state automatically.

The only supported way to change the active organization is the organization switch endpoint handled by prismio/organizations/views.py::OrganizationAuthRedirectView. That view is used both after login and when the user selects a different organization in the UI.

UI Authorization Path

prismio/organizations/mixins.py:

  • OrganizationScopedMixin
  • Requires the URL org to match the already-active organization.
  • Redirects to organizations:organization_auth when the active org does not match.
  • Enforces optional required_permission.
  • Scopes queryset in get_queryset() when .for_organization() exists.

prismio/organizations/views.py:

  • OrganizationAuthRedirectView
  • Sets request.session["active_organization_id"] and the matching organization payload.
  • Runs on login redirect and on explicit org switching in the UI.

Use in class-based views:

class ExampleView(LoginRequiredMixin, OrganizationScopedMixin, ListView):
    model = Project
    required_permission = "projects.view"

API Authorization Path

prismio/organizations/permissions.py:

  • HasOrganizationContext DRF permission:
  • Requires the request to already have an active organization context.
  • Requires the active organization to match organization_id from the route.
  • Enforces action permission via view.get_required_permission().
  • Validates object organization in has_object_permission.

prismio/organizations/viewsets.py:

  • OrganizationScopedViewSet:
  • permission_classes = [HasOrganizationContext]
  • Declarative permission_map
  • Scoped get_queryset()
  • Server-side perform_create(serializer.save(organization=request.organization))

API requests cannot switch organizations by changing the URL alone. A user must first switch the active organization through the UI auth path.

Example endpoint pattern:

/api/orgs/{organization_id}/projects/

Creating a New Tenant-Owned Model

1. Define model

from django.db import models
from prismio.organizations.models import OrganizationOwnedModel


class Report(OrganizationOwnedModel):
    title = models.CharField(max_length=255)

2. Serializer must not accept organization from client

from rest_framework import serializers


class ReportSerializer(serializers.ModelSerializer):
    organization = serializers.PrimaryKeyRelatedField(read_only=True)

    class Meta:
        model = Report
        fields = ["id", "title", "organization"]

3. API ViewSet

from prismio.organizations.viewsets import OrganizationScopedViewSet


class ReportViewSet(OrganizationScopedViewSet):
    queryset = Report.objects.all()
    serializer_class = ReportSerializer
    permission_map = {
        "list": "reports.view",
        "retrieve": "reports.view",
        "create": "reports.create",
        "update": "reports.update",
        "partial_update": "reports.update",
        "destroy": "reports.delete",
    }

4. URL shape

Use org-scoped URLs:

/api/orgs/{organization_id}/reports/
/api/orgs/{organization_id}/reports/{id}/

5. UI View (if needed)

Use OrganizationScopedMixin and set required_permission.

6. Querying rules

Do:

self.get_queryset().get(pk=pk)
Report.objects.for_organization(request.organization)

Also acceptable in request-scoped code:

Report.objects.all()

Prefer explicit for_organization(...) for defense in depth on critical read and write paths.

Avoid:

Report.objects.get(pk=pk)
Report.unscoped_objects.all()  # unless explicitly needed for cross-org work

Role and Permission Bootstrap

ensure_default_roles() seeds baseline roles and permissions.

Call this in setup paths where role records may be needed before user actions (for example, organization auth/create flows and selected tests).

Required Test Coverage

At minimum, include tests for:

  • Cross-organization object access is denied.
  • Inactive membership is denied.
  • Wrong role is denied for mutating actions.
  • Client cannot force organization on create.
  • UI and API enforce the same policy.
  • Switching active organization changes visible tenant data.

Reference tests:

  • prismio/organizations/tests/test_multitenancy_security.py

Security Defaults Summary

  • Tenant-owned models inherit OrganizationOwnedModel.
  • API uses OrganizationScopedViewSet.
  • UI uses OrganizationScopedMixin.
  • Permissions are declared and centralized.
  • Organization assignment is server-side.
  • Object-level organization checks are explicit.