Skip to content

Attached Documents Permissions and Search

Ed Mozley edited this page Aug 17, 2026 · 1 revision

Attached documents β€” permissions and search

How FreeITSM decides who may see a document, and how that survives being asked about ten thousand of them at once.

The other two pages: Attached documents (what it does) and Attached documents β€” Developer Guide (schema and API).


1. The rule

A document has no permissions of its own. It is visible if β€” and only if β€” you can see at least one of the things it is attached to.

Everything below is a consequence of that sentence.

The obvious alternative β€” a generic per-object permission table, with users, groups, roles and inheritance β€” was considered and rejected. FreeITSM does not have one, and building it would have been a bigger project than the document feature. What it does have is:

  • module membership β€” the read gate (allowed_modules). Reads are not capability-gated in FreeITSM; see the note at the top of api/system/global_search.php
  • each module's own tenancy filter β€” ticketTenantFilter(), activeTenantFilter(), knowledgeTenantFilter()
  • per-record checks that already existed β€” analystCanAccessTicket(), analystCanAccessAsset(), analystCanAccessChange(), analystCanAccessProblem(), analystCanAccessCmdbObject(), analystCanAccessArticle(), all in includes/tenancy.php

Documents borrow those rather than inventing a rival to them.

Why storing visibility would be a bug, not merely duplication

Permissions change after a document is uploaded. Somebody joins a team, a record moves company, a module is granted. A visibility answer written into the row at upload time is stale from that moment, and stale in the dangerous direction: it goes on saying yes.

So the question is asked when it is asked, never before.


2. The same question, two shapes

Downloading asks about one document, so it can afford an exact check. Searching asks about a set, and checking N documents one at a time is a loop that gets slower as the product succeeds.

documentCanView($conn, $analystId, $allowedModules, $documentId): bool
documentVisibilityClause($conn, $analystId, $allowedModules, $alias): [sql, params]

Both read the same registry, so they cannot drift apart β€” and there is a test asserting they never disagree, because a document you cannot find but can still download is exactly the bug this design exists to prevent.

The clause is an EXISTS per accessible entity type, OR'd together β€” which is the rule stated literally:

AND (
  EXISTS (SELECT 1 FROM document_links dl0 JOIN tickets   pa0 ON pa0.id = dl0.parent_id
           WHERE dl0.document_id = d.id AND dl0.parent_type = 'ticket'
             AND pa0.deleted_datetime IS NULL AND (pa0.tenant_id = ? OR pa0.tenant_id IS NULL))
  OR EXISTS (… assets …)
  OR EXISTS (… contracts …)
  …
)

The database evaluates it as a join and stops at the first match. Nothing is fetched and then discarded in PHP.

⚠️ It returns AND 0=1 β€” never an empty string β€” when the analyst can reach no entity type at all. An empty filter means no constraint, which is every document in the system. Fail closed.


3. Where the check actually happens

Place What it uses Why
download.php documentCanView() The boundary. Never assumes the id came from a filtered list
list.php documentCanViewParent() You must see the record to see its documents
find.php / attach.php both See below β€” this one is subtle
links.php (the β“˜) both Lists only parents you may see
Corpus search documentSearchVisibilityClause() Inside the query
⌘K documentVisibilityClause() Inside the query

The download endpoint is the real boundary

Search deciding what to list is a convenience. Download decides what somebody may have. download.php?id=12345 with a guessed integer is the attack, and integers are easy to guess β€” so that endpoint does the full walk of every link, and never trusts that the caller found the id legitimately.

It also returns the same 404 for "no such document" and "not yours". The difference between them would map out what exists.

Attaching is a permission operation

attach.php checks both ends, and the one that is easy to miss is the document.

You must already be able to see the document, not merely the record you are attaching it to. Without that check, anyone could attach a document they have no access to onto a record they do β€” and then read it, because visibility is inherited from the parent. Two clicks, and the permission model is inverted.

