Back to BlogArchitecture & Engineering

One Platform, Many Agencies: Tenant Isolation for Property Portals

Rupak Amin

Founder & Lead Engineer, RAITHub

18 min read

A multi-agency real estate platform keeps agencies apart by making the agency the tenant: every listing, lead and document row carries an agency_id, PostgreSQL row-level security filters every query by it, co-broking is an explicit agreement row rather than a flag, and public search reads a separate projection. Prove it with a cross-agency IDOR suite of at least 12 cases that runs in CI.

If you would rather have it built for you, see how RAITHub would build this below.

This post is about the property-specific rules: agencies, branches, agents, shared and private listings, leads and co-broking. The generic mechanics of row-level security (roles, FORCE, SET LOCAL through a pool, and the pitfalls) are in the PostgreSQL row-level security guide, so they are not repeated here. The wider PropTech picture is in the PropTech software development guide.

Why is a multi-agency property platform harder to isolate than normal SaaS?

Because the tenants compete with each other, and the product still has to let some of their data cross the boundary on purpose. In ordinary B2B SaaS, tenant A never needs to see anything of tenant B. On a property portal, agency A's public listings must appear in everyone's search, a co-broked listing must be visible to a partner agency, and a buyer's inquiry must reach the listing agent. Meanwhile agency A's leads, vendor notes and commission terms are exactly what agency B would most like to see.

That makes three kinds of data, and each needs its own rule:

  • Private to the agency: leads, contacts, vendor and landlord details, internal notes, commission terms, documents.
  • Shared by agreement: co-broked listings, visible to named partner agencies, minus the private fields.
  • Public: listing details an agency has chosen to publish, readable by anyone through search.

The failure the industry keeps having is the plain one. OWASP ranks broken object level authorization first in its 2023 API Security Top 10: attackers manipulate "the ID of an object sent within the request" to reach data they should not see. On a portal, that is one agent changing a lead ID in a URL and reading a rival's buyer list.

How should you model agencies, branches and agents?

Make the agency the tenant, the branch a scope inside it, and the agent a user with a role. Every row that belongs to an agency carries agency_id, even when it also carries branch_id, so the tenant check never needs a join.

LevelWhat it isIsolation ruleTypical roles
PlatformYou, the operatorSupport access through a separate, logged role, never the app rolePlatform admin, support
Agency (tenant)A brokerage or franchise memberHard wall: database-enforced with RLSAgency admin
BranchAn office of that agencySoft wall inside the tenant, set by agency policyBranch manager
AgentA person working leads and listingsSees own leads; branch or agency leads only if the role allowsAgent, negotiator, assistant

A composite foreign key stops a whole class of bug: a branch_id that belongs to a different agency. If branches have a unique key on (agency_id, id), every child table can reference both columns, and the database rejects a listing that claims agency A but points at agency B's branch.

CREATE TABLE agencies (
  id   uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  name text NOT NULL
);

CREATE TABLE branches (
  id        uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agency_id uuid NOT NULL REFERENCES agencies (id),
  name      text NOT NULL,
  UNIQUE (agency_id, id)
);

CREATE TABLE listings (
  id         uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  agency_id  uuid NOT NULL,
  branch_id  uuid NOT NULL,
  agent_id   uuid NOT NULL,
  ref        text NOT NULL,
  visibility text NOT NULL CHECK (visibility IN ('private', 'co_broke', 'public')),
  status     text NOT NULL,
  price      numeric(12, 2),
  FOREIGN KEY (agency_id, branch_id) REFERENCES branches (agency_id, id),
  UNIQUE (agency_id, ref)
);

-- Fields a partner agency must never see live in their own table.
CREATE TABLE listing_private (
  listing_id   uuid PRIMARY KEY REFERENCES listings (id),
  agency_id    uuid NOT NULL,
  vendor_name  text,
  vendor_phone text,
  fee_terms    text,
  notes        text
);

