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 (via clear_active_organization_session) when the stored org id no longer resolves, e.g. the membership was revoked mid-session.

Session State (Single Source of Truth)

Defined in prismio/organizations/services.py:

  • activate_organization_session(request, organization): the only function that writes request.session["active_organization_id"].
  • clear_active_organization_session(request): the only function that clears it.

OrganizationMiddleware is the only code that reads that session key. Every other place in the codebase gets the active organization from request.organization / request.membership / request.organization_context (or, for tenant-owned model queries, the get_current_organization() ContextVar the middleware also populates - see "Future Work" below). Nothing outside services.py should touch the session key directly.

Multiple legitimate flows call activate_organization_session, not just the switcher:

  • OrganizationAuthRedirectView (get/post) - the explicit org switcher, used after login and when the user picks a different organization in the UI.
  • SetDefaultOrganizationView (get/post) - auto-activates on first login / default org selection.
  • OrganizationCreateView.post / OnboardingOrganizationCreateView.post - activates the org a user just created.
  • prismio/internal/invite_flow.py accept-invite flow - activates the org a user just joined.
  • prismio/internal/views.py::SwitchToClientView.post - internal-admin "switch to client view".

clear_active_organization_session has two callers: OrganizationAuthRedirectView.get (explicit reset before switching) and OrganizationMiddleware (automatic cleanup on an invalid/stale session org id).

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
  • Calls activate_organization_session(request, organization).
  • 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.

Future Work

ContextVar-based default scoping is app-layer only

OrganizationScopedManager (prismio/organizations/managers.py) scopes MyModel.objects.all() via the get_current_organization() ContextVar (prismio/organizations/context.py), set by OrganizationMiddleware for the duration of a request. This is convenient, but it is a request-lifetime in-process global, not a database-enforced boundary, and that carries known risks at scale:

  • If the ContextVar isn't reset correctly on every code path (e.g. work offloaded to Celery/background tasks, thread-pool reuse under sync WSGI workers, or an exception path that skips the finally reset), a query could silently run unscoped or scoped to the wrong tenant.
  • Because scoping happens implicitly, a query that never intended to be scoped (or that runs outside a request, such as a management command) can look correctly filtered without anyone verifying it actually is.

This is the standard tradeoff for app-layer multi-tenancy and is not unique to this codebase, but it is one layer short of defense-in-depth. Recommended follow-ups, roughly in order of effort:

  1. Add tests that explicitly assert the ContextVar is reset after every request (including exception paths) and is None/unset at the start of background jobs and management commands.
  2. Keep preferring explicit .for_organization(request.organization) on sensitive read/write paths rather than relying on the implicit default manager, per the existing "defense in depth" guidance above.
  3. Consider enforcing tenant isolation at the database layer for OrganizationOwnedModel tables - e.g. Postgres row-level security (RLS) policies keyed to organization_id - so an unscoped or mis-scoped query fails closed at the database instead of depending on application code getting the ContextVar right.