Who sees what.
Salesforce's sharing model is one of the hardest things to leave — profiles, permission sets, org-wide defaults, a role hierarchy, sharing rules. OpenForce doesn't approximate it. It rebuilds it in two layers you own: application roles in your repo, and row-level-security policies in your PostgreSQL database — so the database itself enforces who sees which rows.
This is our strongest-built surface. Everything below is generated by the engine today; where a piece is transcribed but not yet fully wired, the honesty panel says so plainly.
Before rebuilding it, we inventory the whole thing. This is every construct that decides visibility in a Salesforce org — the X-Ray reads all of it out of your metadata.
- §Profiles, permission sets & permission-set groups — object CRUD and field-level security, granted per user.
- §Org-wide defaults (OWD) — the baseline for each object: Private, Public Read, Public Read/Write, or Controlled by Parent.
- §Role hierarchy — managers inherit visibility of records owned by people below them.
- §Sharing rules — ownership-based and criteria-based grants that open records above the OWD baseline.
- §Manual shares & teams — ad-hoc record shares and account/opportunity teams.
- §Public groups & queues — named principals that sharing rules and ownership resolve against.
- §Sharing sets — the portal/Experience-Cloud construct that scopes an external user to records related to their own account or contact.
Salesforce keeps sharing in the platform layer only. We put it in two layers — the application and the database — so a bug in one is not a data breach. Here is where each Salesforce construct lands.
Layer 1 — application RBAC, in your repo
| Salesforce construct | Rebuilt as |
|---|---|
| Permission set | A role in a generated rbac module. |
| Object CRUD, checked on the route | Every generated API route calls can(role, action, object) against the verified JWT role — not a client header. |
| Permission-set group | A composite role: the union of its members, minus any muting permission set. |
| System Administrator | A synthesized app-level admin role that short-circuits the checks — never transcribed from an org profile. |
| Field-level security | Transcribed per field, per role (read / write / none), enforceable via can(role, action, object, field). See the honest edge below. |
Layer 2 — the database itself, as PostgreSQL row-level security
| Salesforce construct | Rebuilt as a PostgreSQL policy |
|---|---|
| Org-wide default = Private | Owner-keyed policy: a row is visible when its owner column equals the current user. |
| Role hierarchy | Recursive subordinate resolution: a precomputed ancestor/descendant closure table, tested with an EXISTS clause. |
| Criteria sharing rule | A SQL predicate; a lookup criterion maps to the foreign-key column (Region__c → regionId). |
| Controlled by Parent | A parent-visibility read join — you can read the child when you can see its parent. |
| Public groups & queues | Sharing principals in the policy; their membership is seeded as a migration step. |
| Per-request identity | Transaction-local session context (set_config('app.user_id', …, true)) set on every query, so the database enforces row visibility even if the app is bypassed. |
This is not an illustration. It is a trimmed policy from the rls-policies.sql the engine
emitted for a real Program Management Module app — a Private object, so a row is visible only to
its owner, to anyone above the owner in the role hierarchy, or to an admin.
POSTGRESQL rls-policies.sql · Bucket__mdt (OWD: Private)
-- Bucket__mdt -> "Bucket__mdt" (OWD: Private) ALTER TABLE "Bucket__mdt" ENABLE ROW LEVEL SECURITY; ALTER TABLE "Bucket__mdt" FORCE ROW LEVEL SECURITY; CREATE POLICY "Bucket__mdt_select_policy" ON "Bucket__mdt" FOR SELECT USING ( current_setting('app.is_admin', true) = 'true' OR "Bucket__mdt"."ownerId" = current_setting('app.user_id', true) OR EXISTS (SELECT 1 FROM "_role_hierarchy" rh WHERE rh."ancestor_role" = (SELECT "role" FROM "AppUser" WHERE "id" = current_setting('app.user_id', true)) AND rh."descendant_role" = (SELECT "role" FROM "AppUser" WHERE "id" = "Bucket__mdt"."ownerId")) );
FORCE ROW LEVEL SECURITY means the policy applies even to the table owner. The identity comes
from current_setting('app.user_id'), which the generated app sets transaction-locally on
every query from a verified JWT — so this rule holds at the database boundary, not just in application
code. The _role_hierarchy lookup is the recursive-subordinate resolution, precomputed into a
closure table at generation time.
The brand is engineering honesty, so here is exactly where this surface stops today. Everything above is real; these four lines are the truth about the edges.
Object CRUD is enforced everywhere; field-level enforcement on the route is not yet wired.
Every generated route checks can(role, action, object), and field-level security is
transcribed per field, per role, with a can(role, action, object, field) primitive ready.
But the generated route handlers do not yet call it per field — so today a role with object read gets
all columns. Field enforcement at the route is on the build list; we do not claim it as done.
Database-level enforcement requires running as a least-privilege role. Row-level security only
enforces when the app connects as a non-superuser role without BYPASSRLS. The deploy
bundle warns loudly until the operator switches the app's database role; demos run as superuser,
where the policies are set but bypassed. Real row security is one role switch away, and the generator
tells you so.
Sharing sets (portal users) are inventoried, not yet auto-translated. The record-scoping engine is built and fail-closed; the automatic translation of a Salesforce sharing set into a relationship-keyed policy is on our roadmap. Today the X-Ray flags it as not-yet-pipelined, and an engineer wires those portal rules onto a database that already speaks that language.
Manual shares and public groups/queues are inventoried and flagged, not silently dropped. Ad-hoc record shares are surfaced in the emitted policy file with migration guidance; group and queue membership is a seed-time data migration, not something Salesforce exposes in metadata to transcribe.
Your permission model is not a Salesforce feature — it's rows in your database and code in your repo.