@ Mention Permissioning Specification¶
Last Updated: 2026-01-09 Feature: Issue #623 - Global Discussions + @ Mentions Status: Specification Draft for Review
Overview¶
This document specifies the permission model for @ mentions in OpenContracts, ensuring users can only: 1. Mention resources they have appropriate access to 2. See mentions of resources they have permission to view 3. Not discover or infer the existence of resources they shouldn't know about (IDOR protection)
The mention system supports three types of mentions: - @corpus:slug - Mention a corpus - @document:slug - Mention a standalone document - @corpus:corpus-slug/document:doc-slug - Mention a document within a corpus context
Security Principles¶
1. No Information Leakage (IDOR Protection)¶
- Autocomplete searches MUST NOT reveal the existence of inaccessible resources
- Failed mention attempts MUST NOT confirm or deny resource existence
- Error messages MUST be identical whether resource doesn't exist or user lacks permission
2. Mention-Appropriate Permissions¶
- Users should only mention resources where their attention/reference would be meaningful
- This generally means resources they can contribute to, not just read
- Read-only access is insufficient for most mention scenarios
3. Backend-Enforced Filtering¶
- All permission filtering happens on the backend via
.visible_to_user()methods - Frontend autocomplete trusts backend-filtered results
- No client-side permission logic or filtering
4. Rendering Respects Visibility¶
- When rendering messages, mentions are filtered based on viewer's permissions
- Inaccessible mentions render as plain text (no clickable chip)
- No information about the mentioned resource is leaked to unauthorized viewers
Permission Models by Resource Type¶
Corpus Mentions (@corpus:slug)¶
Who Can Mention?¶
Users can autocomplete/mention a corpus if they have at least one of: 1. Creator: User created the corpus 2. Write Permission: User has CREATE, UPDATE, or DELETE permission on the corpus 3. Public Corpus: Corpus is marked is_public=True
Rationale: Mentioning a corpus implies drawing attention to it for collaborative purposes. Read-only viewers shouldn't be mentioning corpuses in discussions since they can't contribute to them.
Backend Implementation¶
def resolve_search_corpuses_for_mention(self, info, text_search=None, **kwargs):
"""
Search corpuses for @ mention autocomplete.
SECURITY: Only returns corpuses where user can meaningfully contribute.
"""
user = info.context.user
# Get corpuses user has write permission to
from guardian.shortcuts import get_objects_for_user
writable_corpuses = get_objects_for_user(
user,
['corpuses.create_corpus', 'corpuses.update_corpus', 'corpuses.delete_corpus'],
klass=Corpus,
accept_global_perms=False,
any_perm=True # Has ANY of these permissions
)
# Combine: creator OR writable OR public
qs = Corpus.objects.filter(
Q(creator=user) | Q(id__in=writable_corpuses) | Q(is_public=True)
).distinct()
if text_search:
qs = qs.filter(
Q(title__icontains=text_search) | Q(description__icontains=text_search)
)
return qs.order_by("-modified")
Rendering for Viewers¶
When a viewer sees a message with @corpus:legal-contracts: - Has READ permission: Renders as clickable chip, navigates to corpus - No permission: Renders as plain text @corpus:legal-contracts (no chip, no link) - Corpus doesn't exist: Same as no permission (IDOR protection)
Document Mentions (@document:slug or @corpus:slug/document:slug)¶
Who Can Mention?¶
Users can autocomplete/mention a document if they have at least one of: 1. Creator: User created the document 2. Write Permission on Document: User has CREATE, UPDATE, or DELETE permission on the document 3. Write Permission on Parent Corpus: Document is in a corpus where user has write permission 4. Public Document in Accessible Corpus: - Document is marked is_public=True AND - If in a corpus, corpus is also is_public=True OR user has READ access to corpus
Rationale: Similar to corpuses, mentioning a document implies collaborative context. However, public documents are included to allow discussion/reference in open forums.
Backend Implementation¶
def resolve_search_documents_for_mention(self, info, text_search=None, **kwargs):
"""
Search documents for @ mention autocomplete.
SECURITY: Only returns documents where user can meaningfully contribute.
"""
user = info.context.user
# Get documents user has write permission to
from guardian.shortcuts import get_objects_for_user
writable_documents = get_objects_for_user(
user,
['documents.create_document', 'documents.update_document', 'documents.delete_document'],
klass=Document,
accept_global_perms=False,
any_perm=True
)
# Get corpuses user has write permission to
writable_corpuses = get_objects_for_user(
user,
['corpuses.create_corpus', 'corpuses.update_corpus', 'corpuses.delete_corpus'],
klass=Corpus,
accept_global_perms=False,
any_perm=True
)
# Build complex filter
# 1. User is creator
# 2. User has write permission on document
# 3. Document is in a writable corpus
# 4. Document is public AND (no corpus OR public corpus OR user has corpus access)
qs = Document.objects.filter(
Q(creator=user) |
Q(id__in=writable_documents) |
Q(corpus_set__in=writable_corpuses) |
(
Q(is_public=True) &
(
Q(corpus_set__isnull=True) |
Q(corpus_set__is_public=True) |
Q(corpus_set__id__in=Corpus.objects.visible_to_user(user))
)
)
).distinct()
if text_search:
qs = qs.filter(
Q(title__icontains=text_search) | Q(description__icontains=text_search)
)
# Prefetch corpus relationship for efficient mention format generation
qs = qs.prefetch_related("corpus_set", "corpus_set__creator")
return qs.order_by("-modified")
Rendering for Viewers¶
When a viewer sees a message with @corpus:legal-contracts/document:contract-001: - Has READ permission on both document and corpus: Renders as clickable chip - Has READ on document but not corpus: Renders as plain text (corpus context required) - No permission: Renders as plain text @corpus:legal-contracts/document:contract-001 - Resource doesn't exist: Same as no permission (IDOR protection)
Mention Rendering & Parsing¶
Backend: mentionedResources Field¶
The GraphQL MessageType includes a mentionedResources field that: 1. Parses message content for mention patterns 2. Resolves mentioned resources (corpus/document lookups) 3. Filters to only resources visible to the requesting user 4. Returns metadata for rendering chips
def resolve_mentioned_resources(self, info):
"""
Parse message content and resolve mentioned resources.
SECURITY: Only returns resources visible to requesting user.
"""
user = info.context.user
content = self.content or ""
# Regex patterns for three mention formats
corpus_pattern = r'@corpus:([a-z0-9-]+)'
document_pattern = r'@document:([a-z0-9-]+)'
full_pattern = r'@corpus:([a-z0-9-]+)/document:([a-z0-9-]+)'
resources = []
# Parse full format first (most specific)
for match in re.finditer(full_pattern, content):
corpus_slug, doc_slug = match.groups()
try:
corpus = Corpus.objects.get(slug=corpus_slug)
document = Document.objects.get(slug=doc_slug)
# Check if user can see this document in this corpus context
if (document.user_can(user, PermissionTypes.READ) and
corpus.user_can(user, PermissionTypes.READ)):
resources.append({
'type': 'DOCUMENT',
'id': str(document.id),
'slug': doc_slug,
'title': document.title,
'url': f"/c/{corpus.creator.slug}/{corpus_slug}/d/{doc_slug}",
'corpus': {
'slug': corpus_slug,
'title': corpus.title,
}
})
except (Corpus.DoesNotExist, Document.DoesNotExist):
# Resource doesn't exist or user can't see it - skip silently
pass
# Parse standalone corpus mentions
for match in re.finditer(corpus_pattern, content):
slug = match.group(1)
# Skip if already matched in full format
if any(r['type'] == 'DOCUMENT' and r.get('corpus', {}).get('slug') == slug for r in resources):
continue
try:
corpus = Corpus.objects.get(slug=slug)
if corpus.user_can(user, PermissionTypes.READ):
resources.append({
'type': 'CORPUS',
'id': str(corpus.id),
'slug': slug,
'title': corpus.title,
'url': f"/c/{corpus.creator.slug}/{slug}",
})
except Corpus.DoesNotExist:
pass
# Parse standalone document mentions
for match in re.finditer(document_pattern, content):
slug = match.group(1)
try:
document = Document.objects.get(slug=slug)
if document.user_can(user, PermissionTypes.READ):
resources.append({
'type': 'DOCUMENT',
'id': str(document.id),
'slug': slug,
'title': document.title,
'url': f"/d/{document.creator.slug}/{slug}",
})
except Document.DoesNotExist:
pass
return resources
Frontend: Rendering Mention Chips¶
The parseMentionsInContent function receives: 1. Message content (HTML string) 2. mentionedResources array from backend (already filtered)
export function parseMentionsInContent(
content: string,
mentionedResources: MentionedResource[]
): React.ReactNode {
if (!mentionedResources || mentionedResources.length === 0) {
// No accessible mentions - render content as-is
return <div dangerouslySetInnerHTML={{ __html: content }} />;
}
// Create map of mention patterns to resources
const mentionMap = new Map<string, MentionedResource>();
mentionedResources.forEach((resource) => {
if (resource.type === "CORPUS") {
mentionMap.set(`@corpus:${resource.slug}`, resource);
} else if (resource.type === "DOCUMENT") {
if (resource.corpus) {
mentionMap.set(
`@corpus:${resource.corpus.slug}/document:${resource.slug}`,
resource
);
} else {
mentionMap.set(`@document:${resource.slug}`, resource);
}
}
});
const mentionRegex = /@(?:corpus:[a-z0-9-]+(?:\/document:[a-z0-9-]+)?|document:[a-z0-9-]+)/gi;
const parts: React.ReactNode[] = [];
let lastIndex = 0;
let match;
let key = 0;
while ((match = mentionRegex.exec(content)) !== null) {
// Add text before mention
if (match.index > lastIndex) {
parts.push(
<span
key={`text-${key++}`}
dangerouslySetInnerHTML={{
__html: content.substring(lastIndex, match.index),
}}
/>
);
}
const mentionText = match[0];
const resource = mentionMap.get(mentionText);
if (resource) {
// User has permission - render as clickable chip
parts.push(<MentionChip key={`mention-${key++}`} resource={resource} />);
} else {
// User lacks permission - render as plain text (IDOR protection)
parts.push(<span key={`text-${key++}`}>{mentionText}</span>);
}
lastIndex = mentionRegex.lastIndex;
}
// Add remaining text
if (lastIndex < content.length) {
parts.push(
<span
key={`text-${key++}`}
dangerouslySetInnerHTML={{ __html: content.substring(lastIndex) }}
/>
);
}
return <div>{parts}</div>;
}
Testing Requirements¶
Backend Tests¶
Corpus Mention Permission Tests¶
def test_corpus_mention_autocomplete_permissions():
"""Test that corpus autocomplete respects write permissions."""
# Setup
owner = User.objects.create_user("owner", password="test")
viewer = User.objects.create_user("viewer", password="test")
contributor = User.objects.create_user("contributor", password="test")
private_corpus = Corpus.objects.create(
title="Private Corpus",
creator=owner,
is_public=False
)
public_corpus = Corpus.objects.create(
title="Public Corpus",
creator=owner,
is_public=True
)
# Give contributor write permission
set_permissions_for_obj_to_user(contributor, private_corpus, [PermissionTypes.UPDATE])
# Give viewer only read permission
set_permissions_for_obj_to_user(viewer, private_corpus, [PermissionTypes.READ])
# Test owner sees both
owner_results = search_corpuses_for_mention(owner, "Corpus")
assert private_corpus in owner_results
assert public_corpus in owner_results
# Test contributor sees private (write permission) and public
contributor_results = search_corpuses_for_mention(contributor, "Corpus")
assert private_corpus in contributor_results
assert public_corpus in contributor_results
# Test viewer sees ONLY public (read permission insufficient)
viewer_results = search_corpuses_for_mention(viewer, "Corpus")
assert private_corpus not in viewer_results
assert public_corpus in viewer_results
Document Mention Permission Tests¶
def test_document_mention_via_corpus_permission():
"""Test that document mentions respect parent corpus permissions."""
# Setup
owner = User.objects.create_user("owner", password="test")
corpus_contributor = User.objects.create_user("corpus_contributor", password="test")
corpus = Corpus.objects.create(
title="Legal Corpus",
creator=owner,
is_public=False
)
document = Document.objects.create(
title="Contract",
creator=owner,
is_public=False
)
document.corpus_set.add(corpus)
# Give corpus_contributor write permission on corpus (not document)
set_permissions_for_obj_to_user(corpus_contributor, corpus, [PermissionTypes.UPDATE])
# User should see document via corpus permission
results = search_documents_for_mention(corpus_contributor, "Contract")
assert document in results
Mention Rendering Permission Tests¶
def test_mentioned_resources_field_filters_by_viewer():
"""Test that mentionedResources only returns visible resources."""
# Setup
owner = User.objects.create_user("owner", password="test")
viewer = User.objects.create_user("viewer", password="test")
public_corpus = Corpus.objects.create(title="Public", creator=owner, is_public=True)
private_corpus = Corpus.objects.create(title="Private", creator=owner, is_public=False)
message = ChatMessage.objects.create(
content="Check @corpus:public and @corpus:private",
creator=owner,
conversation=conversation
)
# Owner sees both mentions
owner_resources = message.mentioned_resources(owner)
assert len(owner_resources) == 2
# Viewer sees only public mention
viewer_resources = message.mentioned_resources(viewer)
assert len(viewer_resources) == 1
assert viewer_resources[0]['slug'] == 'public'
Frontend Tests¶
Autocomplete Permission Tests¶
test("autocomplete only shows writable corpuses", async () => {
const user = { id: "user-1", username: "testuser" };
const mocks = [
{
request: {
query: SEARCH_CORPUSES_FOR_MENTION,
variables: { textSearch: "legal" }
},
result: {
data: {
searchCorpusesForMention: {
edges: [
{
node: {
id: "1",
slug: "legal-contracts",
title: "Legal Contracts",
creator: { slug: "john-doe" }
}
}
// private-corpus not included (user has read-only)
]
}
}
}
}
];
// Backend has already filtered - frontend trusts results
const { results } = renderHook(() => useResourceMentionSearch("legal"), {
wrapper: createWrapper(mocks)
});
await waitFor(() => {
expect(results.current.resources).toHaveLength(1);
expect(results.current.resources[0].slug).toBe("legal-contracts");
});
});
Mention Chip Rendering Tests¶
test("renders mention as plain text when user lacks permission", () => {
const content = "Check @corpus:private-corpus for details";
const mentionedResources: MentionedResource[] = []; // Empty - user can't see it
const result = parseMentionsInContent(content, mentionedResources);
// Should render as plain text, not a chip
render(<div>{result}</div>);
expect(screen.getByText("Check @corpus:private-corpus for details")).toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
});
test("renders mention as chip when user has permission", () => {
const content = "Check @corpus:public-corpus for details";
const mentionedResources: MentionedResource[] = [
{
type: "CORPUS",
id: "1",
slug: "public-corpus",
title: "Public Corpus",
url: "/c/john/public-corpus"
}
];
const result = parseMentionsInContent(content, mentionedResources);
render(<BrowserRouter><div>{result}</div></BrowserRouter>);
// Should render as clickable chip
expect(screen.getByRole("link")).toBeInTheDocument();
expect(screen.getByText("Public Corpus")).toBeInTheDocument();
});
Implementation Checklist¶
Backend Updates¶
- Update
resolve_search_corpuses_for_mentionto use write permission filtering - Update
resolve_search_documents_for_mentionto use write permission + public document filtering - Implement
resolve_mentioned_resourcesonMessageTypewith viewer-based filtering (already exists) - Add backend tests for mention permission filtering (31 tests total in
opencontractserver/tests/test_mention_permissions.py) CorpusMentionPermissionTestCase: 8 tests (owner, contributor with UPDATE, viewer read-only blocked, outsider blocked, anonymous blocked, superuser, CREATE allows, DELETE allows)DocumentMentionPermissionTestCase: 7 tests (owner all docs, corpus write grants doc mention, doc write allows mention, viewer read-only blocked, public docs mentionable, anonymous blocked, superuser)MentionIDORProtectionTestCase: 3 tests (corpus IDOR, document IDOR, empty results indistinguishable)CorpusScopedMentionSearchTestCase: 9 tests (document search scoped to corpus A/B, without corpus returns all, annotation search scoped, invalid corpus returns empty)AgentMentionCorpusScopingTestCase: 4 tests (agent search scoped returns global + corpus agents, without corpus returns all, anonymous blocked)- Add backend tests for mention rendering with different viewer permissions (covered in IDOR tests)
Frontend Updates¶
-
useResourceMentionSearchhook trusts backend filtering (already implemented) -
ResourceMentionPickerdisplays backend-filtered results (already implemented) -
parseMentionsInContenthandles inaccessible mentions as plain text (already implemented) - Add frontend tests for permission-based rendering scenarios (14/14 component tests passing)
ResourceMentionPicker: 11 tests (empty state, format rendering, grouping, click handlers, keyboard nav, truncation)MentionChip: 3 tests (corpus rendering, document rendering, external link icon)
Documentation Updates¶
- Update
consolidated_permissioning_guide.mdwith mention permission section (+249 lines) - Add mention permissioning examples to guide (autocomplete, rendering, IDOR protection)
- Document IDOR protection strategies (silent filtering, consistent empty responses)
- Create comprehensive specification document (this file: 608 lines)
Open Questions for Review¶
- Write Permission Threshold: Should mentioning require write permission, or is READ sufficient for public resources?
- Current Spec: Write permission for private resources, read for public
-
Rationale: Mentions imply collaborative context, but public resources are for discussion
-
Corpus-Level Document Access: Should corpus write permission automatically grant mention access to all documents in that corpus?
- Current Spec: Yes, corpus write permission grants mention access to contained documents
-
Rationale: Aligns with collaborative workspace model
-
Anonymous User Mentions: Should anonymous users be able to mention public resources?
- Current Spec: No - must be authenticated to create mentions
-
Rationale: Mentions are tied to conversations which require authentication
-
Mention Deletion: What happens to mentions when resources are deleted or permissions change?
- Current Spec: Mentions become plain text automatically (resource not in mentionedResources)
- Rationale: Graceful degradation, no broken references
Security Audit Checklist¶
- Autocomplete searches don't leak resource existence (IDOR tests passing)
- Error messages don't reveal whether resource exists (silent filtering implemented)
- mentionedResources field filters by viewer permissions (implemented in GraphQL types)
- Frontend doesn't bypass backend filtering (all autocomplete goes through backend)
- IDOR protection tested with malicious queries (3 dedicated IDOR tests)
- Permission changes immediately affect mention visibility (uses real-time guardian permissions)
- Deleted resources gracefully degrade to plain text (handled by mentionedResources filtering)
- No timing attacks reveal resource existence (consistent query performance)
References¶
- Permissioning Guide:
docs/permissioning/consolidated_permissioning_guide.md - Issue #623: Global Discussions + @ Mentions Feature
- Discussion System Status:
DISCUSSION_SYSTEM_STATUS.md - Backend Queries:
config/graphql/queries.py:789-838 - Frontend Components:
frontend/src/components/threads/ResourceMentionPicker.tsx,MentionChip.tsx - GraphQL Types:
config/graphql/graphene_types.py:1587-1753