toeverything/AFFiNE · error · DocActionDenied
doc_action_denied
doc_action_denied
Error message
You do not have permission to perform ${action} action on doc ${docId}. What it means
Thrown by PermissionService.assertDoc (packages/backend/server/src/core/permission/service.ts:171) when canDoc resolves the user's permission rules for the doc and returns allowed=false. It is the central doc-level authorization gate: any doc API (read/update/delete/move) wrapped with assertDoc rejects callers whose effective rules do not include the requested PermissionDocAction.
Solutions
- Verify the user has an active workspace membership (workspaceUser.getActive) for the doc's workspace.
- Pre-check with the non-throwing PermissionService.canDoc({ userId, workspaceId, docId, action }) before performing the call.
- Filter doc lists through filterReadableDocs instead of asserting per doc.
- For local workspaces pass allowLocal: true where the call supports it.
Example fix
// before
await docService.update({ userId, workspaceId, docId, data }); // may throw doc_action_denied
// after
const allowed = await permission.canDoc({ userId, workspaceId, docId, action: 'Doc.Update' });
if (!allowed) throw new Forbidden('no access');
await docService.update({ userId, workspaceId, docId, data }); Defensive patterns
Strategy: validation
Validate before calling
const allowed = await permission.canDoc({
userId,
workspaceId,
docId,
action: 'Doc.Update',
});
if (!allowed) {
return res.status(403).json({ code: 'doc_action_denied' });
} Type guard
const isDocActionDenied = (e: unknown): e is DocActionDenied => e instanceof DocActionDenied;
Try / catch
try {
await permission.assertDoc({ userId, workspaceId, docId, action });
} catch (e) {
if (e instanceof DocActionDenied) return res.status(403).json({ code: e.code });
throw e;
} Prevention
- Resolve permissions with canDoc before doc mutations instead of relying on assertDoc to fail.
- Pre-filter doc collections with filterReadableDocs so bulk operations never hit per-doc denials.
- Drop sessions and realtime rooms immediately when workspace membership is revoked.
When it happens
Trigger: Calling a doc route that asserts a doc action while the user is not an active member of the workspace, is a collaborator whose role lacks that action, or the doc is in a state (e.g. trashed) where the rule does not grant it; also passing a userId that does not match the session owner.
Common situations: Stale token from a removed member, custom integrations calling doc APIs without joining the workspace first, forgetting allowLocal for local workspaces, or a wrong workspaceId/docId pairing.
Understand the failure class
Background: Permission denied / not authorized / 403 Forbidden: access-control rejections when the caller lacks the required role, grant, or ownership — this error's family across 18 libraries.
Related errors
AI-assisted analysis of toeverything/AFFiNE@591f874dad (2026-08-18).
Data as JSON: /api/errors/9658ca6b08ce0f06.
Report an issue: GitHub.
Appendix: source
Thrown at packages/backend/server/src/core/permission/service.ts:156
action: PermissionDocAction;
allowLocal?: boolean;
}) {
const output = await this.docPermissions({
...input,
actions: [input.action],
});
return output.decisions[0]?.allowed ?? false;
}
async assertDoc(input: {
userId?: string;
workspaceId: string;
docId: string;
action: PermissionDocAction;
allowLocal?: boolean;
}) {
if (!(await this.canDoc(input))) {
throw new DocActionDenied({
action: input.action,
docId: input.docId,
spaceId: input.workspaceId,
});
}
}
async filterReadableDocs<T extends { docId: string }>(input: {
userId?: string;
workspaceId: string;
docs: T[];
allowLocal?: boolean;
}) {
const decisions = await this.batchDocPermissions({
...input,
docs: input.docs.map(doc => ({
docId: doc.docId,
actions: ['Doc.Read'],View on GitHub (pinned to 591f874dad)