PeopleOps: Engineering Case Study
A multi-tenant HR management platform built around server-side authentication, a policy-based permission model (PBAC), audited actions and reliable data operations.
Live application: peopleops.farvees.xyz Source code: github.com/mrfarvees-official/peopleops
Overview
PeopleOps brings employee records and HR workflows (hiring, leave, attendance, payroll) into one structured system where every action is permission-checked, logged and audited. It is also an engineering exercise in keeping authentication, authorization, business rules and database access in separate, testable layers.
| Item | Details |
|---|---|
| Role | Solo developer, backend-focused full-stack project: architecture, data model, auth, PBAC engine, HR framework, backups, tests, deployment |
| Timeline | Oct 2026 to present |
| Frontend | Next.js 16 App Router, React 19, TypeScript, Tailwind CSS 4 |
| Backend | Next.js route handlers and server actions over a layered application core |
| Data access | Prisma 7 with the MariaDB driver adapter, 24 models across 7 schema files, 4 migrations |
| Database | MySQL 8 |
| Validation and ops | Zod, pino structured logging, Prometheus metrics, /api/health |
| Quality | Vitest (159 tests in 13 files), strict TypeScript, dependency-cruiser architecture rules |
| Deployment | Docker Compose stack or PM2 with Nginx and Certbot, HTTPS reverse proxy, database never exposed |
| Live demo | peopleops.farvees.xyz |
The problem
HR systems hold data that must not be visible to everyone in the same way. A salary, a national ID or a pending leave request needs clear access boundaries, predictable workflows and an answer to "who changed this, and when?".
The engineering challenge was not drawing screens. It was organizing the code so that authentication, authorization, business rules and persistence stay understandable as the product grows, and so that a new HR module does not need new security code.
Project goals
- Organize the code into layers with enforced dependency rules.
- Resolve the current user and session on the server.
- Model permissions as data (policies) instead of hidden buttons or
if (role === ...)checks. - Isolate every company's data in a multi-tenant system.
- Record every sensitive action in an audit log.
- Keep business rules testable without a database or a browser.
- Make setup, backup, restore and deployment repeatable.
Product walkthrough
Dashboard

The dashboard summarizes the demo company's headcount, leave, attendance and payroll activity. Demo data is generated by a deterministic seed, so no real personal data appears.
Employee management

Employee records with search, filters, workflow actions and CSV or JSON export and import. Salary and national ID are sensitive fields, shown only to the record's owner and to people allowed to update it.
Permissions and access control

