Skip to content

Knowledge access control

Every question of the form "what may this caller see" in the knowledge stack is answered against a stated identity. There is no way to reach the stack without stating one. This document says what the identity types mean, which layer takes which type, and what happens when there isn't one.

The three identities

Principal is a closed set of three types, defined in campuscore_app/apps/main_app/services/access/principal.py. A function that accepts a Principal accepts exactly these and nothing else.

EndUser is a signed-in person. It is a frozen dataclass carrying their user_id, their active role slugs, and the operator flag (is_superuser). The role slugs are resolved once, when the EndUser is built, so it is a self-contained value: passing it around never re-queries the database.

Anonymous is a caller with no session - the public chat surface's identity (see public-access.md). It sees exactly the explicitly published tiers: folders with public visibility, and campus chunks whose scrape source is flagged publicly visible. A workspace-visibility folder, unpublished campus content, and everything scoped or private are invisible to it.

System is the application acting on its own authority: ingest, a backfill, an admin evaluation. It reads without per-user restriction, and it must state a reason. The reason is not decoration - it is what makes every unrestricted read greppable, so there is no unnamed "no restriction" state anywhere in the codebase.

The single conversion, and it raises

end_user_from_user(user) (and its async twin aend_user_from_user) is the only way to turn a Django user object into an identity. When there is no signed-in user - None, an AnonymousUser, or anything reporting is_authenticated = False - it raises EndUserRequired.

Nothing catches EndUserRequired specifically, on purpose. Every caller sits behind an endpoint that already rejects unauthenticated requests: chat_stream is session-authenticated and returns 401, the document endpoints pass request.user from a session-authenticated route, and the offline evaluation scripts load a real account before they start. So if it fires, our wiring is wrong, and we want that loud. On the document endpoints it surfaces as a 500. On a chat turn it lands in ChatService.generate_response's existing catch-all, which logs it at ERROR and sends the client a sanitized error frame - still loud and traceable, but not a 500. The alternative, which this replaced, was a function that quietly returned Anonymous and let the caller carry on with a silently narrowed - or in the sibling function, silently widened - view.

A caller that genuinely means "no session" constructs Anonymous() by name. A caller that genuinely reads everything constructs System(reason=...) by name. Neither is something a missing argument can produce by accident.

Which layer takes which type

The rule: a function that makes an access decision takes the identity type it needs; a function that only carries an identity through keeps the Django user object, typed and required.

Three tiers follow from that.

Takes a Principal, because all three kinds are legitimate answers:

  • KnowledgePolicy, the single authority
  • the two chunk-store adapters, apply_chunk_access_control and accessible_source_file_ids
  • SearchScope, and the four DocumentChunkVectorStore search methods that read it
  • RetrievalService.retrieve
  • the three KnowledgeFolderService access helpers

Takes an EndUser | Anonymous: every agent tool that reads the knowledge layer. create_default_registry stays EndUser (the signed-in toolset); create_guest_registry binds the guest toolset to an Anonymous. System is deliberately excluded from tools - an agent run never acts on the application's own authority.

Keeps the Django user object: ChatService.generate_response, and AgentCore as a constructor argument. The connector ActionContext.for_request(user=...) needs the ORM row to find that person's stored credentials, which an EndUser value object does not carry.

AgentCore therefore holds both: the EndUser its tools were built with, and the Django user its connector context needs. Nothing else would keep those in agreement, so its constructor raises ValueError when user.id is not the principal's user_id. run() deliberately takes no user argument. A per-run user would be a second identity channel that the constructor's check cannot see - the same hazard one layer up.

Tools are bound at construction

An agent tool that reads the knowledge layer declares principal: EndUser | Anonymous as a required pydantic field with no default. A tool built without one cannot exist.

