Detecting bulk changes
This example shows how to detect potentially suspicious user behavior in ONLYOFFICE DocSpace by analyzing audit logs and automatically applying temporary restrictions. The workflow monitors recent file-related activity, identifies users who modify an unusually large number of files in a short time window, sends a security alert, and updates the account status of the affected users.
Before you start
- Replace
https://yourportal.onlyoffice.comandYOUR_API_KEYwith your actual DocSpace portal URL and API key. Ensure you have the necessary data and permissions to perform these operations. - Before you can make requests to the API, you need to authenticate. Check out the Personal access tokens page to learn how to obtain and use access tokens.
Full example
- Node.js
- Python
import express from "express";
// =========================
// Config
// =========================
const API_HOST = process.env.DOCSPACE_API_HOST; // Set DOCSPACE_API_HOST in env (recommended). For quick tests you can temporarily paste your portal URL here.
const API_KEY = process.env.DOCSPACE_API_KEY; // Set DOCSPACE_API_KEY in env (recommended). For quick tests you can temporarily paste token here.
// Detection rules
const WINDOW_MINUTES = 10;
const FILE_CHANGE_THRESHOLD = 20;
// Where to notify (placeholder)
const SECURITY_CONTACT = "security-team@yourcompany.com";
const HEADERS: Record<string, string> = {
Accept: "application/json",
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
// =========================
// Helpers
// =========================
async function docspaceRequest(path: string, method: string = "GET", body: any = null) {
const url = `${API_HOST}${path}`;
try {
const res = await fetch(url, {
method,
headers: HEADERS,
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error(`[ERROR] Request failed: ${method} ${url}`);
console.error(`[ERROR] Status: ${res.status}, Message: ${text}`);
return null;
}
return await res.json();
} catch (err: any) {
console.error(`[ERROR] Request error: ${err?.message || err}`);
return null;
}
}
function toIso(dt: Date) {
return dt.toISOString();
}
// =========================
// Step 1: Load audit events
// =========================
async function getRecentAuditEvents(dtFrom: Date, dtTo: Date) {
const params = new URLSearchParams({
from: toIso(dtFrom),
to: toIso(dtTo),
});
const data = await docspaceRequest(`/api/2.0/security/audit/events/filter?${params.toString()}`, "GET");
const resp = data && typeof data === "object" ? (data.response ?? null) : null;
return Array.isArray(resp) ? resp : [];
}
// =========================
// Step 2: Detect anomalies
// =========================
function isFileChangeEvent(evt: any) {
const entityType = String(evt?.entityType ?? evt?.targetType ?? evt?.entity ?? "").toLowerCase();
const action = String(evt?.action ?? "").toLowerCase();
const isFileEntity = entityType.includes("file") || entityType.includes("document");
const isChangeAction =
action.includes("edit") ||
action.includes("update") ||
action.includes("save") ||
action.includes("modify") ||
action.includes("upload") ||
action.includes("create") ||
action.includes("version");
return isFileEntity && isChangeAction;
}
function buildUserFileChangeMap(events: any[]) {
const result = new Map<string, Set<string>>();
for (const evt of events) {
if (!isFileChangeEvent(evt)) continue;
const userId = String(evt?.userId ?? evt?.account ?? "").trim();
const fileId = String(evt?.entityId ?? evt?.fileId ?? evt?.targetId ?? "").trim();
if (!userId || !fileId) continue;
if (!result.has(userId)) result.set(userId, new Set());
result.get(userId)!.add(fileId);
}
return result;
}
function detectAnomalies(userMap: Map<string, Set<string>>) {
const anomalies: { user_id: string; file_count: number }[] = [];
for (const [userId, fileSet] of userMap.entries()) {
const count = fileSet.size;
if (count > FILE_CHANGE_THRESHOLD) {
anomalies.push({ user_id: userId, file_count: count });
}
}
return anomalies;
}
// =========================
// Step 3: Alert + disable users (Update a user)
// =========================
function sendSecurityAlert(anomalies: { user_id: string; file_count: number }[]) {
console.log("--- SECURITY ALERT ---");
console.log(`Recipient: ${SECURITY_CONTACT}`);
console.log(`Window: last ${WINDOW_MINUTES} minutes`);
console.log(`Threshold: > ${FILE_CHANGE_THRESHOLD} unique files`);
console.log("Suspicious users:");
for (const a of anomalies) {
console.log(`- userId=${a.user_id}, changed_files=${a.file_count}`);
}
}
async function disableUser(userId: string) {
// Update a user
// PUT /api/2.0/people/:userid
// Body includes "disable": true
const payload = {
disable: true,
comment: `Auto-disabled: changed > ${FILE_CHANGE_THRESHOLD} files in ${WINDOW_MINUTES} minutes`,
};
const data = await docspaceRequest(`/api/2.0/people/${encodeURIComponent(userId)}`, "PUT", payload);
return Boolean(data);
}
async function disableSuspiciousUsers(anomalies: { user_id: string; file_count: number }[]) {
if (!anomalies.length) return;
sendSecurityAlert(anomalies);
for (const a of anomalies) {
const ok = await disableUser(a.user_id);
if (ok) console.log(`[OK] User disabled: ${a.user_id}`);
else console.error(`[ERROR] Failed to disable user: ${a.user_id}`);
}
}
// =========================
// Main runner
// =========================
async function runDetection() {
const now = new Date();
const dtFrom = new Date(now.getTime() - WINDOW_MINUTES * 60 * 1000);
console.log(`[INFO] Checking anomalous activity between ${toIso(dtFrom)} and ${toIso(now)}...`);
const events = await getRecentAuditEvents(dtFrom, now);
if (!events.length) {
console.log("[INFO] No audit events in this window.");
return;
}
const userMap = buildUserFileChangeMap(events);
const anomalies = detectAnomalies(userMap);
if (!anomalies.length) {
console.log(`[INFO] No users exceeded the threshold of ${FILE_CHANGE_THRESHOLD} files.`);
return;
}
console.log(`[INFO] Detected ${anomalies.length} user(s) with anomalous activity.`);
await disableSuspiciousUsers(anomalies);
console.log("[INFO] Anomalous activity handling completed.");
}
// =========================
// Optional: expose as an HTTP endpoint (run on demand)
// =========================
const app = express();
app.use(express.json({ limit: "1mb" }));
app.get("/security/scan", async (_req, res) => {
try {
await runDetection();
return res.status(200).json({ status: "ok" });
} catch (e: any) {
console.error("[ERROR] Scan failed:", e?.message || e);
return res.status(500).json({ status: "error" });
}
});
app.listen(3000, () => {
console.log("Security scan endpoint: http://localhost:3000/security/scan");
});
import os
from datetime import datetime, timedelta, timezone
from urllib.parse import urlencode, quote
import requests
from flask import Flask, jsonify
# =========================
# Config
# =========================
API_HOST = os.environ.get("DOCSPACE_API_HOST") # Set DOCSPACE_API_HOST in env (recommended). For quick tests you can temporarily paste your portal URL here.
API_KEY = os.environ.get("DOCSPACE_API_KEY") # Set DOCSPACE_API_KEY in env (recommended). For quick tests you can temporarily paste token here.
WINDOW_MINUTES = 10
FILE_CHANGE_THRESHOLD = 20
SECURITY_CONTACT = "security-team@yourcompany.com"
HEADERS = {
"Authorization": f"Bearer {API_KEY}",
"Content-Type": "application/json",
"Accept": "application/json",
}
# =========================
# Helpers
# =========================
def docspace_request(path, method= "GET", json_body= None):
url = f"{API_HOST}{path}"
try:
r = requests.request(method.upper(), url, headers=HEADERS, json=json_body, timeout=30)
except Exception as e:
print(f"[ERROR] DocSpace request error: {e}")
return None
if not (200 <= r.status_code < 300):
print(f"[ERROR] DocSpace request failed: {method} {url}")
print(f"[ERROR] Status: {r.status_code}, Message: {r.text}")
return None
try:
return r.json()
except ValueError:
print(f"[ERROR] Failed to parse JSON from {url}")
return None
# =========================
# Step 1: Load audit events
# =========================
def get_recent_audit_events(dt_from, dt_to):
params = {"from": dt_from.isoformat(), "to": dt_to.isoformat()}
query = urlencode(params)
data = docspace_request(f"/api/2.0/security/audit/events/filter?{query}", "GET")
if not isinstance(data, dict):
return []
events = data.get("response", [])
return events if isinstance(events, list) else []
# =========================
# Step 2: Detect anomalies
# =========================
def is_file_change_event(evt):
entity_type = str(evt.get("entityType") or evt.get("targetType") or evt.get("entity") or "").lower()
action = str(evt.get("action") or "").lower()
is_file_entity = ("file" in entity_type) or ("document" in entity_type)
is_change_action = any(
k in action
for k in ["edit", "update", "save", "modify", "upload", "create", "version"]
)
return is_file_entity and is_change_action
def build_user_file_change_map(events):
result = {}
for evt in events:
if not is_file_change_event(evt):
continue
user_id = str(evt.get("userId") or evt.get("account") or "").strip()
file_id = str(evt.get("entityId") or evt.get("fileId") or evt.get("targetId") or "").strip()
if not user_id or not file_id:
continue
if user_id not in result:
result[user_id] = set()
result[user_id].add(file_id)
return result
def detect_anomalies(user_map):
anomalies = []
for user_id, file_set in user_map.items():
count = len(file_set)
if count > FILE_CHANGE_THRESHOLD:
anomalies.append({"user_id": user_id, "file_count": count})
return anomalies
# =========================
# Step 3: Alert + disable users (Update a user)
# =========================
def send_security_alert(anomalies):
print("--- SECURITY ALERT ---")
print(f"Recipient: {SECURITY_CONTACT}")
print(f"Window: last {WINDOW_MINUTES} minutes")
print(f"Threshold: > {FILE_CHANGE_THRESHOLD} unique files")
print("Suspicious users:")
for a in anomalies:
print(f'- userId={a["user_id"]}, changed_files={a["file_count"]}')
def disable_user(user_id):
# Update a user
# PUT /api/2.0/people/:userid
payload = {
"disable": True,
"comment": f"Auto-disabled: changed > {FILE_CHANGE_THRESHOLD} files in {WINDOW_MINUTES} minutes",
}
safe_user_id = quote(str(user_id), safe="")
data = docspace_request(f"/api/2.0/people/{safe_user_id}", method="PUT", json_body=payload)
return data is not None
def disable_suspicious_users(anomalies):
if not anomalies:
return
send_security_alert(anomalies)
for a in anomalies:
user_id = a["user_id"]
ok = disable_user(user_id)
if ok:
print(f"[OK] User disabled: {user_id}")
else:
print(f"[ERROR] Failed to disable user: {user_id}")
# =========================
# Main runner
# =========================
def run_detection():
now = datetime.now(timezone.utc)
dt_from = now - timedelta(minutes=WINDOW_MINUTES)
print(f"[INFO] Checking anomalous activity between {dt_from.isoformat()} and {now.isoformat()}...")
events = get_recent_audit_events(dt_from, now)
if not events:
print("[INFO] No audit events in this window.")
return
user_map = build_user_file_change_map(events)
anomalies = detect_anomalies(user_map)
if not anomalies:
print(f"[INFO] No users exceeded the threshold of {FILE_CHANGE_THRESHOLD} files.")
return
print(f"[INFO] Detected {len(anomalies)} user(s) with anomalous activity.")
disable_suspicious_users(anomalies)
print("[INFO] Anomalous activity handling completed.")
# =========================
# Optional: expose as an HTTP endpoint (run on demand)
# =========================
app = Flask(__name__)
@app.get("/security/scan")
def security_scan():
try:
run_detection()
return jsonify({"status": "ok"}), 200
except Exception as e:
print("[ERROR] Scan failed:", e)
return jsonify({"status": "error"}), 500
if __name__ == "__main__":
print("Security scan endpoint: http://localhost:3000/security/scan")
app.run(host="0.0.0.0", port=3000)
Step 1: Load audit events for a short time window
The script calculates a time range covering the last WINDOW_MINUTES and sends a GET request to /api/2.0/security/audit/events/filter using the from and to query parameters.
The response returns audit events recorded during this period.
- Node.js
- Python
async function getRecentAuditEvents(dtFrom: Date, dtTo: Date) {
const params = new URLSearchParams({
from: toIso(dtFrom),
to: toIso(dtTo),
});
const data = await docspaceRequest(
`/api/2.0/security/audit/events/filter?${params.toString()}`,
'GET'
);
const resp = data && typeof data === 'object' ? (data.response ?? null) : null;
if (!Array.isArray(resp)) return [];
return resp;
}
def get_recent_audit_events(dt_from, dt_to):
params = {"from": dt_from.isoformat(), "to": dt_to.isoformat()}
query = urlencode(params)
data = docspace_request(f"/api/2.0/security/audit/events/filter?{query}", "GET")
if not isinstance(data, dict):
return []
events = data.get("response", [])
return events if isinstance(events, list) else []
Step 2: Detect users with suspicious file-change volume
From the loaded audit events, the script keeps only actions related to file or document changes (for example, edit, update, upload, create, or version events).
For each user:
- a set of distinct file IDs is collected,
- the total number of unique files modified by the user is calculated.
If the number exceeds
FILE_CHANGE_THRESHOLD, the user is marked as suspicious and added to the anomalies list.
- Node.js
- Python
function buildUserFileChangeMap(events: any[]) {
const result = new Map<string, Set<string>>();
for (const evt of events) {
if (!isFileChangeEvent(evt)) continue;
const userId = String(evt?.userId ?? evt?.account ?? '');
const fileId = String(evt?.entityId ?? evt?.fileId ?? evt?.targetId ?? '');
if (!userId || !fileId) continue;
if (!result.has(userId)) result.set(userId, new Set());
result.get(userId)!.add(fileId);
}
return result;
}
def is_file_change_event(evt):
entity_type = str(
evt.get("entityType") or evt.get("targetType") or evt.get("entity") or ""
).lower()
action = str(evt.get("action") or "").lower()
is_file_entity = ("file" in entity_type) or ("document" in entity_type)
is_change_action = any(
k in action
for k in ["edit", "update", "save", "modify", "upload", "create", "version"]
)
return is_file_entity and is_change_action
Step 3: Alert and restrict accounts
If suspicious users are detected:
- A security alert is generated (placeholder), containing:
- user IDs,
- number of files changed,
- configured threshold.
- A PUT request is sent to /api/2.0/people/:userid with the list of suspicious user IDs.
This updates the account status to
RESTRICTED_STATUS, temporarily limiting further activity until the incident is reviewed.
- Node.js
- Python
function sendSecurityAlert(anomalies: { user_id: string; file_count: number }[]) {
console.log("--- SECURITY ALERT ---");
console.log(`Recipient: ${SECURITY_CONTACT}`);
console.log(`Window: last ${WINDOW_MINUTES} minutes`);
console.log(`Threshold: > ${FILE_CHANGE_THRESHOLD} unique files`);
console.log("Suspicious users:");
for (const a of anomalies) {
console.log(`- userId=${a.user_id}, changed_files=${a.file_count}`);
}
}
async function disableUser(userId: string) {
// Update a user
// PUT /api/2.0/people/:userid
// Body includes "disable": true
const payload = {
disable: true,
comment: `Auto-disabled: changed > ${FILE_CHANGE_THRESHOLD} files in ${WINDOW_MINUTES} minutes`,
};
const data = await docspaceRequest(`/api/2.0/people/${encodeURIComponent(userId)}`, "PUT", payload);
return Boolean(data);
}
async function disableSuspiciousUsers(anomalies: { user_id: string; file_count: number }[]) {
if (!anomalies.length) return;
sendSecurityAlert(anomalies);
for (const a of anomalies) {
const ok = await disableUser(a.user_id);
if (ok) console.log(`[OK] User disabled: ${a.user_id}`);
else console.error(`[ERROR] Failed to disable user: ${a.user_id}`);
}
}
from urllib.parse import quote
def send_security_alert(anomalies):
print("--- SECURITY ALERT ---")
print(f"Recipient: {SECURITY_CONTACT}")
print(f"Window: last {WINDOW_MINUTES} minutes")
print(f"Threshold: > {FILE_CHANGE_THRESHOLD} unique files")
print("Suspicious users:")
for a in anomalies:
print(f'- userId={a["user_id"]}, changed_files={a["file_count"]}')
def disable_user(user_id):
# Update a user
# PUT /api/2.0/people/:userid
payload = {
"disable": True,
"comment": f"Auto-disabled: changed > {FILE_CHANGE_THRESHOLD} files in {WINDOW_MINUTES} minutes",
}
safe_user_id = quote(str(user_id), safe="")
data = docspace_request(f"/api/2.0/people/{safe_user_id}", method="PUT", json_body=payload)
return data is not None
def disable_suspicious_users(anomalies):
if not anomalies:
return
send_security_alert(anomalies)
for a in anomalies:
user_id = a["user_id"]
ok = disable_user(user_id)
if ok:
print(f"[OK] User disabled: {user_id}")
else:
print(f"[ERROR] Failed to disable user: {user_id}")