-
Notifications
You must be signed in to change notification settings - Fork 17
Knowledge Folders and Security Developer Guide
How folders and the access model are built: the one function that decides who can read what, the four main-pane renderers and why they differ, and how drag-and-drop works across all of them.
The user-facing page is Knowledge β folders and security. The design reasoning β including what was ruled out β is Folders and permissions.
Colour key: βοΈ engine Β· π API Β· π₯οΈ UI Β· π§ͺ tests
| π¨ | File | What it does |
|---|---|---|
| βοΈ | includes/knowledge/visibility.php |
the choke point. KnowledgeViewer, knowledgeVisibilitySql(), knowledgeCanRead(), knowledgeAclSql()
|
| βοΈ | includes/knowledge/audit.php |
the one writer of knowledge_audit
|
| βοΈ | includes/knowledge/audience.php |
the Audience:: ladder (internal β customer β public) |
| π | api/knowledge/folders.php |
list / create / rename / move / delete / move_article / shortcuts / exceptions |
| π | api/knowledge/permissions.php |
get / set_mode / add / remove / search_principals
|
| π | api/knowledge/permission_model.php |
containers-vs-filing, including the blast-radius preview |
| π₯οΈ | knowledge/index.php |
the shell, the modals, and the knowledge.js?v=NN cache-buster |
| π₯οΈ | assets/js/knowledge.js |
all four renderers, drag-and-drop, selection, the permission modals |
| π₯οΈ | assets/css/knowledge.css |
desktop styling Β· assets/css/mobile.css layer 17 for the phone |
| π§ͺ | tests/knowledge-visibility/ |
88 integration checks; 07_acl.php is the folder/ACL one |
β οΈ Editingknowledge.jsorknowledge.cssmeans bumping?v=NNinknowledge/index.phpβ and verify it on the served page, not by grepping the source. A full-file rewrite has silently reverted asedbump more than once here, and the symptom is a change that appears not to have been made.
Everything that reads an article goes through one file. That is the whole architecture, and it is worth stating why: before this existed there were ~48 raw SELECT β¦ FROM knowledge_articles across ~30 files, and adding an access model to 30 places means 30 chances to miss one.
[$sql, $params] = knowledgeVisibilitySql($conn, $viewer, 'a', ['lifecycle' => 'live']);
$stmt = $conn->prepare("SELECT * FROM knowledge_articles a WHERE 1=1 $sql");
$stmt->execute($params);The clause is a fragment appended to somebody else's WHERE, not a complete query. That is what lets ~30 differently-shaped readers adopt it.
KnowledgeViewer::forAnalyst($conn, $analystId); // one active company
KnowledgeViewer::forApiKey($conn, $keyId); // acts as the analyst it belongs to
KnowledgeViewer::forPortalUser($conn, $userId);
KnowledgeViewer::forWebChat($conn, $channelId);
KnowledgeViewer::forSystem('reason'); // must SAY why it is unrestrictedThere is no public constructor. A caller cannot assemble a viewer that is more permissive than any real principal, and forSystem() demands a written reason so an unrestricted read is never accidental.
function knowledgeCanRead(PDO $conn, KnowledgeViewer $viewer, $articleId, array $opts = []): boolIt runs the same clause against a single id. This is deliberate and load-bearing: if "can this appear in a list?" and "can this be opened?" were separate implementations they would drift, and the drift would be a disclosure. One clause serves both.
| Axis | Meaning |
|---|---|
tenant_id |
|
audience |
the trust ladder: internal β customer β public
|
| the ACL | folders and per-document rules |
π Every axis narrows. Nothing widens. An ACL grant cannot lift an article above its audience rung. Test
03_readers.phpasserts exactly this: a portal user granted on aninternalarticle still cannot read it, paired with a positive control proving a granted analyst can.
knowledgeAclSql() resolves the folder tree in PHP, not in SQL. No recursive CTE β the result is a flat IN (β¦) of readable article ids folded into the fragment, which keeps it appendable to arbitrary queries.
The order of operations matters:
if ($viewer->isUnrestricted()) return ['', []]; // forSystem
if (!knowledgeAclTablesExist($conn)) return ['', []]; // pre-upgrade install
if (!knowledgeAclHasAnyRows($conn)) return ['', []]; // β the fast path
// β¦ administrator floor, then the real resolutionThe fast path originally asked only "are there any ACL rows?". It is wrong, and it fails in the most dangerous direction:
A folder marked Restricted with an empty list is the tightest possible setting β and it has no ACL rows. The fast path saw "nothing configured", returned an empty clause, and the guard was off precisely where it mattered most.
It survived 24 green checks because every fixture had rows in it. The fix ORs in the restricted flags:
$any = (bool)$conn->query("SELECT 1 FROM knowledge_acl LIMIT 1")->fetchColumn()
|| (bool)$conn->query("SELECT 1 FROM knowledge_folders WHERE is_restricted = 1 LIMIT 1")->fetchColumn()
|| (bool)$conn->query("SELECT 1 FROM knowledge_articles WHERE is_restricted = 1 LIMIT 1")->fetchColumn();β Generalise it: a fast path keyed on "is anything configured?" is wrong wherever the empty configuration is the strict one.
knowledge_acl has no allow/deny column. An object is either Open (the list is who is excluded) or Restricted (the list is who is admitted), and the rows mean whichever the object says.
That absence is the guarantee: a contradictory ACL is not something the schema can store, so there is no precedence rule and no effective-permissions dialog. Flipping polarity therefore must wipe the list β permissions.php does, and audits entries_dropped_by_polarity_change.
Cap::KNOWLEDGE_MANAGE always passes, and every use is recorded:
knowledgeAuditAdminOverride($conn, $viewer, $articleId); // action = 'admin_override'07_acl.php asserts both halves.
One entry point, four exits. renderArticleList() in assets/js/knowledge.js:
container.className = 'article-list kb-layout-' + kbLayout;
if (kbLayout === 'tree') { container.innerHTML = renderTreeLayout(); kbRenderSelection(); return; }
if (kbLayout === 'details') { container.innerHTML = renderDetailsLayout(); kbRenderSelection(); return; }
const folderRows = renderFolderRows(); // list + cards share these
const ordered = articles.slice().sort(byTitle);
container.innerHTML = folderRows + ordered.map(cardHtml).join('');
kbRenderSelection();| View | Rendered by | Folder row | Article row |
|---|---|---|---|
| List | the tail of renderArticleList()
|
.kb-folder-card |
.article-card |
| Cards | the same code | .kb-folder-card |
.article-card |
| Tree | renderTreeLayout() |
.kb-tree-folder |
.kb-tree-article |
| Details | renderDetailsLayout() |
.kb-details-folder |
.kb-details-row |
List and cards are one renderer, differing only by the kb-layout-* class on the container β CSS turns the same markup into rows or a grid. Tree and details replace the whole pane because they draw folders and articles interleaved, which the shared path cannot express.
β οΈ Every return path must callkbRenderSelection(). The tick boxes come out right because each row asks thekbSelectedSet as it is built, but the row highlight is a class applied afterwards. Miss one path and changing view leaves the ticks on and the highlighting off β a half-selected list. This has been a real bug.
Every article row in every view carries data-article="<id>". Selection, keyboard navigation and range selection all work off that one attribute rather than four view-specific selectors:
function kbVisibleArticleIds() {
return Array.from(document.querySelectorAll('#articleList [data-article]'))
.map(el => Number(el.dataset.article));
}π In DOM order, not data order. The details view sorts by whichever column was clicked and the tree groups by folder, so a Shift-range built from the
articlesarray would select rows the user never saw between the two they clicked. A range means "everything between these two, as displayed".
const byName = (a, b) => String(a).localeCompare(String(b), undefined,
{ numeric: true, sensitivity: 'base' });
const walk = (parent, depth) => {
for (const f of kbFolders.filter(x => x.parent_id === parent).sort((a,b) => byName(a.name, b.name))) {
html += folderRow(f, depth);
walk(f.id, depth + 1); // β subfolders FIRST
for (const a of (byFolder[String(f.id)] || [])) html += article(a, depth + 1);
}
};
walk(null, 0);
for (const a of (byFolder['root'] || [])) html += article(a, 0); // unfiled, at the bottomnumeric: true so Step 2 precedes Step 10. Articles arrive newest-first, which suits a feed and not a tree.
Two bugs came from the same root, and both are the same shape:
(a) When Home was changed to show only top-level articles, the tree kept drawing from that fetch β a tree of folders with nothing in any of them. Only a test that asserted on the documents caught it.
(b) Clicking a folder in the tree re-fetched "articles in this folder", but kbFolders is a separate fetch that was never narrowed β so every document vanished while every folder stayed.
// A folder NEVER narrows the tree. Search and tags still do.
const treeShowsEverything = kbLayout === 'tree';
const browsingTop = activeFolder === '' && !search && tagIds.length === 0 && !treeShowsEverything;
if (browsingTop) url += 'folder=root&';
else if (activeFolder !== '' && !treeShowsEverything) url += `folder=${encodeURIComponent(activeFolder)}&`;β The tell for this whole class of bug: folders and documents come from DIFFERENT fetches. Filter one and not the other and you get a screen that looks coherent and is wrong.
grid-template-columns: 22px minmax(0,3fr) minmax(0,1.2fr) 110px minmax(0,1.5fr);The first column is the tick. The header row and the folder rows must each supply an empty <span></span> or every column below shifts by one. The harness asserts the Author heading and its column share an x-position.
One state variable, four kinds of source, four kinds of target.
let kbDrag = null; // { type: 'article' | 'folder', id }
function kbDragStart(e, type, id) {
kbDrag = { type, id };
e.currentTarget.classList.add('kb-dragging'); // fade what is in flight
try { e.dataTransfer.setData('text/plain', type + ':' + id); } catch (_) {}
e.dataTransfer.effectAllowed = 'move';
e.stopPropagation();
}The payload is set because some browsers refuse to start a drag with no data, but nothing reads it back β the handler carries the state itself.
function kbDragOver(e) {
if (!kbDrag) return;
// A folder cannot be dropped on itself. Refusing HERE rather than letting the
// drop land and the server say no means the row never lights up as a target,
// so the answer is visible before the mouse is released.
if (kbDrag.type === 'folder' && String(kbDrag.id) === e.currentTarget.dataset.folder) return;
e.preventDefault(); // β required, or no drop event fires
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('drop-target');
}if (drag.type === 'article') {
if (e.ctrlKey && folderId !== null) {
await folderAction({ action: 'add_shortcut', article_id: drag.id, folder_id: folderId }, β¦);
} else {
await folderAction({ action: 'move_article', article_id: drag.id, folder_id: folderId }, β¦);
}
} else {
if (String(drag.id) === String(folderId)) return; // the server also refuses cycles
await folderAction({ action: 'move', id: drag.id, parent_id: folderId }, β¦);
}Ctrl onto Home (folderId === null) is always a move β a shortcut needs a real folder to live in.
.drop-target is added by JS to whichever row is under the pointer. There were three near-identical CSS copies of the highlight β left panel, tree, details β and the fourth kind of row, .kb-folder-card, never got one. The class was being applied correctly the whole time; nothing painted it. Dragging gave no feedback in two views out of four, which reads as the drag not working.
.kb-folder.drop-target,
.kb-tree-folder.drop-target,
.kb-details-folder.drop-target,
.kb-folder-card.drop-target { outline: 2px solid var(--accent); outline-offset: -2px; background: var(--accent-soft); }β Copies drift, and the way they drift is that one of them never gets written. Keep it one selector list: a new row type then shows up as a missing name, not as a highlight that quietly does nothing.
β οΈ Assert on computed style, not the class. A test that checkedclassList.contains('drop-target')passed on the broken page.
kbDragEnd() clears both classes across the whole document, not just the row that started it β an abandoned drag otherwise leaves a highlight that reads as a selection.
Drive the real page in a same-origin iframe; do not unit-test the functions. Everything in Β§4 and Β§5 above was found that way and would have been missed otherwise.
| Trap | What it looks like |
|---|---|
A top-level const/let is not a window property |
w.kbSelected is undefined forever. Read selection off the DOM instead β a better assertion anyway. |
| Headless Chrome does not run CSS transitions |
.article-card has transition: all 0.2s, so a computed value stays at its start indefinitely. Inject * { transition: none !important } before measuring. |
| Headless Chrome cannot take a 360px window | Use a 360px iframe and assert body.scrollWidth === innerWidth. |
position: fixed inside a transformed ancestor |
Draws correctly, measures correctly, untappable. Only elementFromPoint catches it. |
| Driving the handler is not driving the gesture | Calling toggleShareDropdown() passed while tapping Share did nothing. Find the element, ask elementFromPoint what a tap hits, dispatch on that. (.click() does not exist on an SVG element; dispatchEvent does.) |
| State is not visibility | "The menu became active" passed while the sheet sat at top: -156px. Assert the rect intersects the viewport. |
| A probe endpoint after the fixture setup | Fetching it re-runs the DELETEs and re-INSERTs, destroying what it was asked to measure. Answer the probe before the setup. |
π Pair every "cannot" with a "can". A filter that hides everything from everybody passes every negative test ever written.
07_acl.phpnever asserts a refusal without a matching positive control.
Run: php tests/knowledge-visibility/run.php β 88 checks, dev installs only.
| Table | Purpose |
|---|---|
knowledge_folders |
parent_id, name, is_restricted, inherit_permissions
|
knowledge_acl |
object_type (folder/article), object_id, principal_type, principal_id β no allow/deny column
|
knowledge_shortcuts |
article_id, folder_id β a pointer with no permissions of its own |
knowledge_user_groups / _members
|
ad-hoc groups, membership carries expires_at
|
knowledge_audit |
object_type, object_id, action, analyst_id, detail (JSON) |
knowledge_articles also gains folder_id, is_restricted and inherit_permissions.
β¬ Known gap:
knowledge_user_groupsis written by nothing. The ACL acceptsuser_groupprincipals andsearch_principalsfinds them, but there is no screen that creates a group.knowledgeAuditHistory()likewise has no caller β the audit trail is recorded and never displayed.
- Knowledge β folders and security β the user-facing page
- Folders and permissions β design reasoning, and what was ruled out
- Knowledge β the module overall
- Mobile: Knowledge β the phone layer
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
- π Date & Time Formats
- Theming & Dark Mode
- β¨οΈ Command palette (βK)
- π Searching inside tickets
- π Attached documents
-
MobileβFriendly
- β³ π« Mobile: Tickets
- β³ π» Mobile: Assets
- β³ π Mobile: Calendar
- β³ π Mobile: Knowledge
- β³ π¦ Mobile: Service Status
- β³ πΌ Mobile: Watchtower
- β³ π§© Mobile: Problem Management
- β³ π Mobile: Change Management
- β³ πΏ Mobile: Software
- β³ β Mobile: Tasks
- β³ π§° Mobile: Techniques & Tricks
-
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
- β³ π Ticket notes: internal or shared
- β³ ποΈ Canned responses
- β³ βοΈ Limiting replies to particular senders
- β³ βοΈ Email signatures
- β³ π The public web address
- β³ π’ Ticket numbering
- β³ π Raising a ticket for someone else
- β³ π Merging tickets
- β³ β Splitting tickets
- β³ β Selecting several tickets
- β³ ποΈ The folder pane
- β³ π οΈ Snoozing tickets β Developer Guide
- β³ π₯ Collision detection
- β³ β±οΈ Time tracking
- β³ π Scheduled work in your own calendar
- 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)