Not verified hands-on. This article was written from primary documentation rather than reproduced in an org, so it carries no tested-release badge. If you run it and something differs, say so in the comments and it gets tested and corrected.

Briefly: object permissions decide whether you may touch that kind of record at all. Sharing decides which rows you get. They are evaluated independently, and neither one ever compensates for the other. A user with Read-only on Account will never edit an Account however generously it is shared, and a user with full CRUD on Account still sees only the Accounts shared to them.

Two gates, both closed by default

Every record access question passes through at least three separate checks, and people conflate the first two constantly.

Gate Set by Question it answers
Object permissions Profile and permission sets May you read, create, edit or delete this object?
Record access Org-wide defaults, role hierarchy, sharing rules, manual shares, teams, Apex sharing Which rows?
Field-level security Profile and permission sets Which columns?

Access is the intersection of all three. Grant Edit on the object and share nothing, and the user edits nothing. Share everything and grant Read only, and the user reads everything and changes nothing. The two systems do not negotiate.

Why this keeps catching people

Because the error messages are identical. INSUFFICIENT_ACCESS_OR_READONLY covers both gates, and the Salesforce UI hides the object that was refused just as thoroughly as it hides the record. A user who lacks Read on Contract does not see a broken Contract tab. The tab simply is not there, which reads as “the feature isn’t set up” rather than “you lack a permission”.

The other reason is that the Setup pages sit far apart. Object permissions live in a permission set. Sharing lives in Sharing Settings, on the role hierarchy, and on the record itself. Nothing in either screen mentions the other.

Diagnosing which gate closed, in two queries

Start with the row. This tells you what the sharing model concluded for one specific user and one specific record:

SELECT RecordId, HasReadAccess, HasEditAccess, HasDeleteAccess, MaxAccessLevel
FROM UserRecordAccess
WHERE UserId = :theUserId AND RecordId = :theRecordId

If MaxAccessLevel comes back as Edit or All and the user still cannot save, sharing is not your problem. Go to the object gate:

SELECT Parent.Profile.Name, Parent.Label, PermissionsRead, PermissionsEdit,
       PermissionsCreate, PermissionsDelete
FROM ObjectPermissions
WHERE SobjectType = 'Account'
  AND ParentId IN (SELECT PermissionSetId FROM PermissionSetAssignment
                   WHERE AssigneeId = :theUserId)

That subquery covers the profile too, because a profile is exposed as a permission set with IsOwnedByProfile set to true. The union of those rows is the user’s real object access. If every row shows PermissionsEdit false, you have your answer and no amount of sharing work will help.

Where the clean separation breaks down

Three cases blur the line, and all three are worth knowing before you start debugging.

  • View All and Modify All. These are object permissions that override sharing for that object. They are the one place the object gate reaches into the record gate, which is exactly why they are dangerous.
  • Master-detail details. A detail record has no sharing of its own. Its record access is its parent’s record access, entirely. Its object permissions are still its own, so the child can be read-only at the object level while fully shared through the parent.
  • Field-level security. Not a record concept at all, but it produces symptoms people file as access bugs, because a field the user cannot see is missing rather than greyed out.

In Apex, the two gates need two different tools

The with sharing keyword governs the record gate only. It filters which rows the query returns and does nothing about object permissions or field visibility. A class marked with sharing will happily return a Contract to a user who has no Read on Contract.

The object and field gates need WITH USER_MODE on the query and AccessLevel.USER_MODE on the DML, which enforce all three at once. Use them by default and reserve system mode for the operations that genuinely need it.

Frequently asked

Can a sharing rule grant Edit to someone with Read-only object permission? No. The share is created and does nothing useful. This is the single most common wasted afternoon in Salesforce administration.

Does the record owner always get full access? The owner gets record access, not object permissions. Remove Delete on the object and the owner cannot delete their own record.

Why does my report return zero rows when the user can open the record? Reports respect both gates plus report folder access. Check folder sharing before you suspect either gate.

Which gate should I open first when designing? Object permissions, then the most restrictive org-wide default you can live with, then open rows back up deliberately. Starting from Public Read/Write and tightening later is a project nobody finishes.