Skip to content

Knowledge Folders and Security Developer Guide

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

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.


1. πŸ“ The files involved

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

⚠️ Editing knowledge.js or knowledge.css means bumping ?v=NN in knowledge/index.php β€” and verify it on the served page, not by grepping the source. A full-file rewrite has silently reverted a sed bump more than once here, and the symptom is a change that appears not to have been made.


2. βš™οΈ The choke point

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.

The viewer is a value, and it has named constructors only

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 unrestricted

There 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.

knowledgeCanRead() is built ON the list clause

function knowledgeCanRead(PDO $conn, KnowledgeViewer $viewer, $articleId, array $opts = []): bool

It 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.

The three axes

Axis Meaning
tenant_id ⚠️ NULL = shared with EVERY company here β€” the opposite of tickets and assets
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.php asserts exactly this: a portal user granted on an internal article still cannot read it, paired with a positive control proving a granted analyst can.


3. πŸ”’ How the ACL clause is built

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 resolution

πŸ”΄ The bug worth knowing about (#1217)

The 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.

Polarity lives on the object, not the rows

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.

The administrator floor

Cap::KNOWLEDGE_MANAGE always passes, and every use is recorded:

knowledgeAuditAdminOverride($conn, $viewer, $articleId);   // action = 'admin_override'

⚠️ An ordinary read by an administrator is not logged as an override β€” only a read that the access list would otherwise have refused. 07_acl.php asserts both halves.


4. πŸ–₯️ The four renderers

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 call kbRenderSelection(). The tick boxes come out right because each row asks the kbSelected Set 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.

data-article is the unifying handle

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 articles array would select rows the user never saw between the two they clicked. A range means "everything between these two, as displayed".

The tree: subfolders before documents, both by name

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 bottom

numeric: true so Step 2 precedes Step 10. Articles arrive newest-first, which suits a feed and not a tree.

πŸ”΄ The tree draws its whole shape from ONE fetch

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.

The details view is a grid, so every row type owes a cell

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.


5. 🎯 Drag and drop

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.

Refusing the impossible drop before it happens

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');
}

Ctrl means shortcut, matching Explorer

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.

πŸ”΄ One rule for the highlight, not four

.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 checked classList.contains('drop-target') passed on the broken page.

Cleaning up

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.


6. πŸ§ͺ Testing this

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.php never asserts a refusal without a matching positive control.

Run: php tests/knowledge-visibility/run.php β€” 88 checks, dev installs only.


7. Schema

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.

⚠️ A shortcut must be filtered by the target's readability at list time, or the title leaks to somebody who cannot open it.

⬜ Known gap: knowledge_user_groups is written by nothing. The ACL accepts user_group principals and search_principals finds them, but there is no screen that creates a group. knowledgeAuditHistory() likewise has no caller β€” the audit trail is recorded and never displayed.


Related pages

FreeITSM

Getting Started

Modules

Multi-tenancy (planned)

Blue sky thinking

Bugs resolved

Links

Clone this wiki locally