MOVGRANTO

Read-Only Mode Instead of Account Lockout in SaaS

An expired BusiKM subscription cuts off writes, not access to data. How read-only mode works in Django and what the exception list costs.

Published: 8 min read

Author: Łukasz Głowacz, Full-Stack Engineer · Czytaj po polsku

Two boxes: read and export returning 200 OK, and write returning 402 after the subscription expires.
Contents

When a company's subscription in BusiKM ends, the system switches to read-only mode. The owner, the accountant and the drivers can still log in, browse routes and export data, but they can't add or change anything. I chose this over a typical account lockout because in this industry customers still need their data after the subscription lapses, and not out of convenience.

Why not a regular account lockout

The simplest paywall shows the user only a payment screen after login once the subscription has expired. For most apps that's enough. In BusiKM it's different, because a vehicle mileage log is an accounting document that a company must keep for five years. I wrote it down in the business model documentation as a principle: cutting off access to a company's own log over an unpaid invoice would cause the customer trouble with the tax office, not just inconvenience. On top of that, the data covers periods the customer has already paid for.

That gave me one rule for the whole system: an expired subscription cuts off writes, while reading and export stay. The demo account got the same behavior, since it is meant to be read-only permanently.

One gate instead of a check in every view

The BusiKM backend is Django with DRF and over 30 apps, each with its own views. If I checked the subscription in every write view separately, every new endpoint would have to remember the decorator, and the first one forgotten would become a hole. So the lock lives in a single middleware, SubscriptionWriteGateMiddleware, which sees every API request. The criterion is the HTTP method: GET always passes, while POST, PUT, PATCH and DELETE pass only if the company has a valid subscription or the path is on the exception list.

Request flow through the write gate: GET always passes, writes pass for paths on the exception list or with a valid subscription, otherwise the backend returns 402 write_access

class SubscriptionWriteGateMiddleware:
    WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
 
    # Paths that must work exactly when the subscription has expired:
    # log in, pay, take your own data out.
    ALWAYS_ALLOWED_PREFIXES = (
        "/api/v1/accounts/auth/",
        "/api/v1/billing/",
        "/api/v1/users/me/data-export/",  # GDPR export
        "/api/v1/integrations/exports/",  # accounting exports
        "/api/v1/documents/reports/",     # PDF reports
        "/api/v1/reports/",               # POST, but read-only
        # ...
    )
 
    def _blocked_reason(self, request):
        if request.method not in self.WRITE_METHODS:
            return None
        if request.path.startswith(self.ALWAYS_ALLOWED_PREFIXES):
            return None
        user = self._resolve_user(request)  # reads the JWT itself
        if user is None or user.is_superuser:
            return None
        company = getattr(user, "company", None)
        if company is None:
            return None
        if company.is_demo:
            return _read_only_response("demo", "...")
        subscription = getattr(company, "subscription", None)
        if subscription is None or not subscription.is_valid:
            return _read_only_response(subscription.plan if subscription else "no_subscription", "...")
        return None

The exception list is the most important part of this code. Without login, payments and export, read-only mode would be a trap with no way out: the customer couldn't even pay to get back to normal work. Payment endpoints have to work precisely when the subscription has expired.

One detail came up during rollout that is easy to forget. DRF authenticates the JWT token only inside the view, so in the middleware request.user is still anonymous. A gate that trusts request.user would block nothing, because every request from the app would look anonymous to it. That's why _resolve_user reads the token itself via JWTAuthentication. If the token is invalid, the gate does nothing, and regular authentication in the view decides on access.

The response when blocked is a 402 with the feature: "write_access" field. The same 402 code is also returned by plan gates for individual features, for example export to an accounting system on a lower plan. The feature field lets clients tell "this feature isn't in your plan" apart from "the whole company is in read-only mode".

A test that checks the protected paths actually exist

The prefix list in the middleware has a weak spot: a typo in a prefix causes no error at all. That's how it was in the previous middleware for feature gating. For the whole time it guarded three routes that weren't in config/urls.py. The tests passed because the middleware responded before routing, so they were testing themselves rather than whether the protected path exists. The same problem was in the rate-limit exemptions: /api/v1/health/ didn't exist (the real path is /api/v1/monitoring/health/), and /metrics/ with a trailing slash didn't match /metrics. So the health check and metrics were subject to the limit, even though they were supposed to be exempt.

Now a separate test walks through all routes registered in the Django resolver and checks that every prefix on the exception list leads to an existing route. A typo in a prefix means either a hole in the gate or a customer who can't pay, and the test catches both cases in CI.

Two gates that disagreed

On October 7 I fixed a bug that shows well the cost of layered checks. The middleware let through export to the accounting system and PDF reports, because their paths are on the exception list. But those same views also have a plan_gate decorator, which checks whether the company's plan includes a given feature. And with an expired subscription, that decorator returned 402 for everything. The first gate said "allowed", the second said "not allowed", and after the subscription lapsed the customer couldn't get their log out, which is exactly what the whole read-only mode was meant to prevent.