Two choices in that schema carry most of the weight. The listing reference is unique per agency, not globally, so a duplicate check cannot reveal that a rival already has a property on its books. And the vendor's details sit in listing_private, because RLS filters rows, not columns: a partner agency that may see a co-broked listing must still never see who is selling it or what fee was agreed.

Should each agency get its own database instead? It is a real option for a handful of large franchise members with contractual separation needs, but it makes cross-agency search and co-broking much harder. The trade-off is in database per tenant vs shared schema. Most portals use a shared schema with RLS.

What do the RLS policies for listings and leads look like?

One permissive policy per way of reaching a row, plus restrictive policies for scopes inside the agency. PostgreSQL's CREATE POLICY reference is precise about how they combine: permissive policies are joined with OR and "add to the set of records which can be accessed", while restrictive policies are joined with AND, so "all restrictive policies must be passed for each record".

These policies assume the app connects as a non-owner role, tables use FORCE ROW LEVEL SECURITY, and the request's agency, branch, agent and role are set per transaction with set_config, as the RLS guide shows.

-- Read the request context; an unset value becomes NULL and matches nothing.
CREATE FUNCTION app_ctx(key text) RETURNS text
  LANGUAGE sql STABLE
  AS $$ SELECT NULLIF(current_setting('app.' || key, true), '') $$;

ALTER TABLE listings ENABLE ROW LEVEL SECURITY;
ALTER TABLE listings FORCE ROW LEVEL SECURITY;

-- 1. Your own agency's listings: read and write.
CREATE POLICY listings_own ON listings
  FOR ALL TO app_user
  USING (agency_id = app_ctx('agency_id')::uuid)
  WITH CHECK (agency_id = app_ctx('agency_id')::uuid);

-- 2. A partner's co-broked listings: read only, under an active agreement.
CREATE POLICY listings_co_broke_read ON listings
  FOR SELECT TO app_user
  USING (
    visibility = 'co_broke'
    AND status = 'active'
    AND EXISTS (
      SELECT 1 FROM co_broke_agreements a
      WHERE a.listing_agency_id = listings.agency_id
        AND a.partner_agency_id = app_ctx('agency_id')::uuid
        AND a.status = 'active'
        AND (a.ends_at IS NULL OR now() < a.ends_at)
    )
  );

-- Private listing fields: own agency only, no partner policy at all.
ALTER TABLE listing_private ENABLE ROW LEVEL SECURITY;
ALTER TABLE listing_private FORCE ROW LEVEL SECURITY;
CREATE POLICY listing_private_own ON listing_private
  FOR ALL TO app_user
  USING (agency_id = app_ctx('agency_id')::uuid)
  WITH CHECK (agency_id = app_ctx('agency_id')::uuid);

-- Leads: the agency wall, then a restrictive scope inside it.
ALTER TABLE leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE leads FORCE ROW LEVEL SECURITY;
CREATE POLICY leads_own ON leads
  FOR ALL TO app_user
  USING (agency_id = app_ctx('agency_id')::uuid)
  WITH CHECK (agency_id = app_ctx('agency_id')::uuid);

CREATE POLICY leads_scope ON leads
  AS RESTRICTIVE FOR SELECT TO app_user
  USING (
    app_ctx('role') = 'agency_admin'
    OR (app_ctx('role') = 'branch_manager' AND branch_id = app_ctx('branch_id')::uuid)
    OR assigned_agent_id = app_ctx('agent_id')::uuid
  );

What each piece is doing:

  • The co-broke policy is SELECT only. A partner can read a shared listing, but an UPDATE or DELETE needs a policy that passes for that command, and only listings_own does. A partner cannot edit the price or withdraw the listing.
  • The agreement subquery is itself under RLS. The co_broke_agreements table needs a policy letting each agency see the agreements it is a party to, or the EXISTS check finds nothing and co-broking silently stops working. That fails closed, which is the right direction.
  • Leads have no partner policy. No sharing agreement grants access to another agency's leads. The restrictive leads_scope then narrows an agency's own leads by role, so an agent sees their assigned leads and a branch manager sees the branch.
  • Branch rules are a choice. Some agencies want every agent to see every listing in the agency; few want that for leads. Keep the branch scope in a restrictive policy so a looser listing rule cannot widen lead access through OR.

