Skip to main content

01 / WORK / CASE STUDY

Multi-tenant SaaS HRIS

A multi-tenant HR platform — attendance, scheduling, payroll, approvals, recruiting — across 36 modules; currently in acceptance and remediation.

TYPE
FULL STACK
SCOPE
Internal · acceptance stage
STACK
DJANGO · DRF · MYSQL · REDIS · CELERY · REACT
LINKS
Internal project (private)

01 / PROBLEM

HR in small and mid-sized companies is scattered across spreadsheets, paper approvals and disconnected tools — hard to audit, easy to get wrong, and re-implemented at every company. This system is a multi-tenant SaaS: one deployment serves multiple organizations with strict data isolation, subscription-gated modules, and built-in Taiwan labor-law compliance checks.

02 / CONSTRAINTS

  • Company-internal; the codebase is private. Currently in acceptance with synthetic seed data — not yet in production.
  • Tenant isolation is non-negotiable — a single query missing its tenant filter is a data breach.
  • Must implement Taiwan labor law (working-hour caps, overtime multipliers, withholding) and support three languages.

03 / ARCHITECTURE

A modular monolith: Django 4.2+ and DRF split into 36 domain apps (attendance, scheduling, payroll, approvals, recruiting, assets, labor insurance…), with 241 models and 188 registered resource routes — 728 concrete endpoints exercised during acceptance. Deliberately not microservices: payroll and approval flows are far simpler inside one transaction boundary.

Multi-tenancy is shared-schema with row-level isolation: middleware resolves the tenant and 186 of the 241 models carry a tenant column by inheriting the tenant-aware base. Async work runs on Celery + Beat — shift generation, attendance archiving, holiday/regulation sync; Redis backs cache and channels; the database switches between PostgreSQL and MySQL per environment. CI runs on GitHub Actions with pre-commit, plus a Prometheus / Grafana / Loki observability stack.

04 / RESPONSIBILITIES

Independent full-stack development (all 434 backend and 123 frontend commits are mine, across two git identities): data models and multi-tenancy, the permission system, domain modules, Celery jobs, the React frontend and CI.

05 / CHALLENGES → SOLUTIONS

CHALLENGE

A security audit found custom API actions bypassing tenant filtering — accounts could be manipulated across tenants.

SOLUTION

Isolation was pushed into the base layer (TenantAwareModel plus middleware-resolved context) and every queryset-bypassing action patched. The audit confirmed 35 findings (2 critical); 30 are fixed and 5 partially — pending staging re-verification, recorded as-is.

CHALLENGE

The payroll API's cache key ignored user identity — within one TTL window, an employee could hit an admin's cached response and see company-wide salaries.

SOLUTION

Added vary_by_user to the cache decorator, folding the user into the key — then wrote a regression test with real HTTP calls proving two users can never see each other’s cache.

CHALLENGE

Taiwan's public holidays and regulatory parameters change yearly — manual syncing doesn't scale.

SOLUTION

Scheduled it with Celery Beat: yearly next-year holiday sync, daily regulation-expiry checks, weekly upcoming-holiday notices — tasks retry automatically with exponential backoff.

06 / SYSTEM FACTS

36

domain modules

241

data models

728

endpoints tested

87.5%

acceptance pass rate

07 / LESSONS

Cache design is authorization design: the payroll leak came from a cache key missing the identity dimension. In a multi-tenant system that class of bug is not a performance issue — it is a breach.

Systemic vulnerabilities trace back to model design: dual global-vs-tenant role tracks caused the privilege and cross-tenant issues. Doing it again, I would design permissions on the tenant dimension from day one.

08 / SCREENS

All screens below show demo (mock) data — no real user data.