The policy editor. Each policy has an effect (allow or deny), subjects (any, a role or a user), targets (action and resource) and conditions that compare attributes such as the record's owner and status.
What the platform includes
- HR modules: org units, employees, leave types, leave requests, attendance, candidates, payroll runs, payslips and reports.
- Accounts and roles: invite, activate and suspend users, assign roles, tenant-scoped.
- PBAC administration: create and edit policies, browse the action and resource catalogue, and test whether a user could perform an action.
- Audit log: a read-only history of who did what, with sensitive values masked.
- Settings: runtime-configurable platform behavior, guarded by PBAC.
- Backup, restore, import and export: generic and table-driven, with dry-run previews.
Architecture and code structure
PeopleOps is a layered application. The UI and API routes call a small server/ composition layer, which wires application services to infrastructure. Business logic lives in the application and domain layers and never imports Prisma or Next.js.
System architecture
Code structure
Key design decisions
- Enforced layering:
dependency-cruiserrules fail the check if domain code imports anything outside the domain, if application code imports infrastructure, if pages import backend code, if Prisma is used outside infrastructure andserver/, or if there is a circular dependency.npm run archcurrently passes with no violations across 165 modules. - Server-side sessions: the browser holds a random token in an
httpOnly,sameSite=laxcookie (securein production). Only a SHA-256 hash of the token is stored in the database. - Policy-based authorization: access rules are data in the database, evaluated by a small pure function, not scattered through route handlers.
- One pipeline for every action: PBAC check, then the action, then an audit entry and a structured log.
- Generic HR framework: a module is one configuration object. List, view, create, update, soft delete, restore and workflow steps come from shared code, so every module gets the same security behavior.
- Modular Prisma schema: separate files for identity, PBAC, HR, audit, backup and settings.
Authentication and authorization
Authentication answers "who is making this request?". Authorization answers "may this person perform this action on this record?". Keeping them separate means a hidden button is never treated as a security boundary.
Authentication
- Passwords are hashed with salted
scryptthrough a replaceable hasher interface. - Login with an unknown email still performs a password verification against a dummy hash, so response timing does not reveal which emails exist.
- Unknown user, wrong password and suspended account all return the same generic error. The real reason is written to the audit log.
- A user can sign in only when the account is
activeand the company (tenant) is active. - State-changing cookie-authenticated requests are checked for a same-origin
Origin, on top ofsameSite=lax.
The authorization flow
For every protected operation:
- Resolve the current session on the server.
- Reject the request if there is no valid identity.
- Evaluate the policies for the action, resource and record attributes.
- Validate input and business rules.
- Perform the data operation, scoped to the caller's tenant.
- Write an audit entry and a structured log with the outcome.
Permission-based access control
The evaluator (platform/domain/pbac.ts) is deliberately small:
- Default deny. With no matching policy the answer is no.
- Any matching deny wins over any number of allows.
- Fail closed. If a condition refers to an attribute that is missing, the condition is false.
- Conditions can compare an attribute to a literal or to another attribute, for example
resource.ownerIdequalssubject.id. - Operators:
eq,neq,in,not_in,gt,gte,lt,lte,contains,exists. - Every denial is audited. A
system_developerrole bypasses policies, and each bypass is itself audited aspbac.bypass.
The seeded policies include global denies that always win:
| Policy | Rule |
|---|---|
deny-self-approval | Nobody approves or rejects their own leave, attendance or payroll request |
deny-audit-log-mutation | The audit log is read-only |
deny-delete-active-employee | An employee can be deleted only after being terminated |
deny-change-closed-payroll | A locked or published payroll run cannot be edited or deleted |
deny-manual-payslip-change | Payslips come only from payroll runs |
deny-cross-tenant | Tenant-bound roles cannot touch another company's records |
A technical challenge: keeping business rules enforceable
Challenge
In an HR system the dangerous mistakes are rule violations, not crashes: a manager approving their own leave, an HR admin editing a payroll run that was already published, or one company's staff reaching another company's data. Hiding the button does not stop any of these, because a request can be sent directly to the API. And with nine HR resources and several roles, it is easy to change one policy and silently break another.
Approach
- Rules live on the server, as policies. Each rule above is a deny policy with conditions on the record's attributes, evaluated before any data is read or written. A person who could never create a record is refused before their input is even parsed.
- Records expose their attributes to the policy engine. Each module declares which fields supply
ownerId,managerIdandstatus, so the same engine decides every module. - Lists are narrowed in SQL, not just checked. Records carry owner and manager copies of the login, so people without unrestricted
viewsee only their own records and their reports' records, and totals stay exact when paging. - The UI follows the server. Records are returned with
_canflags (update, delete, restore, available workflow steps) computed from a single policy load, so buttons reflect what the server will actually allow. - Sensitive fields are masked in the UI for non-owners and in audit entries.
Verification
The policy-matrix test runs the real seeded policies through the real evaluator and pins down what every role may and may not do on every HR resource. It covers cases such as:
- An employee can edit their own leave only while it is open.
- Nobody, including an HR admin, can approve their own request, but the same admin can approve someone else's.
- Every HR resource can be viewed and listed by some company role, not only the super admin.
- A tenant-bound role is denied on a record from another company.
Changing a policy without updating the matrix fails the test run. The PBAC evaluator tests also cover default deny, deny precedence and the fail-closed behavior for missing attributes.
Test and verification workflow
There is no CI pipeline yet. These checks are run locally and, for the build, during deployment.
Data integrity and database operations
- Schema and migrations: the schema is split into seven Prisma files and applied with
prisma migrate deploy. The Docker stack runs migrations and the seed in a one-shot container before the app starts, anddeploy.shruns migrations before each build. - Validation: inputs are parsed with Zod at the boundary. HR modules declare field types, required flags, options and maximum lengths once, and the framework enforces them.
- Multi-tenancy: every query is scoped to the caller's tenant, and the authorizer throws if a
tenantIdis missing. - Soft delete and restore are built into the HR framework.
- Audit: every write has an audit entry. Sensitive fields such as salary and national ID are masked. Successful reads are recorded only when the
audit.log_viewssetting is on, because each one would otherwise be a database write per page load. Denials and all changes are always recorded. - Transactions: imports and restores run in a single database transaction. A dry run rolls it back, so the database itself checks types, unique keys and foreign keys. Any row error means nothing is written.
- Tenant safety on import: rows must belong to the caller's tenant, and an existing row from another tenant is never overwritten.
Import, export, backup and restore
These features are table-driven rather than written per feature. A table is registered once in a catalogue with its PBAC resource, tenant scope and hidden columns, and a test fails if a database table is neither registered nor explicitly excluded.
- Export and import: CSV or JSON per table. Import is a two-step flow in the browser: a dry run shows how many rows would be added or updated, or the exact row errors, and nothing is saved until the user confirms.
- Backups: gzip JSON bundles with a SHA-256 checksum verified on every read.
- Restore: supports a preview mode, takes a
pre_restoresafety backup before applying, and puts back rows that are missing or changed. - Limits (v1): imports up to 10 MB and 20,000 rows, exports up to 200,000 rows per table.
Testing and verification

