← WorkCase study

PeopleOps

PeopleOps is my first project: a multi-company HR platform for employee records, leave, attendance, payroll and recruitment. Policy-based permissions and audit logging run alongside the everyday workflows.

Type
HR platform
Started
Oct 2026
Role
Developer
PeopleOps landing page

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.

ItemDetails
RoleSolo developer, backend-focused full-stack project: architecture, data model, auth, PBAC engine, HR framework, backups, tests, deployment
TimelineOct 2026 to present
FrontendNext.js 16 App Router, React 19, TypeScript, Tailwind CSS 4
BackendNext.js route handlers and server actions over a layered application core
Data accessPrisma 7 with the MariaDB driver adapter, 24 models across 7 schema files, 4 migrations
DatabaseMySQL 8
Validation and opsZod, pino structured logging, Prometheus metrics, /api/health
QualityVitest (159 tests in 13 files), strict TypeScript, dependency-cruiser architecture rules
DeploymentDocker Compose stack or PM2 with Nginx and Certbot, HTTPS reverse proxy, database never exposed
Live demopeopleops.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

PeopleOps 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

PeopleOps 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

PeopleOps permissions interface

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-cruiser rules 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 and server/, or if there is a circular dependency. npm run arch currently passes with no violations across 165 modules.
  • Server-side sessions: the browser holds a random token in an httpOnly, sameSite=lax cookie (secure in 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 scrypt through 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 active and the company (tenant) is active.
  • State-changing cookie-authenticated requests are checked for a same-origin Origin, on top of sameSite=lax.

The authorization flow

For every protected operation:

  1. Resolve the current session on the server.
  2. Reject the request if there is no valid identity.
  3. Evaluate the policies for the action, resource and record attributes.
  4. Validate input and business rules.
  5. Perform the data operation, scoped to the caller's tenant.
  6. 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.ownerId equals subject.id.
  • Operators: eq, neq, in, not_in, gt, gte, lt, lte, contains, exists.
  • Every denial is audited. A system_developer role bypasses policies, and each bypass is itself audited as pbac.bypass.

The seeded policies include global denies that always win:

PolicyRule
deny-self-approvalNobody approves or rejects their own leave, attendance or payroll request
deny-audit-log-mutationThe audit log is read-only
deny-delete-active-employeeAn employee can be deleted only after being terminated
deny-change-closed-payrollA locked or published payroll run cannot be edited or deleted
deny-manual-payslip-changePayslips come only from payroll runs
deny-cross-tenantTenant-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, managerId and status, 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 view see only their own records and their reports' records, and totals stay exact when paging.
  • The UI follows the server. Records are returned with _can flags (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, and deploy.sh runs 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 tenantId is 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_views setting 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_restore safety 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

PeopleOps test output

Terminal output of the Vitest run.

CheckResult
Unit and rule tests13 files, 159 tests passing
Authorization testsPBAC evaluator (11), seeded policy matrix (40) covering allowed and denied operations per role and resource
Business-rule testsHR domain (9), HR module framework (14), including workflow steps and ownership
Authentication testsLogin, uniform errors, suspended accounts, password hashing
Data operationsImport, export and backup logic (24), table catalogue completeness (12), audit (11), settings (15), accounts (17)
Architecturenpm run arch: no dependency violations across 165 modules and 336 dependencies
Type checkingnpm run typecheck: no errors
Production buildBuilt 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

PeopleOps 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.sh pulls 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

DecisionWhy it mattersTrade-off
Policies stored as dataRules can be changed without redeploying, and the same engine serves every moduleMore moving parts than a hard-coded role check, so a policy matrix test is needed to keep it safe
Default deny, deny winsA mistake tends to block a user rather than leak dataMissing a needed allow policy shows up as "Forbidden" and has to be debugged
Pure domain layer with enforced dependency rulesRules can be unit tested without a database or browserMore files and indirection than calling Prisma from a route
One generic HR frameworkEvery module gets the same security, audit and workflow behaviorA module that does not fit the shape needs a hook or a framework change
Table-driven import, export and backupNew tables get these features by registering onceBackups are synchronous and built in memory, so there are size limits
Owner and manager copies on recordsLists can be restricted in SQL with exact pagingCopies must be re-synced when a manager or login changes
Server-side sessions with hashed tokensA stolen database does not give working session tokens, and sessions can be revokedA 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

  1. Add database integration tests for the Prisma repositories, especially tenant scoping and the SQL-level row restrictions.
  2. Add CI to run tests, the architecture check, the build and the type check on every push.
  3. Add login rate limiting and alerting on repeated denials from the audit log.
  4. Move backups to streaming and add scheduled backups with a tested restore into a clean environment.
  5. Add end-to-end browser tests for the key workflows: leave request and approval, payroll run, import with dry run.

PeopleOps is an evolving engineering project. This case study describes the current repository and deployed application; planned work is listed separately under Next steps.

Want something like this built?

Need an internal tool that brings business workflows and access rules together? Tell me what you are building.

Get in touch →
Next case study

Chatbot →

AI chatbot with browser actions