The second case was simpler. KPiR (the Polish tax revenue and expense ledger) and the cost summary use POST, because the period parameters are in the request body, but they don't write anything. Their prefix /api/v1/reports/ wasn't on the list, so the middleware blocked them as writes.

# Features that only take the customer's own data out.
# An expired subscription does not close them.
DATA_RETRIEVAL_FEATURES = frozenset({"fk_export", "pdf_reports"})
 
def check_plan_or_402(request, *, feature, min_plan, require_feature_flag=True):
    _, sub = _resolve_subscription(request)
    if sub is None:
        return plan_gate_response(feature=feature, min_plan=min_plan, current_plan="no_subscription")
    if sub.is_expired and feature not in DATA_RETRIEVAL_FEATURES:
        return plan_gate_response(feature=feature, min_plan=min_plan, current_plan="expired")
    if not _plan_allows(sub.plan, min_plan):
        return plan_gate_response(feature=feature, min_plan=min_plan, current_plan=sub.plan)
    # ...

What matters is what this fix doesn't change. Plan rank and feature flags still apply, so a company that never had a plan with export to the accounting system won't get it just because its subscription expired. Expiry only stops taking away what the customer already had.

The web app and the driver app react differently

The backend is the source of truth, but users shouldn't learn about the lock from an error after clicking "Save". Both clients react to it, each in its own way.

The web panel keeps a copy of the allowed-paths list and blocks writes in the browser, while the driver app has no copy of the list and reacts only to the 402 write_access response from the backend

The web panel knows in advance that the subscription has expired, because it reads its status from the API. It shows a persistent banner saying the data is available for browsing and export, and the owner gets a button to renew the subscription. The "Add", "New" and "Invite" buttons on lists are disabled with a tooltip, instead of leading to a form that can't be saved. The axios interceptor has a copy of the exception list from the backend and doesn't send a write the backend would reject at all. It returns a 402 of the same shape, so the rest of the app handles it the same way as a server response.

The driver app works offline and has its own queues: order statuses, odometer photos, expenses, messages. There is no copy of the list here. The first 402 response from write_access freezes sending for all queues. Entries stay on the phone with the "to send" status, and the retry counter is rolled back, because a subscription lock is not a failed attempt. If it weren't rolled back, after a few attempts an entry would land in failed items and the driver would have to retry it manually.

// API client interceptor: a 402 on a write freezes the queues,
// any other response to a write (the first one after access returns) unfreezes them.
export const observeWriteOutcome = (method: string | undefined, error?: unknown): void => {
  if (!isWriteMethod(method)) return;
  if (error !== undefined && isWriteBlockedError(error)) {
    freezeWrites(blockReasonOf(error));
    return;
  }
  if (error === undefined || httpStatusOf(error) !== undefined) unfreezeWrites();
};

With frozen queues, a problem appeared that only surfaced in code. Unfreezing was supposed to happen after the first successful write response, but while locked the queues send nothing, so there will never be such a response. That's why every five minutes the app reads the company profile (reading works) and unfreezes the queues itself once the subscription is valid again. Then everything the driver saved during the lock goes out to the server without any action on their part.

What it costs

The main cost is the exception list, which lives in two places: in the middleware and in the web panel's interceptor. The copy in the web app gives better UX, because buttons are disabled before the user clicks anything. But a copy can drift, and it already has. The same October 7 fix added /api/v1/reports/ in the backend, and the web app doesn't have that prefix yet. So in read-only mode the panel blocks reports on its own side that the backend already lets through. The fix is simple, but it shows that the path-resolution test guards only the backend.

The second cost is that actions are only partially disabled in the web app. Add buttons are inactive, but editing, deleting and approvals are still clickable and are blocked only on save. I have this in the backlog as a separate task.

The driver app shows the other way: zero copies, only a reaction to 402. It's easier to maintain, but the user learns about the lock only on the first write attempt. I don't yet have an answer on which way is better for the web panel. The most sensible option seems to be for the backend itself to expose the exception list in the subscription status response, with the frontend just consuming it. That leaves a single source of truth.

If you're building a SaaS where customers store documents with an archiving obligation, start by asking what the customer must be able to do once they stop paying. For me, the answer turned a paywall into a write gate. I'm happy to talk about similar decisions in your project, get in touch.

Łukasz Głowacz

About the author

Łukasz Głowacz

Full-Stack Engineer · MOVGRANTO

Full-Stack Engineer at MOVGRANTO. Builds systems from architecture to production: backend, web, mobile, payments and CI/CD. Author of BusiKM, a SaaS for managing van fleets.

LinkedInGitHub

BusiKM

Fleet management for 2.5-3.5 t vans: orders, live fleet map, driver working time and costs in one place. 14 days free.

Try BusiKM ↗

Building a similar system?

We join teams as full-stack engineers or build products from scratch. Let's talk about your project.

Book a call →

Newsletter

One real architecture decision from building a production SaaS, every week.