Tested directly: an analyst with assets but not contracts, attaching a contract-only document onto an asset they can see, gets Not found β€” and find.php does not offer it in the first place.


4. Search

A corpus row carries a tenant and an internal flag. That is enough to judge a ticket. It is not enough to judge a document, whose visibility lives in other tables entirely.

So the clause is built in searchScopeForAnalyst(), where the analyst is known, and rendered by searchScopeToSql() with everything else:

AND (sd.source_type <> 'document' OR EXISTS (
      SELECT 1 FROM documents __d
       WHERE __d.id = sd.source_id AND __d.deleted_datetime IS NULL AND ( … the clause … )))

"Not a document, or a document you can see" β€” so it ANDs onto the existing WHERE without disturbing any other source type. Built once per search. If it cannot be built, documents are excluded entirely.

⌘K asks twice, deliberately

Two separate sources, and the separation is a security decision rather than tidiness:

  • by name β€” LIKE on title, description and filename
  • by content β€” the corpus, restricted to source_type='document'

The existing "found inside tickets" block is gated on $can('tickets') because the corpus does not know about module access. Folding documents into that block would have meant loosening the gate so a contracts-only analyst could reach document content β€” which would have handed them ticket content at the same time.

What is deliberately not filtered

Result subtitles name where a document is attached β€” and only ever a record the caller can see. Naming a hidden one would disclose a contract's existence and its title to somebody with no access to Contracts.

The β“˜ goes one step further: hidden parents are reported as a count and nothing else β€” "And 2 other records you do not have access to". That is a deliberate line. It tells you the document is more widely attached than you can see, which matters before you attach it somewhere new, and it identifies nothing.


5. Scale

Sized for 5,000–10,000 documents, which is a small table.

document_links (parent_type, parent_id) β€” "what is on this record"; (document_id) β€” every permission walk
documents FULLTEXT on (title, description), plus content_hash and tenant_id
search_documents the existing corpus indexes

The design that would not scale, and was rejected: fetching candidate rows and filtering them through a per-module callback in PHP. Fine at six results, an O(N) loop with pagination, and it gets slower exactly as the product succeeds. Everything the filter needs is expressible in SQL, and the expensive per-record form is reserved for the download endpoint where N is 1.

Paging is in the panel from the start β€” "show everything" is fine until somebody attaches three thousand photographs to one asset.


6. The file on disk

The permission layer is worth nothing if the file can be fetched around it.

  • Stored under a random name FreeITSM chooses, from its own extension allow-list β€” but a random name is not a permission. It leaks through logs, proxies, referrers and browser history.
  • uploads/documents/ is denied entirely by .htaccess (Apache), web.config (IIS) and deploy/nginx/freeitsm.conf (nginx, which reads no .htaccess at all).
  • Run D009 "Guarded paths" to find out which of those your server is actually honouring. It writes a probe, fetches it as a stranger would, and deletes it β€” and it found this folder exposed on Apache within minutes of the folder being created.
  • Serving goes through attachmentSendHeaders(), which decides what may be shown inline. An HTML or SVG rendered inline on our own origin runs as us, so the answer for those is never "inline" β€” and it is the file type's call, not the caller's.

7. What the tests cover

tests/document-permissions.php β€” 22 assertions Β· tests/document-search-permissions.php β€” 9

Every denial is paired with a positive control: the same analyst, the same document, the module granted. A test that only proves absence passes just as happily when the index is empty, the word is below the full-text minimum, or the query is broken.

That is not theoretical. The search test failed on its first run β€” not from a permission bug, but because searchCorpusQuery() returns grouped results with the rows under hits, and the test read source_type off the group. Reading it wrong finds nothing, which looks exactly like a permission check working. Without a control that must succeed, that file would have passed while proving nothing.

The assertion worth keeping above all others:

check('search and download agree in every case', $agree, true);

Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally