← all posts

Building an Arabic-first, multi-tenant legal platform

How Nibras is put together: one .NET image in two roles, a request pipeline where order is security, firm isolation in the data layer, authorization that fails closed, background work that never charges twice, and an AI assistant that has to ask first.

I lead engineering at Nibras Legal Tech. We build a practice-management platform for law firms in Jordan: cases, court sessions, clients, documents, billing, and an AI legal assistant called Lexi. Many firms share one system, the interface is Arabic first, and the data is about as sensitive as data gets.

Most of the codebase was built and changed by one person, me, and that shaped every decision below. With no team to catch a forgotten check in review, the system has to catch it. So the theme is simple: make the safe path the default, and make the unsafe path fail loudly, preferably before the code runs.

The platform is private, so the snippets are simplified illustrations, not the production source. The numbers are counted from the codebase:

What Count
Database tables 186
API endpoints 715
Automated tests 2,634
Lines of application code about 206,000

In short:

One image, two roles

The system is deliberately small in moving parts. There is one PostgreSQL 17 database (with pgvector for the AI index, and the background job queue in its own schema), one S3-compatible object store, and one ASP.NET Core application. The React 18 single-page app is built into the same Docker image and served by the API itself, so there is no separate frontend deployment to keep in step.

That one image runs as two containers. An environment flag gives each a role: the web role serves the API, the realtime hubs and the SPA, and does not start the job server. The worker role runs the background jobs, OCR and PDF rendering under its own CPU cap. A slow document conversion never competes with a lawyer opening a case.

BrowserReact SPA, RTLReverse proxyTLS, rate limitsWeb roleAPI, hubs, SPA filesWorker rolejobs, OCR, PDFsone image,two rolesPostgreSQL 17data, vectors, job queueObject storeprivate by defaultModel provideranswers, OCR, embeddings
fig. 1 / one image, run as a web role and a worker role, in front of one database and one private object store.

Inside the API, code is grouped by domain (access, documents, finance, matters, Lexi, OCR, plans and so on) rather than by technical layer. Each domain folder holds its controllers, services, jobs and DTOs together, and the React app mirrors the same names. It is a modular monolith in the plain sense: one deployable, with module boundaries that are checked in CI rather than enforced by a network. How those boundaries are kept honest is its own post: ratcheting architecture rules in a modular monolith.

One more shared piece ties the two stacks together: a single JSON route contract listing every screen, its path, and the permission key each action needs. The API derives its permission catalog from it, and the web app builds its router from it. A new screen is added once, and both sides pick it up.

Startup is strict too. In production the API refuses to boot if the database, the signing secret, the app URL, mail or storage settings are missing or weak. It never migrates, repairs or seeds the database on startup; schema changes only happen through explicit deploy commands.

Arabic first, enforced by tooling

Most software adds Arabic at the end, and it breaks in a hundred small places. In Nibras, right-to-left is the default and English, French and Spanish are the additional locales. Layouts use logical spacing and alignment so they work in both directions, and pull requests are checked in Arabic and English, RTL and LTR.

The rules that matter are enforced by tools rather than by memory:

Arabic search, where one name has several spellings, is covered in the RAG post linked below.

The pipeline is the security model

In ASP.NET Core, middleware order is easy to treat as plumbing. Here it is a list of decisions, and each position has a reason.

Security headersCSP, HSTS, nosniffError handlerproblem+jsonForwarded headerstrusted proxiesRouting, CORSknown originsAuthenticationtoken + versionSupport contextstaff in a firmFirm contextfrom the token onlyWorking hoursper-person windowDenial detailsnames missing keyAuthorizationone resolverMedia guardprivate paths 404Rate limiterper user or IPEndpointsat startup: one endpoint without an access rule stops the process
fig. 2 / the request pipeline, in order. read left to right, then back, then on.

A few of those reasons:

Isolation by construction

When many firms share one database, “remember to filter by firm” is not a security model. Somebody forgets once, and one firm sees another firm’s cases. The firm boundary lives in the data layer, in one EF Core context, where no feature can skip it:

HTTP requestfirm from the sessionBackground jobdeclares firm or platformRealtime messagehub scoped by tokenAI retrievalcaller's firm and casesFirm scopestamped on every savefiltered on every queryPostgreSQLone database, many firmscrossing firms: named escape hatch only, easy to spot
fig. 3 / every path into the data goes through the same firm scope. crossing it is explicit.

The same idea runs through the people model. A person, a login, a firm membership and a firm’s private notes about that person are different things. Platform staff live in a separate realm from firm users, an email can exist in only one realm, and realtime user ids carry a realm prefix so a staff id can never receive a firm user’s push.

Authorization that fails closed

With 715 endpoints, “we’ll add the permission check later” is how you ship an open door. I designed the authorization layer around three rules.

Every endpoint declares its audience, or the API does not run. Each endpoint carries one kind of gate: a permission key, any of a set of keys, an explicit signed-in-only mark, platform staff only, or anonymous. The check runs at startup in every environment and again in the test suite:

