Knowledge Folders and Permissions - edmozley/freeitsm GitHub Wiki
Knowledge โ folders and permissions
Status: PART BUILT. The reader, the schema and the permission engine exist and are tested; there is no interface yet, so nothing in the product can create a folder or an access row and the feature ships inert. ยง15 records what is done, what is not, and where the build deviated from this page.
Today the Knowledge module can restrict an article two ways: which company owns it (tenant_id) and how much we trust the reader (audience). It cannot say "only these people." This page designs that third axis, the folder tree it hangs off, and the audit trail that explains it.
The other pages: Knowledge (what it does), Knowledge Assistant (the AI side), REST API โ Knowledge.
1. The rule
Every axis narrows. Nothing widens.
An article is readable only if all of these agree: the company owns it or it is shared, the reader's trust level reaches its audience rung, and the access list does not exclude them. Adding a permission can never make an article visible to someone the audience ladder excludes.
Everything below is a consequence of that sentence. In particular it means there is no precedence puzzle to solve: the axes are ANDed, never weighed against each other.
2. The finding that shapes the whole project
There is no choke point. Roughly 48 raw SELECT ... FROM knowledge_articles statements across ~30 files read the table directly. includes/services/knowledge.php exists but handles only single-article-by-id operations; it does not own listing, and most callers bypass it.
Compare Tasks (#1175), where scoping was won by gating two functions โ loadTaskRow and apiLoadTask โ and stopping. Here there is nothing to gate.
So the project is not "add permissions". It is:
- Build the reader everything must go through.
- Move ~30 files onto it.
- Add folders and access lists behind it.
Steps 1 and 2 are the work. The folder tree and the drag-and-drop are the cheap half. Any plan that starts at step 3 ships a disclosure.
The six back doors
A permission check on the document view catches none of these. Each reads article bodies without anybody opening a document:
| Path | What leaks |
|---|---|
api/system/global_search.php, includes/search/indexer.php |
the snippet discloses content before anyone clicks |
includes/knowledge/kb_ai.php, api/knowledge/ai_chat.php |
the assistant reads restricted articles and paraphrases them into an answer |
includes/knowledge/gap_analysis.php |
gap clusters cite articles by title |
api/lms/knowledge_articles.php, api/lms/ai_author.php |
LMS pulls KB content into course material |
includes/warbot/tools.php |
War Room queries the KB |
api/v1/resources/knowledge.php |
the REST API |
The AI path is the sharp one. Restrict the HR disciplinary procedure to the HR team, and then the assistant summarises it for a first-line analyst โ a worse disclosure than handing over the document, because nothing records that it happened. The filter must be applied at retrieval, before the model sees the text, never at display.
The search path has the same shape and a known answer, already written down for attached documents: the query carries the permission controls; results are not filtered afterwards. Post-filtering starves result counts and fails worst for the readers who can see least.
The REST API is nearly free
api_keys.analyst_id is NOT NULL, and api/v1/lib/auth.php opens with "Every key acts as an analyst so that audit rows...". A key therefore already has a human identity and inherits that analyst's access with no new principal model โ provided it goes through the choke point like everything else.
3. Three axes, and how they compose
| Axis | Question | Values | Note |
|---|---|---|---|
tenant_id |
Whose article is it? | a company, or NULL |
โ ๏ธ NULL means shared with every company here โ the opposite of tickets and assets |
audience |
How much do we trust the reader? | internal / customer / public | An ordered ladder. See includes/knowledge/audience.php |
| access list | Which named people? | new | This page |
The audience header explains why a ladder was chosen over three checkboxes: "a ladder cannot express a contradiction." An access list can, so the rule in ยง1 exists to stop it: the access list only ever narrows within the rung. An article marked internal with a grant naming a portal user is still invisible to that user.
That has an interface consequence, not just a code one โ see OPEN 3.
4. Folders
One parent, always
A document lives in exactly one folder. This is the load-bearing decision and everything downstream depends on it.
The alternative โ a document in several folders โ makes "inherit from parent" unanswerable. Which parent? Most-permissive wins and it leaks; most-restrictive wins and people lose documents they filed themselves. Every system that tried it ended up with an effective-permissions dialog nobody could read.
Folders and tags stay complementary and both keep their job: a folder is where a document lives, a tag is what it is about.
Shortcuts
A shortcut is a pointer with no permissions of its own. It resolves to the target, and the target's access list decides. This is what preserves the single-parent tree while still allowing a document to appear in two places.
Two rules make it safe:
- A shortcut never grants. If you cannot read the target, the shortcut is not a way in.
- Shortcuts are filtered by target readability at list time โ otherwise the row renders the target's title, which is the search-snippet leak wearing a different hat.
On upgrade
Every existing article lands in a root folder, inheritance on, unrestricted. Zero migration, and the same trick audience used: the default is the state the install was already in.
5. Open and Restricted
A folder or document is one of two things, and the exception list is always the opposite polarity:
| Mode | Default | Exceptions you may add |
|---|---|---|
| Open | everyone (subject to ยง1) | denies |
| Restricted | nobody | grants |
This is better than the NTFS model it resembles, for one reason: an access list cannot contradict itself. Allow and deny entries never coexist on the same object, so there is no precedence rule to remember and no dialog explaining which one won. It is the same instinct as the audience ladder โ make the contradiction inexpressible rather than adjudicating it.
Overlapping groups
Alice is in Engineers (denied) and Leads (not).
- On an Open object: any matching deny wins, absolutely.
- On a Restricted object: any matching grant wins (additive).
Different directions, each unambiguous, because only one polarity is ever present. Both fail safe.
Flipping polarity clears the list
An Open folder with six denies, switched to Restricted, now holds six entries of the wrong polarity.
Do not keep them dormant. An invisible entry that springs back to life when someone flips the mode again is the "an unloaded checkbox looks exactly like OFF" failure in a new costume. Flipping wipes the list, behind a confirmation that names the count.
The administrator floor
Someone will create a Restricted folder, grant one person, and that person will leave. Without a floor the folder is unrecoverable.
A knowledge.admin capability always passes, always โ and every such pass writes an audit row. That is what the audit table is for.
6. Inheritance, and the one admin setting
Both folders and documents carry an inherit from parent tickbox. Clearing it starts a new set of permissions at that node. Walking up stops at the first node that does not inherit.
The question that remains is what happens when an Open document sits inside a Restricted folder, and there is no universally right answer โ so it is an install setting, knowledge_folder_permission_model, in Knowledge โ Settings:
| Setting | Meaning |
|---|---|
| Folders are containers (default) | You must be able to read every ancestor. A locked cabinet is locked. |
| Folders are filing | The document's own list is authoritative; folders only organise. |
Deliberately not called strict/loose: that framing reads as secure/insecure, so nobody chooses it on the merits. Both are defensible philosophies and an admin should pick one on purpose.
Containers is the default because every default in this module leans safe โ audience defaults to internal precisely so an upgrade can never start disclosing. It also composes with shortcuts: "this must be visible to everyone but it lives in HR" is not a permissions fight, it is a document in an open folder with a shortcut into HR. Permissions follow the real home.
Three constraints on the setting:
- Install-wide only. Not per-folder โ mixed modes in one tree make "can Bob read this?" depend on which mode each ancestor is in, which is unanswerable in a support call. Not per-company either: articles with
tenant_id IS NULLare shared, so a per-company posture would make the same document strict for one client and loose for another. - Flipping it shows its blast radius first โ "47 documents become readable by people who cannot read their folder" โ computed, confirmed, and audited. It is the highest-privilege action in the module and it changes live access with no per-document change to point at.
- It ships with the reader's first version. It is a branch inside the resolve function, not a layer on top; retrofitting means revisiting every caller a second time.
7. Principals
An access entry names one of four things:
| Principal | Source | Note |
|---|---|---|
| Analyst | analysts |
|
| Team | teams |
already exists, already used for module and company access |
| Portal user | users |
|
| User group | new table |
User groups are new, not a reuse of lms_learning_groups. The driving case is ad hoc and short-lived โ three engineers on site for a week need access to one folder โ and routing that through the LMS to grant a document permission would be daft. users has no grouping of any kind today, so a table is needed regardless.
Because the case is explicitly temporary, group membership carries an optional expires_at. "For the week" is the requirement as stated, and an access list that quietly stays open after the engineers leave is the failure mode worth designing out on day one.
8. Audit
document_access_log is the pattern to copy โ it already exists and does exactly this job for attached documents.
One caveat that matters at scale: views are a different volume class from edits. view_count already increments on every read (api/knowledge/knowledge_article.php), and a row per view on a busy KB is millions of rows a year.
- Creates, edits, permission changes, deletes, administrator-floor passes โ keep indefinitely. They are rare, and they are what someone actually comes looking for.
- Views โ sample, dedupe per user per day, or hold to a retention window.
Do not let view spam bury the permission-change rows. Those are the reason the table exists.
9. How far to take it: exceptions must be visible and rare
Document-level permissions are the part of this design most likely to age badly, and it is worth being explicit about why โ the risk is not where it looks.
The code cost of document-level over folder-level is close to nil. The access list is polymorphic either way, and the resolver walks the same tree. If folders with inheritance exist, per-document permissions are a tickbox that is already built.
The operational cost is the real one. Broken inheritance is the single thing that makes large permission systems unmanageable in practice โ not because the feature is wrong, but because a document whose permissions differ from its folder is invisible from the tree. You cannot look at a folder and know what is true inside it, and that is what generates "why can't I see this?" indefinitely.
So the answer is not to drop it. Document-level permissions are in scope โ the decision taken is that the module gets them, with the administrative tooling that keeps them manageable following behind.
What that tooling has to prevent is precisely stated: an administrator marking something Restricted and losing it, because nobody โ including its author โ can see it any more.
Three safety nets do that, and they do not all cost the same:
| Net | Cost | When |
|---|---|---|
The administrator floor โ knowledge.admin always passes (ยง5) |
already in the design | with the feature |
| Refusing a Restricted object with no living grant | a validation rule; minutes | with the feature |
| Badge in the list + a "documents with their own permissions" report | ~a day | in due course |
The first two are worth pulling forward, because together they mean a document can be hidden but never orphaned. The floor guarantees recovery; the validation stops the most common way a document goes dark in the first place โ saving Restricted with an empty grant list, which is silent, instant, and looks exactly like it worked.
The third genuinely can wait. It solves a different problem: not recovering an exception but noticing one. A document whose permissions differ from its folder is invisible from the tree, and at ten exceptions that is fine, at four hundred it is not. Build it when the count starts to matter.
The scope note this replaces
An earlier draft recommended shipping folder-level only and letting demand pull document-level through. That is no longer the plan, but the reasoning behind it still holds and is worth keeping: the choke point and the resolver are identical either way, so the extra surface is genuinely additive, not a fork in the architecture. Nothing here is rework if the scope moves again.
The genuine risk in this whole project remains a permission system that half works, because people trust it. That is an argument for the two cheap nets above, not against the feature.
10. Interface
One tree, one list โ the Explorer shape, because that is what Explorer actually is:
- Left panel gains a folder tree beside the existing search and tag sections.
- Main pane lists the selected folder's contents.
- Properties / Security panel as a second tab on the left panel, in the iManage idiom. > ๐ด NOT BUILT AS DESCRIBED. Permissions ended up in a modal opened from the right-click menu and from the article itself, not as a second tab on the left panel. The modal came first because it is reachable from every view and from the article, where the left panel is not always on screen; the panel remains an open question.
- Drag and drop to move; right-click for move / rename / permissions / new folder.
๐ด SUPERSEDED โ all four view modes were built. This paragraph originally argued that three extra renderers were not worth it, and that a tree plus a list is the explorer view. Ed asked for them one at a time โ first the Explorer-style main pane, then cards, then the tree in the main pane, then the details table โ and the cost estimate was wrong: list and cards turned out to be ONE renderer differing only by a class on the container, so it was two new renderers, not three.
renderTreeLayout()andrenderDetailsLayout()replace the whole pane; list and cards share the original path. See the Developer Guide ยง4.
Two prerequisites, both currently unmet:
knowledge.jsdoes not usesafe-html.jsand has ~25 rawinnerHTMLcalls. That is a todo today; it becomes a blocker the moment folder names are user-authored and rendered into a tree.- Mobile is a second interface, not a free one. A tree with drag-and-drop and right-click is desktop-only. The phone gets a drill-down list plus the sticky action row already used in the ticket detail (
mobile.css), carrying move / edit / permissions as icons. โ Built โ the article action bar ismobile.csslayer 17k, carrying Share / Move / Permissions / Edit / Archive as icons pinned to the bottom.
11. Schema (proposed)
Not yet in database/freeitsm.sql or api/system/db_verify.php โ this is the design, and both must be updated in the same commit as the build.
| Table | Purpose |
|---|---|
knowledge_folders |
id, parent_id (NULL = root), name, is_restricted, inherit_permissions, owner_id, tenant_id, created/modified |
knowledge_articles.folder_id |
new column; NULL = root during migration |
knowledge_articles.is_restricted, .inherit_permissions |
the document's own polarity |
knowledge_acl |
object_type (folder | article), object_id, principal_type (analyst | team | user | user_group), principal_id โ polarity comes from the object, never the row |
knowledge_shortcuts |
folder_id, article_id, created_by โ no permission columns, by design |
knowledge_user_groups, knowledge_user_group_members |
members carry optional expires_at |
knowledge_audit |
document_access_log shape, plus action and a JSON detail column for permission changes |
knowledge_acl holds no allow/deny column. That absence is the ยง5 guarantee: the polarity lives on the object, so a contradictory pair cannot be stored.
12. Build order
- The reader. One resolve function, one SQL clause builder. Both permission models behind it from the start.
- Move the ~30 files onto it, including all six back doors. Extend
tests/knowledge-visibility/โ five test files already exist from the tenancy and audience work. - Folders + tree + inheritance, folder-level and document-level access, analysts and teams. Ships with the administrator floor and the empty-grant validation from ยง9 โ those two are not deferrable.
- User groups with
expires_at, and portal-user principals. - Administrative tooling โ the exception badge and the "documents with their own permissions" report.
- Explorer polish โ properties panel, right-click, drag and drop.
- Mobile drill-down and action row.
The permission engine wants to land on its own. Steps 1 and 2 change no visible behaviour if done correctly, which is exactly why they are separable โ and exactly why they need tests rather than a demo.
13. Open decisions
- Naming collision.
audience = 'public'(anonymous web-chat visitors) and an Open folder are two meanings of one word on the same screen. This page uses Open / Restricted for the access list and keeps public for the audience rung. Cheap to settle now, expensive after it reaches 24 locales. - Do folders carry an audience of their own, or only documents?
- Granting a portal user on an
internalarticle. Under ยง1 it does nothing. Silent no-ops are indefensible, so the editor must warn at the point of the grant โ but the wording, and whether it offers to raise the rung instead, is undecided. - View-audit retention โ sampled, deduped daily, or windowed.
14. Ruled out, and why
- A document in several folders โ makes inheritance unanswerable (ยง4). Shortcuts cover the need without the ambiguity.
- Allow and deny on the same object โ the NTFS model. Reintroduces a precedence rule and an effective-permissions dialog nobody can read.
- Per-folder permission models โ mixed modes make the tree unanswerable.
- Reusing
lms_learning_groupsโ wrong concept, wrong lifecycle, and the ad hoc case is the requirement (ยง7). - Filtering AI and search results after the fact โ post-filtering starves results, fails worst for the least-privileged reader, and in the AI case does not work at all: by then the model has already read the text.
15. Build status
Shipped, #1209โ#1253.
This page is the design record and it has been overtaken in places; where it has, the paragraph says so inline rather than being quietly rewritten, because the reasoning that turned out to be wrong is worth keeping. For what the product actually does now, see Knowledge โ folders and security and its Developer Guide.
The engine
includes/knowledge/visibility.phpโ the choke point.KnowledgeViewer(named constructors only),knowledgeVisibilitySql()for lists,knowledgeCanRead()for one id, built on the same clause so the two cannot drift.- Every reader migrated onto it, including all six back doors. Search's indexer and backfill are deliberately not migrated and are commented saying why โ they must index everything, because the corpus is filtered at query time.
includes/knowledge/audit.phpโ the one writer ofknowledge_audit. Four files had each grown a private copy of the INSERT.- The schema in both
database/freeitsm.sqlandincludes/db_verify_schema.php. - Polarity, inheritance, both permission models, four principal types, group expiry, the administrator floor and its audit row.
The interface
Folder tree in the left panel; four view modes in the main pane (see ยง10, superseded); drag-and-drop with Ctrl for shortcuts; right-click menus; breadcrumbs and folder addresses in the URL; the permission exceptions report; the containers-vs-filing settings screen with a blast-radius preview; multi-select with ranges and keyboard; bulk move; and the mobile action bar.
๐ด The bug worth remembering: #1217
The ACL fast path asked "are there any access rows?" and skipped the join if not. A folder marked Restricted with an empty list is the tightest possible setting, and it has no rows โ so the guard was off exactly where it mattered most. It survived 24 green checks because every fixture had rows.
โญ A fast path keyed on "is anything configured?" is wrong wherever the empty configuration is the strict one.
โฌ Still outstanding
- User groups have no screen.
knowledge_user_groupsand itsexpires_atmembership exist, the ACL acceptsuser_groupprincipals, andsearch_principalsfinds them โ but nothing creates a group. The ad hoc case from ยง7, which was the requirement, is therefore not yet reachable. - The audit trail is recorded and never shown.
knowledgeAuditHistory()has no caller. - The properties/security panel on the left, as against the modal that was built (ยง10).
- Folders do not carry their own audience (ยง13).
- i18n โ the module's new English strings are not yet translated.
Two deviations from this page, both deliberate
The administrator floor is Cap::KNOWLEDGE_MANAGE, not a new knowledge.admin. It already exists as the module's umbrella capability and its holders already administer Knowledge; a new capability would mean an RBAC seed plus a settings screen for no additional safety. Easy to split out later if it earns its own.
Folder reachability is resolved in PHP, not with WITH RECURSIVE. ยง11 anticipated a recursive CTE, and MySQL 8 does support one โ but the visibility clause is a fragment appended to somebody else's WHERE, and a WITH cannot be introduced there at all. The alternative, a correlated recursive subquery, re-walks the tree once per row. Folders number tens to hundreds, so one pass in PHP is both cheaper and legible. The same reasoning is already recorded in global_search.php for its "two queries, merged in PHP โ not a UNION".
The MySQL 8 finding still stands and is still useful: it is simply not needed here.
One property worth keeping true
Inheritance needs no pass of its own. A node that inherits has no rules and therefore permits trivially, so the nearest non-inheriting ancestor is the only one that can object โ which is why "containers" is just walk to the root and "filing" is stop at the first node that speaks.
Related pages
- Knowledge ยท Knowledge Assistant ยท REST API โ Knowledge
- Attached documents โ permissions and search โ the same problem solved for a different object; the "query carries the controls" rule comes from there
- Admin access control โ teams, capabilities and the
knowledge.adminfloor