That replaced three separate pieces of code in agent_core.py that each rebound tools after construction and each tested differently (if hasattr(t, 'with_user') and user, if user is not None and hasattr(t, "with_user"), and if user:). A tool that slipped past all three kept its class default of user = None, and what that then meant differed per tool: most read the whole corpus, search_attachments additionally dropped its uploader filter so one conversation's attachments were visible to anyone in that conversation, and list_folder_files fell back to workspace folders only.

create_default_registry(principal) now builds the whole toolset already bound, and there is no rebinding step to forget. This is the same mechanism cites_sources uses on BaseTool: a required field with no default means a new tool that does not declare cannot be instantiated, so the registry fails to build and every agent test fails.

apps/main_app/tests/test_tool_principal_contract.py pins it, including that each copy-builder threads the field through - one of them, with_run_state, runs once per agent iteration on the live path.

apps/main_app/tests/test_cross_user_tool_isolation.py pins the property the whole thing exists for: a second signed-in person reaches nothing in the first person's private folder through search, read_file, find_entity, describe_knowledge or list_folder_files.

Three entity tools - ResolveEntityTool, ListEntitiesTool, GetEntityTool - deliberately hold no principal. They are not registered with the agent and cannot be called by it. FindEntityTool constructs them internally, is their only caller, and filters everything they return through _accessible_doc_ids on all three of its paths before anything leaves the tool. Each carries a comment saying so. Registering one of them, or adding a second caller, means giving it a principal first.

Where a file's scope comes from

SourceFile.access_scope is stamped by the code path that creates the row, never derived afterwards. There are four scopes, each carried by its own concrete key: folder (knowledge_folder - access follows the folder's visibility), campus (public institution knowledge; web_page is optional provenance, not an access input), conversation (owner plus conversation - private to the uploader), and connector (no keys; access flows from each reader's own ConversationAttachment link to the shared copy). The stamping sites are folder_file_registrar.reserve (folder), ContentPersistence.save_raw_content (campus), ChatAttachmentService.generate_presigned_url (conversation), and the connector ingest path (connector).

The column is NOT NULL and one database check constraint per scope makes any cross-scope field combination a write-time IntegrityError - a folder file cannot carry an owner, a campus file cannot carry a folder, and so on (test_sourcefile_scope_constraints.py pins each combination). The one deliberate slack is that a conversation-scoped file's conversation FK stays nullable: a chat upload made before the first message has an owner but no conversation until link_pending_attachments_to_conversation backfills it. SourceFile.owner cascades with the user - a deleted user's conversation-scoped files, readable by no one else by definition, are deleted with them.

The chunk copy

Every DocumentChunk carries its parent file's access_scope, knowledge_folder_id and scraper_configuration_id, because the retrieval permission filter runs on the chunk table and must not join to SourceFile on the hot path. The invariant is that those three columns always equal the parent's, and the database is what holds it.

Two triggers, installed by migration 0145, own the copy. main_app_documentchunk_copy_access fires before an insert, and before any update naming one of the three columns, and takes all three from the parent row whatever the caller sent. main_app_sourcefile_propagate_access fires after an update that actually changes one of the parent's three columns and writes the new values onto that file's chunks in the same transaction. So re-pointing a file at another folder moves its chunks with it, and the next query answers differently, with no re-ingest.

Python may pre-fill the three columns - DocumentWriter._build_one_chunk_row does, so the instances it returns match the rows it wrote - but it never owns them. A chunk with no parent_file has no parent to copy from and keeps whatever the caller set; the documentchunk_scope_is_a_known_member check constraint still holds its scope to the four members or NULL.

One interaction is worth stating. Deleting a ScraperConfiguration makes Django's collector null DocumentChunk.scraper_configuration first, and the copy trigger puts the doomed id straight back from the parent. The transaction still ends consistent because the collector also nulls SourceFile.scraper_configuration, and the propagate trigger then clears the chunks. The chunk-side SET_NULL is a dead safeguard; the parent-side one is what does the work.

Where an entity's provenance comes from

