SharePointVault ~/gotchas/security ⌕ Search 404 pages, error messages, scripts…Ctrl K ★ Star
M↓ README.mdM↓ app-provisioned-library-inherits-web-write.md
sharepoint-vault›gotchas›security›app-provisioned-library-inherits-web-write.mdhistoryedit on GitHub
● GOTCHAsecurity· 6 min read

A library your app provisions inherits the web's write permissions

applies to: SharePoint Onlinereviewed: 2026-08-31#security#provisioning#libraries#search#rag#permissions

Provisioning helpers set item-level permissions on lists; document libraries usually get none, so they inherit the web. Any member with Contribute can upload straight over REST. When that library is what an AI assistant reads as "company knowledge", uploading a file is the same as editing the answers.

Knihovny z provisioningu obvykle nedostanou žádná oprávnění a dědí je z webu – člen s Přispívat tam nahraje cokoli. Když z knihovny čte AI, je to cesta, jak jí podstrčit „firemní pravdu“.

Symptom

There is none, which is the point. Everything works; the library simply accepts writes from more people than the product's UI suggests. You discover it by asking the question, not by hitting an error.

The consequence has a shape though: an assistant grounded on that library starts citing a document nobody in the content team wrote, with the confident tone reserved for internal sources.

Cause

Two habits meeting:

  1. Library definitions carry fewer settings than list definitions. Provisioning code that faithfully applies ReadSecurity/WriteSecurity to lists often has no equivalent path for libraries — the settings are simply never sent, so the library keeps the web's inheritance.
  2. The app's own gate is UI. "Only administrators can publish" is enforced by which buttons render. POST /_api/web/GetFolderByServerRelativeUrl('…')/Files/add(...) does not consult your React tree.

The fix that looks right and mostly is not

The obvious move is WriteSecurity: 4 on the library — create and edit: none. It is one PATCH, it reads back cleanly, and it is almost useless on a team site.

Item-level permissions are bypassed by anyone holding Manage Lists — and that is not just owners. Measured on a real site:

Members  [Edit] -> bypasses the setting      Visitors [Read] -> setting applies
Manage Lists:  Full Control ✅  Design ✅  Edit ✅  |  Contribute ❌  Read ❌

The built-in Edit level includes Manage Lists, and Edit is what the default Members group gets on a modern team site. So WriteSecurity: 4 stops users with Contribute and nobody else — while the ordinary member you were worried about uploads happily.

The only boundary that holds is unique permissions on the library:

POST /_api/web/GetList('<serverRelLibUrl>')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=true)
POST /_api/web/GetList('<serverRelLibUrl>')/roleassignments/addroleassignment(principalid=<group>,roleDefId=<def>)

Writers (the publishing/approver groups) get Contribute or higher; everyone else gets Read.

Do not keep WriteSecurity: 4 "as defence in depth" either

An earlier version of this page said you could leave it on as a second layer. That advice was wrong, and the reason is worth stating plainly, because it generalises far past this one setting.

On a document library the item-level settings are not merely weak — they are not a supported feature at all. SharePoint does not render the Item-level Permissions section in a library's Advanced Settings; Microsoft's answer for libraries is per-file Manage access instead. You can still PATCH ReadSecurity/WriteSecurity onto a library over REST, and you can read the value back, but WriteSecurity on libraries is widely reported not to be honoured, and library item-level permissions are described as deprecated. Whatever the current server build does, you are relying on behaviour the product does not document.

And there is no value that both protects and works. ReadSecurity: 2 means read only the documents you uploaded — apply it to a shared library and it goes empty for everyone except each file's own author. ReadSecurity: 1 is what SharePoint already does. So the only settings that would change anything are the ones that break the library.

The general rule: a stored setting that excludes nobody is worse than no setting. It reads back cleanly, it survives review, it appears in the release notes as a lock, and it costs you the question "so what actually stops them?" — the only question that was ever load-bearing. Defence in depth means a second boundary, not a second value. If the unique role assignments are the control, let them be visibly the whole control.