Terminal output of the Vitest run.
| Check | Result |
|---|---|
| Unit and rule tests | 13 files, 159 tests passing |
| Authorization tests | PBAC evaluator (11), seeded policy matrix (40) covering allowed and denied operations per role and resource |
| Business-rule tests | HR domain (9), HR module framework (14), including workflow steps and ownership |
| Authentication tests | Login, uniform errors, suspended accounts, password hashing |
| Data operations | Import, export and backup logic (24), table catalogue completeness (12), audit (11), settings (15), accounts (17) |
| Architecture | npm run arch: no dependency violations across 165 modules and 336 dependencies |
| Type checking | npm run typecheck: no errors |
| Production build | Built as part of the Docker image and by deploy.sh |
Reproduce the checks
npm ci
npm run test
npm run arch
npm run build # also generates the Next.js route types
npm run typecheck
Deployment

Deployment setup for the live application.
The repository supports two repeatable deployment paths behind an HTTPS reverse proxy:
- Docker Compose: a MySQL 8 container (memory-capped and published only on localhost), a one-shot migrate-and-seed container, and the Next.js app built as a multi-stage image that runs as a non-root user. The app listens on loopback only and has an HTTP healthcheck against
/api/health. Backups live on a named volume. - PM2 and Nginx:
deploy.shpulls the branch, installs dependencies, applies migrations, builds, restarts the process under PM2 and requests a Let's Encrypt certificate with Certbot on first run.
Deployment checklist
- HTTPS is terminated at the proxy, HTTP redirects to HTTPS, and HSTS is enabled.
- Secrets such as database credentials and seed passwords come from environment configuration, not from Git.
- The database is never published publicly. It is bound to localhost or a private network.
- Migrations run in a controlled step (
prisma migrate deploy) before the app starts. - The proxy limits request bodies to 12 MB, just above the 10 MB import limit.
- Health (
/api/health) and Prometheus metrics (/api/metrics, guarded) are available for monitoring.
Engineering decisions and trade-offs
| Decision | Why it matters | Trade-off |
|---|---|---|
| Policies stored as data | Rules can be changed without redeploying, and the same engine serves every module | More moving parts than a hard-coded role check, so a policy matrix test is needed to keep it safe |
| Default deny, deny wins | A mistake tends to block a user rather than leak data | Missing a needed allow policy shows up as "Forbidden" and has to be debugged |
| Pure domain layer with enforced dependency rules | Rules can be unit tested without a database or browser | More files and indirection than calling Prisma from a route |
| One generic HR framework | Every module gets the same security, audit and workflow behavior | A module that does not fit the shape needs a hook or a framework change |
| Table-driven import, export and backup | New tables get these features by registering once | Backups are synchronous and built in memory, so there are size limits |
| Owner and manager copies on records | Lists can be restricted in SQL with exact paging | Copies must be re-synced when a manager or login changes |
| Server-side sessions with hashed tokens | A stolen database does not give working session tokens, and sessions can be revoked | A database read per authenticated request, mitigated by throttled session touch updates |
Alternatives I considered were hard-coded role checks (simpler, but every new module would repeat the rules) and signed stateless tokens (no session table, but harder to revoke and audit). Policy data and server sessions fit a product where "who can do what" changes and every action must be accountable.
Results
- Architecture: enforced layers, with 165 modules passing the dependency rules.
- Security behavior: default-deny PBAC with six global deny policies, tenant isolation, uniform login errors and masked sensitive data, all covered by tests.
- Reliability: 159 passing tests, transactional import and restore with dry-run previews, checksummed backups with a safety backup before restore.
- Delivery: a live application with seeded demo data (a demo company with 38 employees, candidate history, two years of leave, 90 days of attendance and 24 payroll runs) and reproducible deployment scripts.
Current limitations
- The automated tests cover the domain and application layers with in-memory fakes. The Prisma repositories and the database queries are not covered by automated integration tests.
- There is no CI pipeline yet, so the checks run locally.
- Backups are built in memory, and restore does not delete rows created after the backup was taken.
- Backup files include secret columns such as password hashes, so they must be treated as sensitive.
- Rate limiting and alerting on repeated failed logins are not implemented.
- Imports are limited to 10 MB and 20,000 rows.
Next steps
- Add database integration tests for the Prisma repositories, especially tenant scoping and the SQL-level row restrictions.
- Add CI to run tests, the architecture check, the build and the type check on every push.
- Add login rate limiting and alerting on repeated denials from the audit log.
- Move backups to streaming and add scheduled backups with a tested restore into a clean environment.
- Add end-to-end browser tests for the key workflows: leave request and approval, payroll run, import with dry run.
Links
- Live application: peopleops.farvees.xyz
- Source code: mrfarvees-official/peopleops
- Portfolio: farvees.xyz
- GitHub: mrfarvees-official
PeopleOps is an evolving engineering project. This case study describes the current repository and deployed application; planned work is listed separately under Next steps.
