One Platform, Many Agencies: Tenant Isolation for Property Portals
Founder & Lead Engineer, RAITHub
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.
| Level | What it is | Isolation rule | Typical roles |
|---|---|---|---|
| Platform | You, the operator | Support access through a separate, logged role, never the app role | Platform admin, support |
| Agency (tenant) | A brokerage or franchise member | Hard wall: database-enforced with RLS | Agency admin |
| Branch | An office of that agency | Soft wall inside the tenant, set by agency policy | Branch manager |
| Agent | A person working leads and listings | Sees own leads; branch or agency leads only if the role allows | Agent, 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.
| Data | Own agency | Co-broke partner | Public search |
|---|---|---|---|
| Listing address, price, photos, description | Read and write | Read (active agreement) | Read if published |
| Vendor or landlord name and contact | Read and write | Never | Never |
| Fee and commission terms | Read and write | Only the agreed split on the agreement row | Never |
| Viewing availability | Read and write | Read, request a slot | Never, or a booking form only |
| Leads and buyer contacts | Read and write, scoped by role | Never | Never |
| Offers on the listing | Read all | Only offers it submitted | Never |
| Documents (contracts, IDs, surveys) | Read and write | Only documents explicitly shared | Never |
| Agent profile | Read and write | Read | Read 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 as | Tries to | Expected |
|---|---|---|---|
| 1 | Agent, agency A | Read a private listing of agency B | 404 |
| 2 | Agent, agency A | Read B's co-broke listing with no agreement | 404 |
| 3 | Agent, agency A | Read B's co-broke listing under an active agreement | 200, with no vendor or fee fields |
| 4 | Agent, agency A | Same listing, after the agreement is revoked | 404 |
| 5 | Agent, agency A | Update the price of B's co-broke listing | 404 |
| 6 | Agent, agency A | Read a lead of agency B by ID | 404 |
| 7 | Agent, agency A | Create a listing with agency_id set to B | Rejected |
| 8 | Agent, agency A | Create a listing in A pointing at B's branch | Rejected by the composite key |
| 9 | Agent, branch A1 | Read a lead assigned to an agent in branch A2 | 404 |
| 10 | Anonymous | Find a private or co-broke listing through search, autocomplete or the sitemap | Not found |
| 11 | Agent, agency A | Download B's document by file ID or an old signed URL | 403 or expired |
| 12 | Agency admin, A | Export leads or listings as CSV | Only 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?
| Option | Example and cost | Choose this when | Watch out for |
|---|---|---|---|
| Off-the-shelf brokerage platform | Lofty 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 now | It runs your agency; it is not a platform you own and sell to other agencies |
| Template or no-code | Houzez 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 yet | Separation between agencies depends on the app's own checks over one shared database; test it yourself before rival agencies' leads go in |
| Custom build | Market cost depends on scope; see the PropTech guide | The platform is your product, competing agencies share it, and co-broking or shared search is the point | Isolation 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.
Related posts
Ready to discuss your project?
Book a free 15-minute technical audit with our engineering team.