// Simplified: refuse to start if any endpoint has no explicit access decision.
var unguarded = app.Services.GetRequiredService<EndpointDataSource>().Endpoints
    .OfType<RouteEndpoint>()
    .Where(e => !HasExplicitAccessDecision(e.Metadata)) // a bare [Authorize] does not count
    .Select(e => e.RoutePattern.RawText)
    .ToList();

if (unguarded.Count > 0)
    throw new InvalidOperationException(
        $"Endpoints without an access rule: {string.Join(", ", unguarded)}");

Who can reach what is pinned in golden files. Three committed text files list the audience of every endpoint, every Lexi tool, and the permissions a firm is allowed to hold. A change that widens or narrows access fails the test until the file is regenerated, and the diff is read line by line in review. A new + line is a person who can now do something. Every change to who can do what also needs a negative test, because an all-green suite does not notice that access got wider.

One resolver computes every permission. Enforcement on the server, the capabilities endpoint the UI reads, and the permission preview on the user-edit screen all call the same code. If display and enforcement were computed separately, a screen would eventually show a button the API refuses, or worse, the reverse.

∪∩−=Section pages × role operationsthe live assignment, nothing elsePersonal allow exceptionseach with a reason, capped by the grantorPlan ceilingan unconfigured plan gets almost nothingExplicit deniesbeat every grant, even the wildcardEffective permissionsenforcement and UI read the same set
fig. 4 / one resolver for every permission check. denies are subtracted last and beat everything.

A firm is organized into departments and sections. Each colleague gets one department, one section and one role, where a role is only a name and a set of operations: add, edit, delete, cancel, print, finalize, export. The section decides which screens a person sees, and the role decides what they can do on them. Then:

Writing a person’s exceptions is a full replace inside a serializable transaction. It first claims the person’s authorization version with a conditional update, so two admins editing the same person at once cannot silently overwrite each other. The loser gets a 409.

Sessions you can actually revoke

Access tokens live 10 minutes by default, and startup refuses any setting outside 5 to 15. The refresh token is a random value in an HttpOnly cookie that lasts 7 days, and the server stores only its SHA-256 hash, one row per device. A database leak does not hand out working sessions.

Every refresh spends the current row and inserts its successor in a single conditional update. If two requests present the same token at the same moment, exactly one wins and the other is treated as a replay. A replayed token means someone has a copy they should not have, so the whole family of tokens for that device is revoked. The account’s other devices stay signed in.

Sign inrotateRefresh 1usedrotateRefresh 2usedrotateRefresh 3currentCopy of Refresh 1replayed by someone elseWhole family revokedrefresh 1, 2 and 3 stop working; sign in again
fig. 5 / refresh tokens rotate on every use. replaying an old one revokes that device's whole family.

Each person also carries an authorization version, and every token is stamped with it. Any change to what someone may do bumps the counter, and the very next request with an old token gets a 401. The browser refreshes, receives a token with the new version and the new permissions, and carries on. Removing an employee’s access takes effect now, not whenever their token happens to expire. A password change, a reset, deactivation, or deactivating the whole firm goes further and revokes every device.

The browser side needed as much care. All tabs share one rotating cookie, so if two tabs refreshed at once, the second would look exactly like a replay. Tabs take turns through the browser’s Web Locks API, and concurrent requests inside a tab share a single refresh. When the signed-in identity changes, the client-side query cache is cleared before the new user renders, so one account’s data never flashes up for another. Sign-in answers the same way whether or not an account exists, and platform staff need a password plus an emailed code.

Background work that never charges twice

Job delivery is at-least-once, so every job has to be safe to run twice. Recurring jobs (hearing reminders 24 hours and 1 hour before a session, credit refills, period-end plan changes, retention cleanup) are registered by a deploy command rather than at startup, and each one is repeat-safe through a marker row, a cursor saved in the same transaction as its notification, or a stable reference id.

Money and credits follow one rule: every debit and every refund carries a reference, and the ledger has a unique index on it. A retry finds the original transaction instead of creating a second one, and a database check constraint makes a negative balance impossible.

OCR is the clearest example, and it became the template for any paid, slow, retryable work. A scanned PDF from a firm user goes through this:

any failureCheck plan, count pagesreserve only what this file needsStore the source privatelyper-firm prefix, never a public URLInsert job, debit creditsdebit keyed to the job idEnqueue, answer 202the user is not kept waitingClaim: Queued to Processingone conditional update; 0 rows means skipExtract textthe model provider, page by pageSettle on the real page counttop up or refund the differenceMark failed, refund allrefund keyed to the jobStale-job reaperstuck 30 min: fail and refund
fig. 6 / the pattern for paid background work: reserve, claim, settle, and refund on every failure path.

The claim is the important line. A plain read-then-write would let two workers process the same job. A single conditional update cannot:

// Simplified: claim a queued job atomically. A cancelled job can never reach the provider.
var claimed = await db.OcrJobs
    .Where(j => j.Id == jobId && j.Status == JobStatus.Queued)
    .ExecuteUpdateAsync(s => s.SetProperty(j => j.Status, JobStatus.Processing));

