diff --git a/.gitignore b/.gitignore
index f4e4419320..c5fca46779 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ aws_cf_deploy_anything_llm.json
yarn.lock
*.bak
.idea
+docker-compose.override.yml
diff --git a/README.md b/README.md
index e109b6a0eb..0219a0e1ce 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@ AnythingLLM divides your documents into objects called `workspaces`. A Workspace
- 🆕 [**No-code AI Agent builder**](https://docs.anythingllm.com/agent-flows/overview)
- 🖼️ **Multi-modal support (both closed and open-source LLMs!)**
- [**Custom AI Agents**](https://docs.anythingllm.com/agent/custom/introduction)
-- 👤 Multi-user instance support and permissioning _Docker version only_
+- 👤 Multi-user instance support with granular permissions (including document upload control) _Docker version only_
- 🦾 Agents inside your workspace (browse the web, etc)
- 💬 [Custom Embeddable Chat widget for your website](https://github.com/Mintplex-Labs/anythingllm-embed/blob/main/README.md) _Docker version only_
- 📖 Multiple document type support (PDF, TXT, DOCX, etc)
diff --git a/docker/docker-compose.override.yml b/docker/docker-compose.override.yml
new file mode 100644
index 0000000000..20f70f457d
--- /dev/null
+++ b/docker/docker-compose.override.yml
@@ -0,0 +1,4 @@
+services:
+ anything-llm:
+ environment:
+ - MULTI_USER_MODE=true
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
index 32999e11c6..2fdf399d09 100644
--- a/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
+++ b/frontend/src/components/WorkspaceChat/ChatContainer/DnDWrapper/index.jsx
@@ -110,8 +110,9 @@ export function DnDFileUploaderProvider({ workspace, children }) {
type: "attachment",
});
} else {
- // If the user is a default user, we do not want to allow them to upload files.
- if (!!user && user.role === "default") continue;
+ // If the user is a default user without upload permission, we do not want to allow them to upload files.
+ if (!!user && user.role === "default" && !user.canUploadDocuments)
+ continue;
newAccepted.push({
uid: v4(),
file,
@@ -147,8 +148,9 @@ export function DnDFileUploaderProvider({ workspace, children }) {
type: "attachment",
});
} else {
- // If the user is a default user, we do not want to allow them to upload files.
- if (!!user && user.role === "default") continue;
+ // If the user is a default user without upload permission, we do not want to allow them to upload files.
+ if (!!user && user.role === "default" && !user.canUploadDocuments)
+ continue;
newAccepted.push({
uid: v4(),
file,
@@ -220,7 +222,8 @@ export default function DnDFileUploaderWrapper({ children }) {
onDragLeave: () => setDragging(false),
});
const { user } = useUser();
- const canUploadAll = !user || user?.role !== "default";
+ const canUploadAll =
+ !user || user?.role !== "default" || user?.canUploadDocuments;
return (
diff --git a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
index aaf1b658be..b7a7d10da4 100644
--- a/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
+++ b/frontend/src/pages/Admin/Users/UserRow/EditUserModal/index.jsx
@@ -1,7 +1,11 @@
import React, { useState } from "react";
import { X } from "@phosphor-icons/react";
import Admin from "@/models/admin";
-import { MessageLimitInput, RoleHintDisplay } from "../..";
+import {
+ DocumentUploadPermission,
+ MessageLimitInput,
+ RoleHintDisplay,
+} from "../..";
import { AUTH_USER } from "@/utils/constants";
export default function EditUserModal({ currentUser, user, closeModal }) {
@@ -11,6 +15,10 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
enabled: user.dailyMessageLimit !== null,
limit: user.dailyMessageLimit || 10,
});
+ const [documentUpload, setDocumentUpload] = useState({
+ enabled: user.canUploadDocuments || false,
+ limit: user.documentUploadLimit || 10,
+ });
const handleUpdate = async (e) => {
setError(null);
@@ -27,6 +35,14 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
data.dailyMessageLimit = null;
}
+ // Document upload permissions
+ data.canUploadDocuments = documentUpload.enabled;
+ if (documentUpload.enabled) {
+ data.documentUploadLimit = documentUpload.limit;
+ } else {
+ data.documentUploadLimit = null;
+ }
+
const { success, error } = await Admin.updateUser(user.id, data);
if (success) {
// Update local storage if we're editing our own user
@@ -147,6 +163,12 @@ export default function EditUserModal({ currentUser, user, closeModal }) {
limit={messageLimit.limit}
updateState={setMessageLimit}
/>
+
{error &&
Error: {error}
}
diff --git a/frontend/src/pages/Admin/Users/index.jsx b/frontend/src/pages/Admin/Users/index.jsx
index 3759894764..7777e39b1b 100644
--- a/frontend/src/pages/Admin/Users/index.jsx
+++ b/frontend/src/pages/Admin/Users/index.jsx
@@ -113,16 +113,19 @@ function UsersContainer() {
const ROLE_HINT = {
default: [
"Can only send chats with workspaces they are added to by admin or managers.",
+ "Can upload documents if granted permission by an admin or manager.",
"Cannot modify any settings at all.",
],
manager: [
"Can view, create, and delete any workspaces and modify workspace-specific settings.",
"Can create, update and invite new users to the instance.",
+ "Can upload documents to any workspace.",
"Cannot modify LLM, vectorDB, embedding, or other connections.",
],
admin: [
"Highest user level privilege.",
"Can see and do everything across the system.",
+ "Can upload documents to any workspace.",
],
};
@@ -197,3 +200,62 @@ export function MessageLimitInput({ enabled, limit, updateState, role }) {
);
}
+
+export function DocumentUploadPermission({
+ enabled,
+ limit,
+ updateState,
+ role,
+}) {
+ if (role === "admin" || role === "manager") return null;
+ return (
+
+
+
+
+ Can upload documents
+
+
+ {
+ updateState((prev) => ({
+ ...prev,
+ enabled: e.target.checked,
+ }));
+ }}
+ className="peer sr-only"
+ />
+
+
+
+
+ Allow this user to upload documents to workspaces they have access to.
+
+
+ {enabled && (
+
+
+ Document upload limit
+
+
+ e.target.blur()}
+ onChange={(e) => {
+ updateState({
+ enabled: true,
+ limit: Number(e?.target?.value || 0),
+ });
+ }}
+ value={limit}
+ min={1}
+ className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
+ />
+
+
+ )}
+
+ );
+}
diff --git a/server/endpoints/api/document/index.js b/server/endpoints/api/document/index.js
index efac6a22d3..936aca4249 100644
--- a/server/endpoints/api/document/index.js
+++ b/server/endpoints/api/document/index.js
@@ -1,5 +1,8 @@
const { Telemetry } = require("../../../models/telemetry");
const { validApiKey } = require("../../../utils/middleware/validApiKey");
+const {
+ canUploadDocuments,
+} = require("../../../utils/middleware/validatedRequest");
const { handleAPIFileUpload } = require("../../../utils/files/multer");
const {
viewLocalFiles,
@@ -25,7 +28,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/upload",
- [validApiKey, handleAPIFileUpload],
+ [validApiKey, canUploadDocuments, handleAPIFileUpload],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -127,7 +130,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/upload/:folderName",
- [validApiKey, handleAPIFileUpload],
+ [validApiKey, canUploadDocuments, handleAPIFileUpload],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -286,7 +289,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/upload-link",
- [validApiKey],
+ [validApiKey, canUploadDocuments],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -383,7 +386,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/raw-text",
- [validApiKey],
+ [validApiKey, canUploadDocuments],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -784,7 +787,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/create-folder",
- [validApiKey],
+ [validApiKey, canUploadDocuments],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -850,7 +853,7 @@ function apiDocumentEndpoints(app) {
app.delete(
"/v1/document/remove-folder",
- [validApiKey],
+ [validApiKey, canUploadDocuments],
async (request, response) => {
/*
#swagger.tags = ['Documents']
@@ -909,7 +912,7 @@ function apiDocumentEndpoints(app) {
app.post(
"/v1/document/move-files",
- [validApiKey],
+ [validApiKey, canUploadDocuments],
async (request, response) => {
/*
#swagger.tags = ['Documents']
diff --git a/server/endpoints/document.js b/server/endpoints/document.js
index e4c311aee5..9ff0afe1e3 100644
--- a/server/endpoints/document.js
+++ b/server/endpoints/document.js
@@ -5,7 +5,10 @@ const {
flexUserRoleValid,
ROLES,
} = require("../utils/middleware/multiUserProtected");
-const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+ validatedRequest,
+ canUploadDocuments,
+} = require("../utils/middleware/validatedRequest");
const fs = require("fs");
const path = require("path");
@@ -13,7 +16,7 @@ function documentEndpoints(app) {
if (!app) return;
app.post(
"/document/create-folder",
- [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ [validatedRequest, canUploadDocuments],
async (request, response) => {
try {
const { name } = reqBody(request);
@@ -43,7 +46,7 @@ function documentEndpoints(app) {
app.post(
"/document/move-files",
- [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ [validatedRequest, canUploadDocuments],
async (request, response) => {
try {
const { files } = reqBody(request);
diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js
index 95fe61b49a..907ede56e5 100644
--- a/server/endpoints/workspaces.js
+++ b/server/endpoints/workspaces.js
@@ -13,7 +13,10 @@ const { DocumentVectors } = require("../models/vectors");
const { WorkspaceChats } = require("../models/workspaceChats");
const { getVectorDbClass } = require("../utils/helpers");
const { handleFileUpload, handlePfpUpload } = require("../utils/files/multer");
-const { validatedRequest } = require("../utils/middleware/validatedRequest");
+const {
+ validatedRequest,
+ canUploadDocuments,
+} = require("../utils/middleware/validatedRequest");
const { Telemetry } = require("../models/telemetry");
const {
flexUserRoleValid,
@@ -109,11 +112,7 @@ function workspaceEndpoints(app) {
app.post(
"/workspace/:slug/upload",
- [
- validatedRequest,
- flexUserRoleValid([ROLES.admin, ROLES.manager]),
- handleFileUpload,
- ],
+ [validatedRequest, canUploadDocuments, handleFileUpload],
async function (request, response) {
try {
const Collector = new CollectorApi();
@@ -159,7 +158,7 @@ function workspaceEndpoints(app) {
app.post(
"/workspace/:slug/upload-link",
- [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ [validatedRequest, canUploadDocuments],
async (request, response) => {
try {
const Collector = new CollectorApi();
@@ -202,7 +201,7 @@ function workspaceEndpoints(app) {
app.post(
"/workspace/:slug/update-embeddings",
- [validatedRequest, flexUserRoleValid([ROLES.admin, ROLES.manager])],
+ [validatedRequest, canUploadDocuments],
async (request, response) => {
try {
const user = await userFromSession(request, response);
@@ -869,11 +868,7 @@ function workspaceEndpoints(app) {
/** Handles the uploading and embedding in one-call by uploading via drag-and-drop in chat container. */
app.post(
"/workspace/:slug/upload-and-embed",
- [
- validatedRequest,
- flexUserRoleValid([ROLES.admin, ROLES.manager]),
- handleFileUpload,
- ],
+ [validatedRequest, canUploadDocuments, handleFileUpload],
async function (request, response) {
try {
const { slug = null } = request.params;
@@ -947,11 +942,7 @@ function workspaceEndpoints(app) {
app.delete(
"/workspace/:slug/remove-and-unembed",
- [
- validatedRequest,
- flexUserRoleValid([ROLES.admin, ROLES.manager]),
- handleFileUpload,
- ],
+ [validatedRequest, canUploadDocuments, handleFileUpload],
async function (request, response) {
try {
const { slug = null } = request.params;
diff --git a/server/models/documents.js b/server/models/documents.js
index 81c2dd9a79..26516548ee 100644
--- a/server/models/documents.js
+++ b/server/models/documents.js
@@ -99,6 +99,7 @@ const Document = {
docpath: path,
workspaceId: workspace.id,
metadata: JSON.stringify(metadata),
+ uploadedBy: userId ? Number(userId) : null,
};
const { vectorized, error } = await VectorDb.addDocumentToNamespace(
@@ -198,6 +199,20 @@ const Document = {
return 0;
}
},
+
+ countByUser: async function (userId) {
+ if (!userId) return 0;
+
+ try {
+ const count = await prisma.workspace_documents.count({
+ where: { uploadedBy: Number(userId) },
+ });
+ return count;
+ } catch (error) {
+ console.error("FAILED TO COUNT USER DOCUMENTS.", error.message);
+ return 0;
+ }
+ },
update: async function (id = null, data = {}) {
if (!id) throw new Error("No workspace document id provided for update");
diff --git a/server/models/user.js b/server/models/user.js
index 35e8271bcf..326a9211b5 100644
--- a/server/models/user.js
+++ b/server/models/user.js
@@ -22,6 +22,8 @@ const User = {
"role",
"suspended",
"dailyMessageLimit",
+ "documentUploadLimit",
+ "canUploadDocuments",
"bio",
],
validations: {
@@ -55,6 +57,19 @@ const User = {
}
return limit;
},
+ documentUploadLimit: (documentUploadLimit = 10) => {
+ if (documentUploadLimit === null) return null;
+ const limit = Number(documentUploadLimit);
+ if (isNaN(limit) || limit < 1) {
+ throw new Error(
+ "Document upload limit must be null or a number greater than or equal to 1"
+ );
+ }
+ return limit;
+ },
+ canUploadDocuments: (canUpload = false) => {
+ return Boolean(canUpload);
+ },
bio: (bio = "") => {
if (!bio || typeof bio !== "string") return "";
if (bio.length > 1000)
@@ -68,7 +83,10 @@ const User = {
case "suspended":
return Number(Boolean(value));
case "dailyMessageLimit":
+ case "documentUploadLimit":
return value === null ? null : Number(value);
+ case "canUploadDocuments":
+ return Boolean(value);
default:
return String(value);
}
@@ -323,6 +341,32 @@ const User = {
return currentChatCount < user.dailyMessageLimit;
},
+
+ /**
+ * Check if a user can upload documents based on their permissions and document upload limit.
+ * Admin and Manager roles can always upload documents regardless of the canUploadDocuments flag.
+ * @param {User} user The user object record.
+ * @returns {Promise} True if the user can upload documents, false otherwise.
+ */
+ canUploadDocument: async function (user) {
+ const { ROLES } = require("../utils/middleware/multiUserProtected");
+ if (!user) return false;
+
+ // Admin and Manager can always upload
+ if (user.role === ROLES.admin || user.role === ROLES.manager) return true;
+
+ // For regular users, check the permission flag
+ if (!user.canUploadDocuments) return false;
+
+ // If no upload limit is set, check only the permission flag
+ if (user.documentUploadLimit === null) return true;
+
+ // Check against the document upload limit
+ const { Document } = require("./documents");
+ const currentDocumentCount = await Document.countByUser(user.id);
+
+ return currentDocumentCount < user.documentUploadLimit;
+ },
};
module.exports = { User };
diff --git a/server/prisma/migrations/20250413101107_add_document_upload_permissions/migration.sql b/server/prisma/migrations/20250413101107_add_document_upload_permissions/migration.sql
new file mode 100644
index 0000000000..42bcf6c9e7
--- /dev/null
+++ b/server/prisma/migrations/20250413101107_add_document_upload_permissions/migration.sql
@@ -0,0 +1,44 @@
+-- RedefineTables
+PRAGMA defer_foreign_keys=ON;
+PRAGMA foreign_keys=OFF;
+CREATE TABLE "new_users" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "username" TEXT,
+ "password" TEXT NOT NULL,
+ "pfpFilename" TEXT,
+ "role" TEXT NOT NULL DEFAULT 'default',
+ "suspended" INTEGER NOT NULL DEFAULT 0,
+ "seen_recovery_codes" BOOLEAN DEFAULT false,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "dailyMessageLimit" INTEGER,
+ "documentUploadLimit" INTEGER DEFAULT 10,
+ "canUploadDocuments" BOOLEAN NOT NULL DEFAULT false,
+ "bio" TEXT DEFAULT ''
+);
+INSERT INTO "new_users" ("bio", "createdAt", "dailyMessageLimit", "id", "lastUpdatedAt", "password", "pfpFilename", "role", "seen_recovery_codes", "suspended", "username") SELECT "bio", "createdAt", "dailyMessageLimit", "id", "lastUpdatedAt", "password", "pfpFilename", "role", "seen_recovery_codes", "suspended", "username" FROM "users";
+DROP TABLE "users";
+ALTER TABLE "new_users" RENAME TO "users";
+CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
+CREATE TABLE "new_workspace_documents" (
+ "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
+ "docId" TEXT NOT NULL,
+ "filename" TEXT NOT NULL,
+ "docpath" TEXT NOT NULL,
+ "workspaceId" INTEGER NOT NULL,
+ "metadata" TEXT,
+ "pinned" BOOLEAN DEFAULT false,
+ "watched" BOOLEAN DEFAULT false,
+ "uploadedBy" INTEGER,
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "lastUpdatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ CONSTRAINT "workspace_documents_workspaceId_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT "workspace_documents_uploadedBy_fkey" FOREIGN KEY ("uploadedBy") REFERENCES "users" ("id") ON DELETE SET NULL ON UPDATE CASCADE
+);
+INSERT INTO "new_workspace_documents" ("createdAt", "docId", "docpath", "filename", "id", "lastUpdatedAt", "metadata", "pinned", "watched", "workspaceId") SELECT "createdAt", "docId", "docpath", "filename", "id", "lastUpdatedAt", "metadata", "pinned", "watched", "workspaceId" FROM "workspace_documents";
+DROP TABLE "workspace_documents";
+ALTER TABLE "new_workspace_documents" RENAME TO "workspace_documents";
+CREATE UNIQUE INDEX "workspace_documents_docId_key" ON "workspace_documents"("docId");
+CREATE INDEX "workspace_documents_uploadedBy_idx" ON "workspace_documents"("uploadedBy");
+PRAGMA foreign_keys=ON;
+PRAGMA defer_foreign_keys=OFF;
diff --git a/server/prisma/migrations/migration_lock.toml b/server/prisma/migrations/migration_lock.toml
index e5e5c4705a..2a5a44419d 100644
--- a/server/prisma/migrations/migration_lock.toml
+++ b/server/prisma/migrations/migration_lock.toml
@@ -1,3 +1,3 @@
# Please do not edit this file manually
-# It should be added in your version-control system (i.e. Git)
-provider = "sqlite"
\ No newline at end of file
+# It should be added in your version-control system (e.g., Git)
+provider = "sqlite"
diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma
index fb1dae19bc..698bcfe421 100644
--- a/server/prisma/schema.prisma
+++ b/server/prisma/schema.prisma
@@ -32,10 +32,14 @@ model workspace_documents {
metadata String?
pinned Boolean? @default(false)
watched Boolean? @default(false)
+ uploadedBy Int?
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
workspace workspaces @relation(fields: [workspaceId], references: [id])
+ user users? @relation("UploadedDocuments", fields: [uploadedBy], references: [id], onDelete: SetNull)
document_sync_queues document_sync_queues?
+
+ @@index([uploadedBy])
}
model invites {
@@ -68,6 +72,8 @@ model users {
createdAt DateTime @default(now())
lastUpdatedAt DateTime @default(now())
dailyMessageLimit Int?
+ documentUploadLimit Int? @default(10)
+ canUploadDocuments Boolean @default(false)
bio String? @default("")
workspace_chats workspace_chats[]
workspace_users workspace_users[]
@@ -81,6 +87,7 @@ model users {
browser_extension_api_keys browser_extension_api_keys[]
temporary_auth_tokens temporary_auth_tokens[]
system_prompt_variables system_prompt_variables[]
+ uploaded_documents workspace_documents[] @relation("UploadedDocuments")
}
model recovery_codes {
diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json
index 94a1d71e39..858bdfbfc2 100644
--- a/server/swagger/openapi.json
+++ b/server/swagger/openapi.json
@@ -875,6 +875,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -965,6 +968,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -1056,6 +1062,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -1129,6 +1138,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -1497,6 +1509,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -1554,6 +1569,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
@@ -1614,6 +1632,9 @@
}
}
},
+ "401": {
+ "description": "Unauthorized"
+ },
"403": {
"description": "Forbidden",
"content": {
diff --git a/server/utils/middleware/USER_PERMISSIONS.md b/server/utils/middleware/USER_PERMISSIONS.md
new file mode 100644
index 0000000000..f3614f4c3c
--- /dev/null
+++ b/server/utils/middleware/USER_PERMISSIONS.md
@@ -0,0 +1,71 @@
+# User Permissions in AnythingLLM
+
+AnythingLLM supports a powerful role-based permission system when running in multi-user mode. This document explains the different permission types available and how to manage them.
+
+## Role Types
+
+Users in AnythingLLM can have one of the following roles:
+
+- **Admin**: Full system access, can manage all users, workspaces, and settings
+- **Manager**: Can manage workspaces and upload documents
+- **Default**: Regular user with limited permissions
+
+## Document Upload Permissions
+
+By default, only admin and manager roles can upload documents to workspaces. However, AnythingLLM supports granting document upload permissions to regular users.
+
+### Enabling Document Upload for Regular Users
+
+As an admin, you can enable document upload permissions for regular users:
+
+1. Navigate to the Admin panel
+2. Select "Users" from the sidebar
+3. Click the edit icon next to a user with the "default" role
+4. In the user edit modal, you'll see a "Document Upload Permissions" section
+5. Toggle "Can upload documents" to enable this permission
+6. Set a document upload limit (maximum number of documents the user can upload)
+7. Save the changes
+
+### Document Upload Quota
+
+When enabling document upload for a regular user, you can set a quota to limit the maximum number of documents they can upload:
+
+- Set a specific number (e.g., 10, 50, 100)
+- Leave empty for unlimited uploads (same as admin/manager)
+
+### Technical Implementation
+
+The document upload permission system consists of:
+
+1. Database fields in the User model:
+
+ - `canUploadDocuments` (boolean): Permission flag
+ - `documentUploadLimit` (integer): Maximum number of documents this user can upload
+
+2. Middleware to enforce permissions:
+
+ - `canUploadDocuments` middleware checks both the permission flag and quota
+
+3. Tracking of document ownership:
+ - Each document is linked to the user who uploaded it
+ - A count is maintained to enforce the quota
+
+### API Endpoints
+
+The document upload permission is enforced on all document-related endpoints:
+
+- `/workspace/:slug/upload-and-embed`
+- `/workspace/:slug/upload`
+- `/workspace/:slug/upload-link`
+- `/workspace/:slug/update-embeddings`
+- `/workspace/:slug/remove-and-unembed`
+
+## Managing Permissions
+
+Only administrators can modify user permissions. The permission settings are found in the user management section of the admin panel.
+
+## Security Considerations
+
+- Even with document upload permissions, regular users can only upload to workspaces they have access to
+- Document upload permissions do not grant workspace creation/management permissions
+- All uploaded documents go through the same content validation process regardless of user role
diff --git a/server/utils/middleware/validatedRequest.js b/server/utils/middleware/validatedRequest.js
index f78709de21..9a91559f6c 100644
--- a/server/utils/middleware/validatedRequest.js
+++ b/server/utils/middleware/validatedRequest.js
@@ -106,6 +106,39 @@ async function validateMultiUserRequest(request, response, next) {
next();
}
+/**
+ * Check if the authenticated user has permission to upload documents.
+ * This middleware should be used after validatedRequest.
+ * @returns {function}
+ */
+async function canUploadDocuments(request, response, next) {
+ const multiUserMode = response.locals?.multiUserMode;
+ if (!multiUserMode) {
+ next();
+ return;
+ }
+
+ const user = response.locals?.user;
+ if (!user) {
+ return response.status(401).json({
+ success: false,
+ error: "Authentication required",
+ });
+ }
+
+ const canUpload = await User.canUploadDocument(user);
+ if (!canUpload) {
+ return response.status(403).json({
+ success: false,
+ error:
+ "You don't have permission to upload documents or have reached your upload limit",
+ });
+ }
+
+ next();
+}
+
module.exports = {
validatedRequest,
+ canUploadDocuments,
};