Payroll management: year, month and status filters, headcount and total-payroll KPIs, and a table of base pay, additions, deductions, net pay and calculated/paid status
Payroll management — trial calculation, batch creation and export on one screen. Every amount shown is an encrypted column underneath: all 18 money fields on payroll_records use EncryptedDecimalField. Synthetic seed data.
The DRF browsable API for the same payroll module: eleven custom action links, OWASP security notes rendered from the viewset docstring, and a JSON response in the project’s {data, pagination} envelope
The same payroll module seen from the API side. The two OWASP A01/A02 notes on the page render straight out of the viewset docstring — tenant isolation and sensitive-field handling are stated in the code itself, not in a separate document.
Recruitment dashboard: KPI cards for open roles, candidates to review, interviews this week and pending offers; a hiring funnel labelled with stage-to-stage conversion; and per-opening progress
Recruitment dashboard — the funnel labels conversion at each stage, with per-opening hiring progress alongside. The numbers come from seed data (three openings, five candidates), not from production.
Scheduling calendar in month view: month/week/day/list view switcher, a colour legend for morning, afternoon, night and full-day shifts, and auto-schedule and add-shift actions
Scheduling in month view, shifts colour-coded by type. The auto-schedule button maps to the backend’s scheduling.generate_monthly_schedules — the same task also sits on Celery Beat, generating next month’s roster on the 25th.
The scheduling screen at 390px: a day-by-day agenda layout with a long-press drag hint, a bottom tab bar and a floating add button
The same scheduling module at 390px. Not a shrunken month grid — a dedicated agenda layout, with navigation moving from the sidebar to a bottom tab bar.
Swagger UI generated by drf-spectacular with the v1 group expanded, listing GET/POST/PUT/PATCH/DELETE endpoints under tenants
The OpenAPI 3.0 document drf-spectacular generates from 188 router registrations. The screenshot is only the opening slice: the full schema has 1,222 paths and 1,919 operations, and renders about 219,000px tall.

09 / ARCHITECTURE DIAGRAMS

Every diagram below is derived from the project's actual source; each node traces back to a real file. They are wide — scroll sideways, or click to open the full-size SVG.

Four-stage tenant isolation: middleware resolves the tenant, the view layer filters via TenantFilterMixin, the ORM deliberately applies no implicit scope, and four classes of historical bypass are shown fixed
The point of this figure is not that isolation exists, but which layer it lives in. Middleware resolves the tenant — header, then subdomain, then the user’s primary tenant — and TenantFilterMixin filters at the view layer. The ORM deliberately carries no implicit scope: TenantAwareManager filters only is_deleted, and a test in the repo exists to assert exactly that. The cost is drawn along the bottom: 155 of 200 viewsets inherit the mixin, the other 45 still sit on raw DRF bases and are scoped by hand — and every one of the four fixed historical leaks happened above the ORM, in view or cache code.
Runtime architecture: gunicorn and ASGI entrypoints, the Django request pipeline, 36 apps and the async tier, alongside PostgreSQL/Redis, LDAP/Keycloak and the observability stack
The runtime as a whole: HTTP through gunicorn, WebSockets through ASGI, both converging on one middleware pipeline into 36 apps, 241 models (186 of them tenant-scoped) and 188 routes. A single Redis instance is simultaneously cache, session store, Celery broker and channel layer. Bottom right, ELK is marked NOT WIRED — docker-compose defines the three services, but no handler in LOGGING feeds them, so it is drawn dashed rather than quietly omitted.
Sequence diagram of leave submission and approval: the transaction boundary, SELECT … FOR UPDATE on the balance row, the audit log written through Celery, and the WebSocket push
The full round trip of a leave request, from submission to approval. The part worth reading is the balance ledger: submission locks the balance row (SELECT … FOR UPDATE) and adds to pending_days; approval converts pending into used under that same lock, and cancellation reverses it. Audit entries go to Celery’s audit_logs queue by default — but a failed dispatch falls back to a synchronous write rather than disappearing.
Celery topology: 37 beat entries, the task_routes rules, seven declared queues and the worker, plus the nine schedule entries naming unregistered tasks
What the async tier actually looks like: 37 beat entries, prefix-based task_routes, seven priority-tagged queues. The value of the figure is the red band — nine of those 37 entries point at a task that is not registered: the name does not match the @shared_task registration, the module was never created (apps/assets/tasks.py, apps/insurance/tasks.py), or the function is defined nowhere. Separately, the emails queue has no producer, and the crawlers queue is referenced by a schedule but never declared in task_queues. Known and unfixed, drawn as-is.
ERD of 18 core tables — tenants, memberships, employees, org units, leave, attendance, payroll, workflow and the audit log — with real table and column names throughout
Eighteen core tables, with real db_table and column names throughout. Two decisions of the multi-tenant design are visible in it: accounts_user carries no tenant_id — membership lives entirely in the tenants_tenant_user table, so one account can belong to several tenants — and accounts_user.employee_id is globally unique rather than unique per tenant. Together they explain why isolation has to be written at the query layer instead of being caught by a table constraint.

NEXT

AI Nail Platform