if (claimed == 0) return; // someone else has it, or it was cancelled

Page counts read from a PDF’s structure are not always right, which is why the job settles on the provider’s real count afterwards. If a restart leaves a job stuck in processing for 30 minutes, a recurring job fails it and refunds it, and because the refund is keyed to the job, running the reaper twice adds nothing.

Storage follows the same instinct. It is private by default: one list of managed prefixes decides what is public (logos, profile pictures, official gazettes), everything else goes through authorized endpoints, and uploads are checked by magic bytes rather than the declared content type.

Platform staff maintain a national library of Jordanian law: official gazette issues, the legislation published in them, the articles inside each law as a tree of any depth, court decisions, and the amend and repeal relationships between texts. It is global reference data, owned by no firm, and it is Lexi’s verified source of law. Retrieval over it gets its own post: Arabic legal RAG on PostgreSQL and pgvector.

Lexi can read (search the law, summarize the open case, read saved facts) and it can propose changes (create a task, a deadline, a client, or remember a fact). The most important brake is that the model never changes data on its own.

Model proposestool + argumentsApproval tokenhashed, 15 minPerson confirmsnext chat turnRe-check accesslive stateClaim oncethen execute
fig. 7 / the assistant proposes. a person approves. permissions are checked again at the moment it runs.

The approval is not a “yes” button floating free of context. It is a one-time token bound to that exact run, step, tool and set of arguments:

token   = 32 random bytes, shown once
stored  = sha256(token), run, step, tool, sha256(arguments),
          expires_at = now + 15 min
confirm = verify hashes and expiry -> re-check permission and case access
          -> claim the approval with one conditional update -> execute

If the arguments change after approval, the hash no longer matches and nothing runs. A double click, a retry or a second tab loses the claim. Platform staff have no firm, so mutating tools are not even offered to them. The rest of the guardrails follow the same thinking:

Reviewing a design: reuse beats parallel systems

Not all of the job is writing code. A recent example: a proposal arrived for a central archive, a single home for every file in the system, so that cases, sessions, Lexi conversations and the legal library all point at one stored file instead of keeping their own copies. The goal was right. My review was mostly about what already existed.

The platform already had a storage service, an archive record, an access resolver, an audit log, a background job system and an OCR pipeline. A parallel version of any of them would drift from the original within months. So the reviewed design added two tables instead of five, introduced no parallel services, and needed no dual-read migration or feature flag. A few of its decisions generalize well:

Shipping without crossing your fingers

The API exposes health at three depths: live (the process answers), ready (the database is reachable and no migration is pending), and deep (adds object storage and the job system). Missing AI configuration reports as degraded rather than unhealthy, because a lawyer can still work when the assistant is down.

Production deploys only from main, through a workflow a person starts by hand. It waits for CI to pass on that exact commit, runs one deploy at a time, and is never cancelled halfway.

not readyDeploy started by handonly after CI is green on that commitDatabase dumpchecksummed and verified readableMigrate, repair, register jobsStart; image digest must matchReadiness gateworker, app and public URLLiveSchema unchangedprevious image restartedSchema movedrestore or roll forward
fig. 8 / every production deploy: gated, backed up, health-checked, and reversible when it is safe.

The readiness gate is where the safety lives. If the new version does not come up, the workflow checks whether the schema moved. If it did not, the previous image comes back automatically. If it did, an automatic rollback would be unsafe, so a person decides between restoring the dump and rolling forward.

Development happens somewhere else entirely: a virtual machine on office hardware, reachable only over Tailscale. Developers work in per-ticket copies of the repository, from accounts with no internet access, against a sanitized development database. Dev data is never production data, and there is no route from the development box to production at all. I wrote up that setup in working with contractors without handing over the codebase.

Developersssh, browserTailscaleper-account ACLsDev VMoffice hardwareDev databasesanitized, not prodno routeProductionseparate serverkey-only SSH; developer accounts haveno internet and no direct database access
fig. 9 / developers reach the development host and nothing else. production has no route from it.

One lesson from CI: running every job on every change used 1,900 of our 2,000 monthly CI minutes in twelve days. Change detection now decides which jobs a commit needs, diffing against the last commit whose CI passed rather than the previous push, so a red backend commit followed by a frontend-only fix still reruns the backend.

What I would tell myself on day one

  1. Decide isolation and authorization before features. Retrofitting either one touches everything.
  2. Make the unsafe path fail loudly, and early. Refusing to start beats a forgotten check. A failing test beats a review comment.
  3. Keep one place for every hard rule. One resolver for permissions, one data context for firm scope, one pattern for paid work, one gate for deploys.
  4. Prefer one conditional update to a read and a write. Most of the concurrency bugs I did not have came from that habit.
  5. Reuse before you add. The most valuable review comment I wrote this year was “we already have this”.

If you are building something with the same constraints (many tenants, sensitive data, Arabic users, and AI that touches real records), I’m happy to compare notes. You can reach me through my site.