-
Notifications
You must be signed in to change notification settings - Fork 15
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).
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 ofapi/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 inincludes/tenancy.php
Documents borrow those rather than inventing a rival to them.
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.
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 returnsAND 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.
| 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 |
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.
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.
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.
Two separate sources, and the separation is a security decision rather than tidiness:
-
by name β
LIKEon 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.
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.
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.
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) anddeploy/nginx/freeitsm.conf(nginx, which reads no.htaccessat 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.
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);- Attached documents β the user-facing guide
- Attached documents β Developer Guide β schema, API, adding a module
- Security Β· Multi-tenancy β the tenancy filters this borrows
- Roles & Permissions β why reads are gated by module, not capability
FreeITSM β an open-source IT Service Management platform Β· github.com/edmozley/freeitsm Β· MIT licence
- Installation
- β° Scheduled tasks (cron jobs)
- Architecture
- AI Providers
- Internationalisation (i18n)
- Timezones & Time Handling
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
- MobileβFriendly
-
Security
- Layer 1 β which modules you can enter
- β³ π§© Module Access Control
- β³ π οΈ Module Access β Developer Guide
- Layer 2 β what you can administer
- β³ π Roles & Permissions
- β³ π οΈ Roles β Developer Guide
- β³ π€ Why capabilities are constants
- Layer 3 β the System module
- β³ π Admin Access Control
- Hardening
- β³ π Security review response 2026-08
- β³ π‘οΈ Security hardening 2026-08
- β³ π οΈ Security hardening 2026-08 β Developer Guide
- β³ π‘οΈ Round three β plain English
- β³ π οΈ Round three β Developer Guide
- Single Sign-On (SSO)
- ποΈ LDAP & Active Directory
- Browser Extension
- API Reference
-
π REST API β how it works
- β³ π« REST API: Tickets
- β³ π» REST API: Assets
- β³ π΄ REST API: Problems
- β³ π REST API: Changes
- β³ π REST API: Knowledge
- β³ β REST API: Tasks
- β³ ποΈ REST API: CMDB
- β³ π REST API: Contracts
- β³ ποΈ REST API: Calendar
- β³ πΏ REST API: Software
- β³ π¦ REST API: Service Status
- β³ βοΈ REST API: Morning Checks
- β³ π REST API: Forms
- β³ βοΈ REST API: Workflow
- β³ πΊοΈ REST API: Network Mapper
- β³ π§ Using the API docs page
- β³ π OpenAPI specification
- β³ β OpenAPI: kept correct
- β³ π οΈ Maintaining the catalogue
- Watchtower
-
Tickets
- β³ Mailbox Authentication
- β³ π€ Email send log
- β³ Basic IMAP mailboxes
- β³ Email rendering & images
- β³ SLA Management
- β³ WhatsApp channel
- β³ π¬ Web chat channel
- β³ π£ Slack channel
- β³ π Linking tickets
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- Problem Management
- Tasks
- Assets
- Knowledge
- Change Management
- Calendar
- Morning Checks
- Reporting
- Software
- Forms
- Contracts
- Service Status
- π Notifications
- π¨ War Room
- Self-Service Portal
- LMS
- Process Mapper
- CMDB
- Network Mapper
- Workflows
- Issue trackers (Jira, Azure DevOps)
- System
-
Overview
- β³ π Progress tracker
- β³ Concepts & vocabulary
- β³ Email routing & mailboxes
- β³ Settings: global vs per-company
- β³ Users & self-service
- β³ Staff cross-company access
- β³ Worked examples
- β³ Pitfalls & gotchas
- β³ Scope: what it's for
- β³ π οΈ Developer Guide (make a module multi-company)
- β³ ποΈ Case study: CMDB (a linked graph)
- β³ π§ͺ Test harness (prove it's isolated)