-
Notifications
You must be signed in to change notification settings - Fork 17
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.
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.
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.
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.
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.
| 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
- Drag and drop to move; right-click for move / rename / permissions / new folder.
Three view modes (details, explorer, tree) are deliberately not built. That is three renderers to build, test, theme and mobile-proof, in a knowledge.js that is already 61 KB. A tree plus a list is the explorer view.
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.
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.
- 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.
-
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.
- 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.
- Three view modes β three renderers for one module (Β§10).
-
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.
Done. Steps 1β3 of Β§12.
-
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.
-
The schema, in both
database/freeitsm.sqlandincludes/db_verify_schema.php. - The engine: polarity, inheritance, both permission models, four principal types, group expiry, the administrator floor and its audit row.
-
tests/knowledge-visibility/07_acl.phpβ 24 checks, every refusal paired with a positive control.
Not done. No interface. Nothing in the product creates a folder, files a document into one, or edits an access list, so the feature is inert on a real install: with no access rows the resolver's fast path returns an empty clause and every query is byte-identical to before.
Still outstanding: the tree and list, the properties/security panel, drag-and-drop, right-click, shortcuts in the UI, the exception badge and report from Β§9, mobile, and the knowledge_folder_permission_model setting screen (the setting is read, but only an admin editing system_settings by hand can currently change it).
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.
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.
- 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
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)