Which data should be shared, private or public?

Decide this per field before writing a policy. The table below is a sensible default; your agencies' agreements, and local rules on personal data, may change some rows.

DataOwn agencyCo-broke partnerPublic search
Listing address, price, photos, descriptionRead and writeRead (active agreement)Read if published
Vendor or landlord name and contactRead and writeNeverNever
Fee and commission termsRead and writeOnly the agreed split on the agreement rowNever
Viewing availabilityRead and writeRead, request a slotNever, or a booking form only
Leads and buyer contactsRead and write, scoped by roleNeverNever
Offers on the listingRead allOnly offers it submittedNever
Documents (contracts, IDs, surveys)Read and writeOnly documents explicitly sharedNever
Agent profileRead and writeReadRead the public fields

How do you handle co-broking between agencies without leaking leads?

Treat co-broking as data the system can check, not as a setting on a listing. A co-broke agreement row names the listing agency, the partner agency, the scope (one listing, a branch's stock, or all co-broke listings), the agreed split, a start and end date, and a status. Policies read that row, so revoking it removes the partner's access in the same transaction.

The lead question needs a rule everyone accepts before launch. A workable default:

  • The agency whose channel captured the buyer owns the lead. If a buyer inquires through agency B's site about agency A's co-broked listing, the lead row belongs to B.
  • The listing agency gets a viewing or offer request, not the lead. A separate request record, readable by both agencies, carries the listing, the requested time and what B chose to pass on. A cannot browse B's buyer list from it.
  • Every crossing is written to an audit log with both agency IDs, so a disputed commission has a record.

Lead routing inside one agency, from inquiry to the right agent, is a CRM question; it is covered in building a real estate CRM for agents. Role design across portals is in SaaS authorization and RBAC design.

Co-broking and commission rules vary by country and by agency agreement. This is general engineering guidance, not legal advice; confirm the rules for your market with your adviser.

How do you search across every agency without leaking private data?

Do not point public search at the listings table. Build a separate projection, a public_listings table or a search index, that holds only published, active listings and only public columns, and give the search service a role that can read nothing else.

-- The public search role reads one table and nothing else.
CREATE ROLE search_reader LOGIN NOSUPERUSER NOBYPASSRLS;
GRANT SELECT ON public_listings TO search_reader;
-- No grants on listings, listing_private, leads, agreements or documents.

-- public_listings holds public columns only: id, agency_id, ref, title,
-- price, area, bedrooms, photos, agent display name. It is refreshed by a
-- job running under a logged maintenance role, from listings where
-- visibility = 'public' AND status = 'active'.

This shape has three advantages. A bug in the search API can only expose data that was already public. Withdrawing a listing is a delete from one table. And the search query has no tenant filter to forget, because there is nothing private in its table to filter.

The same rule applies to an external search engine: index the projection, never the source tables, and if agents get an internal search across their own agency's private stock, run it through the RLS-protected database role or apply a per-agency filter that the server sets and the browser cannot change. Watch the side channels too: autocomplete, "similar properties", saved-search alerts, sitemaps and map clustering all read listing data and each needs the same rule.

What should a cross-agency IDOR test suite cover?

Every way one agency's user could reach another agency's object. IDOR, insecure direct object reference, is when changing an ID in a request returns someone else's data. OWASP's IDOR prevention cheat sheet recommends access checks "for each object that users try to access" and testing with multiple accounts; on a portal that means seeding at least two agencies, each with two branches, and attacking across every boundary.

#Acting asTries toExpected
1Agent, agency ARead a private listing of agency B404
2Agent, agency ARead B's co-broke listing with no agreement404
3Agent, agency ARead B's co-broke listing under an active agreement200, with no vendor or fee fields
4Agent, agency ASame listing, after the agreement is revoked404
5Agent, agency AUpdate the price of B's co-broke listing404
6Agent, agency ARead a lead of agency B by ID404
7Agent, agency ACreate a listing with agency_id set to BRejected
8Agent, agency ACreate a listing in A pointing at B's branchRejected by the composite key
9Agent, branch A1Read a lead assigned to an agent in branch A2404
10AnonymousFind a private or co-broke listing through search, autocomplete or the sitemapNot found
11Agent, agency ADownload B's document by file ID or an old signed URL403 or expired
12Agency admin, AExport leads or listings as CSVOnly agency A's rows

Expect 404 rather than 403 for cross-agency reads, so a response never confirms that the object exists. A table-driven test keeps the suite short and makes adding a case one line:

const cases = [
  { as: 'agentA', path: (f: Fixtures) => '/api/listings/' + f.listingBPrivate, status: 404 },
  { as: 'agentA', path: (f: Fixtures) => '/api/listings/' + f.listingBCoBrokeNoDeal, status: 404 },
  { as: 'agentA', path: (f: Fixtures) => '/api/leads/' + f.leadB, status: 404 },
  { as: 'agentA1', path: (f: Fixtures) => '/api/leads/' + f.leadA2, status: 404 },
]

describe('cross-agency isolation', () => {
  for (const c of cases) {
    it(c.as + ' cannot reach ' + c.path(fixtures), async () => {
      const res = await api(c.as).get(c.path(fixtures))
      expect(res.status).toBe(c.status)
    })
  }

  it('a co-broke partner never receives vendor fields', async () => {
    const res = await api('agentA').get('/api/listings/' + fixtures.listingBCoBrokeActive)
    expect(res.status).toBe(200)
    expect(res.body).not.toHaveProperty('vendorPhone')
    expect(res.body).not.toHaveProperty('feeTerms')
  })
})

Run it in CI against a real PostgreSQL, alongside a catalogue check that fails the build if any table with an agency_id column lacks forced RLS or a policy. If you already suspect a leak, start with what to do when users can see other tenants' data.

How long does it take to add this yourself, and what is the main risk?

For a developer who knows PostgreSQL well, designing this into a new platform adds roughly 1–2 weeks to the build: the schema, the policies, the search projection and the test suite. Retrofitting it into a live portal with a few dozen tables is more like 2–4 weeks, because every query, background job, report and export has to be run under the new role and checked.

The main risk is a door that bypasses the policies: a job or admin screen connecting as the table owner, a reporting view that runs with its owner's rights, or a broad permissive policy added later that widens access through OR. The IDOR suite and the catalogue check are what catch those before an agency does.

Buy, build or hire?

OptionExample and costChoose this whenWatch out for
Off-the-shelf brokerage platformLofty offers Agent, Team, Broker and Enterprise tiers, with prices on request (Lofty pricing)You are one brokerage, possibly with several offices, and need CRM, lead routing and websites nowIt runs your agency; it is not a platform you own and sell to other agencies
Template or no-codeHouzez WordPress theme, $89 regular licence with 6 months of support (ThemeForest); Bubble from $59 a month billed annually (Bubble pricing)You are testing demand with a few friendly agencies and no sensitive lead data yetSeparation between agencies depends on the app's own checks over one shared database; test it yourself before rival agencies' leads go in
Custom buildMarket cost depends on scope; see the PropTech guideThe platform is your product, competing agencies share it, and co-broking or shared search is the pointIsolation has to be designed in from day one and tested on every release

Why RAITHub for this

  • BlockEstate. RAITHub built this multi-tenant listing and inquiry platform for agents and brokerages, with each brokerage isolated as a tenant, listing intake, agent dashboards, lead routing and a document workflow. The MVP shipped in 6 weeks. It has no MLS integration, and RAITHub does not claim one. See the BlockEstate case study.
  • Sundor Skin. A B2B wholesale platform with 146 PostgreSQL tables under row-level security, 12 staff roles built from 88 permission codes, an IDOR suite that tries to read other buyers' data, and 530+ automated tests. Different industry, same isolation problem. See the Sundor Skin case study.
  • QA-first. The cross-tenant tests are written with the policies, not after launch, and gate CI. More on the QA and test automation service.

Co-broking agreements and cross-agency search as described above are engineering guidance; RAITHub has not shipped a co-broking feature and does not cite one.

When you don't need us

  • You are one agency, even with many branches. Branches are roles inside one tenant. A good off-the-shelf CRM handles that.
  • Agencies never share anything. If there is no co-broking and no shared search, a standard multi-tenant SaaS pattern is enough, and the RLS guide covers it.
  • You need MLS or IDX listings in the platform. Your MLS's access approval comes first, and RAITHub has no live MLS integration to point to.
  • You only want a check of an existing portal. Run the 12 cases above with two test agencies; that answers most "are we leaking?" questions in a day.

How RAITHub would build this

  • Scope: the agency, branch and agent model with a permission matrix; RLS policies on every agency-owned table; co-broke agreements with a shared-field list; a public search projection; and the cross-agency IDOR suite with the catalogue check in CI.
  • Timeline: a new multi-agency MVP in 4–6 weeks at fixed scope, as with BlockEstate; adding isolation to an existing platform is backend work of about 6–12 weeks; a fragile portal that is already leaking starts with code rescue, 2–4 weeks.
  • What you receive: automated tests and CI, including the isolation suite; handover docs and runbooks for roles, policies and the maintenance role; full IP assigned to you, under NDA.
  • Delivery: remote from Dhaka, in English. UK agencies get 3 hours of working-day overlap in winter and 4 in summer, with written daily handoffs.

Next step: a free 15-minute technical audit, then a written fixed quote. RAITHub publishes no rates. See the PropTech service page or the SaaS development service, then book the free audit with how many agencies you expect, what they share, and how buyers search.

Frequently asked questions

How do you keep agencies' data separate on one real estate platform?

Make the agency the tenant: put agency_id on every listing, lead and document row, enforce it with PostgreSQL row-level security under a non-owner database role, keep private fields such as vendor contacts in their own table, and prove it with cross-agency IDOR tests in CI.

Can two agencies share a listing without sharing their leads?

Yes. Model co-broking as an agreement row that grants the partner read-only access to named listings. Leads get no partner policy at all; the listing agency receives a viewing or offer request instead of the buyer's lead record.

Should branches be separate tenants?

Usually not. The agency is the hard boundary; a branch is a scope inside it, enforced with a restrictive policy or role rules, because most agencies want managers to see across branches.

How do you search listings across all agencies safely?

Search a separate projection that holds only published, active listings and public columns, read by a role that has no access to any other table. Then a search bug can only expose data that was already public.

Should cross-agency requests return 403 or 404?

404 for objects in another agency, so a response never confirms that a listing or lead exists. Use 403 for actions on your own agency's objects that your role does not allow.

Does RAITHub's BlockEstate include MLS integration or co-broking?

No. BlockEstate is a multi-tenant listing and inquiry platform with lead routing and a document workflow; it has no MLS integration, and the co-broking design here is engineering guidance rather than a shipped feature.

How long does it take to build a multi-agency property platform?

A focused MVP with tenant isolation built in takes about 4–6 weeks at fixed scope; BlockEstate's took 6 weeks. Retrofitting isolation into a live platform is typically 6–12 weeks of backend work.

Multi-agency platformReal estate portalTenant isolationPostgreSQL RLSCo-brokingIDOR testingPropTech

Ready to discuss your project?

Book a free 15-minute technical audit with our engineering team.