An entity is not stored with a file of its own. EntityDocumentSource holds one row per (entity, contributing file), and those rows are the only record of which files an entity was assembled from. DocumentWriter writes the first one with the entity and marks it is_primary; each later merge adds a non-primary row; EntityReconciler carries a loser's rows to the survivor before deleting it.

Two questions are asked of that list, and they get different answers on purpose.

Access and per-run scope ask "may this principal see the entity?" and read any link. KnowledgePolicy.filter_entities is an EXISTS over the link table with the same source_file_q predicate filter_source_files uses, so a file and an entity answer "may you read this file's content?" identically. An entity assembled from a private file and a workspace file is visible to anyone who can read either - overlap-ANY, decision C-1. The agent tools' _scope_source_file_ids follows the same rule through ScopedToolMixin.entity_scope_q.

Classification and display ask "what is this entity's home file?" and read the primary link only. That is the attachment-cleanup selector, the prompt inventory's attachment exclusion, and the per-file title borrowing in read_file / list_folder_files / get_attachments. The difference matters where the two would disagree: an institutional entity a chat upload later merged into keeps its place in the durable inventory, because its home file is still a corpus file.

Both shapes have one home, EntityDocumentSource.any_link_from and .primary_link_from. Both are EXISTS subqueries and never reverse-relation joins, because a join through sources returns the entity once per matching link and silently doubles a multi-source entity in listings and counts.

EntityDocumentSource is deliberately not in check_governed_reads.py's GOVERNED set, and that is a real blind spot rather than a claim that the table is harmless. A read that starts at the link table can reach entity content without naming EntityDocument - list_folder_files and get_attachments both pull document__summary through the join for their title maps - and the gate, which matches on <Model>.objects.<read>, cannot see it. The discipline that stands in for the gate here: a link-table read must start from a file id list that is already access-filtered. Both title maps do, and so does every other reader; the entity's summary is readable by anyone who can read any of its files, and the file that reaches it has already been through for_principal.

An entity exists exactly as long as some file contributes to it. DocumentWriter.detach_source_files deletes it when its last link goes, on every deletion path the application controls - folder and file deletes, workspace deletes, chat-attachment removal, and the folder-upload supersede. The database cascade can still orphan one when a SourceFile is removed behind the application's back; the daily reap_orphaned_entities janitor deletes those, dry-run by default.

The Postgres floor is built but not enabled

campuscore_app/apps/main_app/services/access/rls.py holds row-level-security policy SQL that mirrors KnowledgePolicy, proven equal to it by test_rls_floor_spike.py. It is not enabled on any deployment, and this work did not change that.

It is a no-op today regardless: the application connects as a Postgres superuser, and superusers bypass row-level security entirely. Making it real needs a separate non-superuser database role per client environment, every request wrapped in a database transaction so the policy can read the caller's identity from session settings, and an install migration with no deny-everything window.

Whether we want the feature at all is issue #111. Until that is decided, the Python layer is the whole enforcement story, and it is the one to change when the rules change.

Where to look

Question File
What are the identity types, and how is one built? campuscore_app/apps/main_app/services/access/principal.py
What is the actual access rule? campuscore_app/apps/main_app/services/access/policy.py
Which files is an entity assembled from? campuscore_app/apps/main_app/models/entity_document_source.py
How does a search apply it? campuscore_app/apps/main_app/services/vector_stores/document_chunk_store.py
What keeps a chunk's access columns equal to its file's? campuscore_app/apps/main_app/migrations/0143_documentchunk_access_copy_triggers_drop_is_public.py, pinned by campuscore_app/apps/main_app/tests/test_chunk_access_copy_contract.py
How do the agent's tools get their identity? campuscore_app/apps/main_app/services/agent/tools/base.py
Can a tool exist without one? campuscore_app/apps/main_app/tests/test_tool_principal_contract.py
Is one person's folder really invisible to another? campuscore_app/apps/main_app/tests/test_cross_user_tool_isolation.py