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:
When truly cross-organization access is required (for example, internal admin and maintenance tasks), use the explicit unscoped manager:
OrganizationOwnedModel uses this manager so tenant-owned models can do:
Request Context¶
Defined in prismio/organizations/services.py:
OrganizationContextdataclass:organizationmembershiproleresolve_organization_context(user=..., organization_id=...)
The resolver validates:
- Authenticated user
- Organization exists and is active
- Membership exists
- Membership status is
active - 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.viewprojects.createprojects.updateprojects.deletemembers.invitebilling.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_contextrequest.organizationrequest.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 writesrequest.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.pyaccept-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_authwhen 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:
HasOrganizationContextDRF permission:- Requires the request to already have an active organization context.
- Requires the active organization to match
organization_idfrom 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:
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:
5. UI View (if needed)¶
Use OrganizationScopedMixin and set required_permission.
6. Querying rules¶
Do:
Also acceptable in request-scoped code:
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
organizationon 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
ContextVarisn'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 thefinallyreset), 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:
- Add tests that explicitly assert the
ContextVaris reset after every request (including exception paths) and isNone/unset at the start of background jobs and management commands. - 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. - Consider enforcing tenant isolation at the database layer for
OrganizationOwnedModeltables - e.g. Postgres row-level security (RLS) policies keyed toorganization_id- so an unscoped or mis-scoped query fails closed at the database instead of depending on application code getting theContextVarright.