The order of operations decides what a half-failure leaves behind:

  1. Break inheritance with copyRoleAssignments=true — the intermediate state is identical to the old one, so nobody loses access if the next step never runs.
  2. Grant the writer groups Contribute before anyone loses write.
  3. Only then downgrade everyone else to Read. Leave Limited Access (RoleTypeKind: 1) alone — SharePoint maintains it so users can reach parent objects.
  4. Read the assignments back and treat "done" as conditional on that check.

copyRoleAssignments=false is the tempting "clean slate", but a failure between breaking and granting leaves the library unreadable for the whole company — and if it is a RAG source, the assistant quietly loses its grounding at the same time.

Verifying Manage Lists without guessing

GET /_api/web/roledefinitions?$select=Name,BasePermissions

Manage Lists is bit 11 (0x800) of BasePermissions.Low. Do not derive the bit from the PermissionKind value (that tempts you into 1 << 12, which is viewFormPages). Derive it from the difference between Edit and Contribute — two levels that differ precisely by Manage Lists — and sanity-check the result: Read must come out false, Full Control true. A calculation claiming "Read has Manage Lists" is telling you the mask is wrong, not that SharePoint is odd.

Making the setting stick at all

Whichever control you choose, the write itself needs care:

1. Do it outside the version-gated provisioning block. Schema-version gates return early once the version matches. If the first person to open the upgraded app is a plain member, the PATCH fails with 403, the version is still recorded, and the setting is never applied again. Use a dedicated post-provisioning step with its own marker (per user + web + step revision).

2. Read first, write only the difference. Cheap, and it stops members generating a 403 on every page load. Read the state that decides the write with a cache-buster — a cached "it already looks right" is the one answer that makes the whole step skip itself:

const bust = (u: string) => u + (u.indexOf('?') === -1 ? '?' : '&') + '_=' + Date.now();
const info = await get(bust(`${web}/_api/web/GetList('${serverRelLibUrl}')?$select=Id,HasUniqueRoleAssignments`));
if (info.HasUniqueRoleAssignments !== true) {
  await post(`${web}/_api/web/lists(guid'${info.Id}')/breakroleinheritance(copyRoleAssignments=true,clearSubscopes=true)`);
}
// ...then grant the writer groups, then downgrade everyone else, then re-read and verify.

Note what is not in that snippet: no PATCH of WriteSecurity. The whole write path is role assignments, because that is the whole control.

3. Never end on a return code. 200 on the three POSTs means the calls were accepted, not that anyone was excluded. Finish by re-reading the assignments and checking that no principal outside the writer allowlist still holds a level with Add/Edit/Delete — and only then record "done". A step that trusts its own status codes will happily mark an untouched library as secured.

Treat 403 as "done for this user" (their marker is their own) but a 5xx as "try again next load".

4. Do not reach for NoCrawl instead. Excluding the library from the search index is a different axis: it does not stop anyone writing, and it does stop the assistant (and tenant search) from finding legitimate content. Search trims results by the asking user's permissions anyway.

"It is set" and "it excludes someone" are different claims

The trap this page exists for: it is easy to PATCH WriteSecurity: 4, read it back over REST, see 4, and write "library locked" in the release notes. That proves the value is stored. Whether anyone is actually excluded depends on which permission levels the site's groups hold — which is a different query entirely:

GET /_api/web/roleassignments?$expand=Member,RoleDefinitionBindings
    &$select=Member/Title,RoleDefinitionBindings/Name

Cross that list with the Manage Lists check above. If every group that can already add files holds Edit or higher, the setting excludes nobody and the control is decorative.

Note also that you cannot test enforcement from an owner account: Full Control bypasses item-level permissions by design, so "I could still upload" tells you nothing. Either sign in as a Contribute-level user, or check effective permissions for a specific person without their password:

GET /_api/web/getusereffectivepermissions(@u)?@u='i:0%23.f|membership|user@company.com'

Until you have observed a non-Manage-Lists user being refused, it is a measure, not a proven control — worth a line in your tech-debt list.

Related

  • Item-level permission settings on a provisioned list — defaults, and reconciling them after the fact.
  • NoCrawl on a library silently blinds your RAG.
  • An image upload that accepts image/* accepts SVG.
Was this helpful?ask or add your experience on GitHub →
Saved you an hour? A star on GitHub helps other SharePoint people find the vault.★ Star on GitHub
last reviewed: 2026-08-31last updated: 2026-09-24historyedit on GitHubview source