Skip to content

OpenContracts Sharing Architecture

Status: This document describes the current state and planned improvements for corpus/document sharing.

Table of Contents

  1. Current State
  2. Architecture Overview
  3. Phase 1: Owner-Controlled Public Visibility
  4. Phase 2: User-Level Sharing

Current State

What Works

Feature Status Notes
Permission types defined ✅ PERMISSION, READ, UPDATE, DELETE, CREATE, PUBLISH, COMMENT
Permission storage ✅ Django Guardian with {Model}UserObjectPermission tables
myPermissions GraphQL field ✅ Users can see their own permissions on objects
objectSharedWith GraphQL field ✅ Shows all users with access (read-only)
set_permissions_for_obj_to_user() ✅ Core utility function for granting permissions
Make corpus public (superuser) ✅ MakeCorpusPublic mutation, superuser-only

What's Missing

Feature Status Impact
Owner can make corpus public ❌ Creators must ask superusers to make corpuses public
Share with specific users ❌ No mutation exists to grant another user access
PERMISSION type enforcement ❌ Backend doesn't check PERMISSION for is_public changes
Creators get PERMISSION ❌ Creators only get CRUD+PUBLISH, not PERMISSION

Security Gap

The frontend checks CAN_PERMISSION before allowing visibility changes, but the backend UpdateCorpusMutation only checks UPDATE permission:

# Backend: base.py DRFMutation
if not obj.user_can(user, PermissionTypes.UPDATE):  # Only UPDATE!
    raise PermissionError(...)
// Frontend: CorpusSettings.tsx
const canPermission = permissions.includes(PermissionTypes.CAN_PERMISSION);
// UI disabled if !canPermission, but backend doesn't enforce this

Architecture Overview

Permission Flow

┌─────────────────────────────────────────────────────────────────────┐
│                        Permission System                             │
├─────────────────────────────────────────────────────────────────────┤
│                                                                      │
│  ┌──────────────┐    ┌──────────────────┐    ┌──────────────────┐  │
│  │   Frontend   │    │     GraphQL      │    │     Backend      │  │
│  │              │    │                  │    │                  │  │
│  │ CAN_PERMISSION│───▶│ myPermissions   │◀───│ permission_corpus│  │
│  │ CAN_UPDATE   │    │ objectSharedWith │    │ update_corpus    │  │
│  │ CAN_READ     │    │                  │    │ read_corpus      │  │
│  └──────────────┘    └──────────────────┘    └──────────────────┘  │
│         │                    │                       │              │
│         ▼                    ▼                       ▼              │
│  ┌──────────────┐    ┌──────────────────┐    ┌──────────────────┐  │
│  │  UI Gating   │    │    Mutations     │    │ Django Guardian  │  │
│  │  (cosmetic)  │    │  (enforcement)   │    │   (storage)      │  │
│  └──────────────┘    └──────────────────┘    └──────────────────┘  │
│                                                                      │
└─────────────────────────────────────────────────────────────────────┘

Key Files

File Purpose
opencontractserver/utils/permissioning.py Core permission utilities
opencontractserver/types/enums.py PermissionTypes enum
config/graphql/base.py DRFMutation base class with permission checks
config/graphql/mutations.py UpdateCorpusMutation, MakeCorpusPublic
config/graphql/permissioning/permission_annotator/mixins.py myPermissions, objectSharedWith resolvers
opencontractserver/utils/sharing.py make_corpus_public() logic
frontend/src/components/corpuses/CorpusSettings.tsx UI for visibility settings

Permission Types Reference

Type Backend Codename Frontend Enum Purpose
READ read_corpus CAN_READ View corpus and contents
CREATE create_corpus CAN_CREATE Add documents/annotations
UPDATE update_corpus CAN_UPDATE Edit corpus metadata
DELETE remove_corpus CAN_REMOVE Delete corpus
PUBLISH publish_corpus CAN_PUBLISH Make corpus public
PERMISSION permission_corpus CAN_PERMISSION Manage access/sharing
COMMENT comment_corpus CAN_COMMENT Add comments

Phase 1: Owner-Controlled Public Visibility

Goal

Allow corpus creators (owners) to toggle their corpus's public visibility without requiring superuser intervention.

Requirements

  1. Owners can set is_public: Creator of a corpus should be able to make it public/private
  2. Backend enforcement: Server-side check, not just UI gating
  3. Cascading visibility: When making public, related objects should also become public
  4. Backward compatible: Existing permissions and workflows unchanged

Implementation Plan

1.1 Update Permission Grant on Corpus Creation

File: config/graphql/mutations.py

Change: Grant PERMISSION and PUBLISH to creators

# In CreateCorpusMutation.mutate() around line 2562-2566
# BEFORE:
set_permissions_for_obj_to_user(
    info.context.user,
    corpus,
    [PermissionTypes.CRUD, PermissionTypes.PUBLISH],
)

# AFTER:
set_permissions_for_obj_to_user(
    info.context.user,
    corpus,
    [PermissionTypes.CRUD, PermissionTypes.PUBLISH, PermissionTypes.PERMISSION],
)

1.2 Create SetCorpusVisibility Mutation

File: config/graphql/mutations.py

New mutation that properly checks permissions and handles cascading:

class SetCorpusVisibility(graphene.Mutation):
    """
    Set corpus visibility (public/private).

    Requires either:
    - User is corpus creator (owner), OR
    - User has PERMISSION permission on corpus, OR
    - User is superuser
    """

    class Arguments:
        corpus_id = graphene.ID(required=True)
        is_public = graphene.Boolean(required=True)

    ok = graphene.Boolean()
    message = graphene.String()

    @login_required
    @graphql_ratelimit(rate=RateLimits.WRITE_MEDIUM)
    def mutate(root, info, corpus_id, is_public):
        user = info.context.user
        corpus_pk = from_global_id(corpus_id)[1]

        try:
            corpus = Corpus.objects.get(pk=corpus_pk)
        except Corpus.DoesNotExist:
            return SetCorpusVisibility(ok=False, message="Corpus not found")

        # Permission check: owner OR has PERMISSION OR superuser
        can_change_visibility = (
            user.is_superuser or
            corpus.creator_id == user.id or
            corpus.user_can(user, PermissionTypes.PERMISSION)
        )

        if not can_change_visibility:
            return SetCorpusVisibility(
                ok=False,
                message="You don't have permission to change this corpus's visibility"
            )

        if is_public:
            # Use existing make_corpus_public logic (async task)
            make_corpus_public_task.si(corpus_id=corpus_pk).apply_async()
            return SetCorpusVisibility(
                ok=True,
                message="Making corpus public. This may take a moment for large corpuses."
            )
        else:
            # Make private (simpler - just update the flag)
            corpus.is_public = False
            corpus.save(update_fields=['is_public'])
            return SetCorpusVisibility(ok=True, message="Corpus is now private")

1.3 Remove is_public from UpdateCorpusMutation

File: config/graphql/mutations.py

Change: Remove is_public from UpdateCorpusMutation.Arguments to prevent bypassing the proper visibility mutation:

class UpdateCorpusMutation(DRFMutation):
    class Arguments:
        id = graphene.String(required=True)
        title = graphene.String(required=False)
        description = graphene.String(required=False)
        icon = graphene.String(required=False)
        label_set = graphene.String(required=False)
        preferred_embedder = graphene.String(required=False)
        slug = graphene.String(required=False)
        # REMOVED: is_public = graphene.Boolean(required=False)
        corpus_agent_instructions = graphene.String(required=False)
        document_agent_instructions = graphene.String(required=False)

Also remove is_public from the serializer or make it read-only:

File: config/graphql/serializers.py

class CorpusSerializer(serializers.ModelSerializer):
    class Meta:
        model = Corpus
        fields = [...]
        read_only_fields = ["id", "is_public"]  # Add is_public to read_only

1.4 Update Frontend to Use New Mutation

File: frontend/src/graphql/mutations.ts

export const SET_CORPUS_VISIBILITY = gql`
  mutation SetCorpusVisibility($corpusId: ID!, $isPublic: Boolean!) {
    setCorpusVisibility(corpusId: $corpusId, isPublic: $isPublic) {
      ok
      message
    }
  }
`;

File: frontend/src/components/corpuses/CorpusSettings.tsx

Update to use the new mutation instead of UPDATE_CORPUS for visibility changes.

1.5 Add Migration for Existing Creators

File: New migration in opencontractserver/corpuses/migrations/

Grant PERMISSION to all existing corpus creators:

def grant_permission_to_creators(apps, schema_editor):
    from guardian.shortcuts import assign_perm
    Corpus = apps.get_model('corpuses', 'Corpus')

    for corpus in Corpus.objects.select_related('creator').iterator():
        if corpus.creator:
            assign_perm('permission_corpus', corpus.creator, corpus)

class Migration(migrations.Migration):
    operations = [
        migrations.RunPython(grant_permission_to_creators, migrations.RunPython.noop),
    ]

Testing Requirements

  1. Unit tests for SetCorpusVisibility mutation:
  2. Owner can make public/private
  3. User with PERMISSION can make public/private
  4. User with only UPDATE cannot change visibility
  5. Superuser can change any corpus
  6. Non-owner without PERMISSION is denied

  7. Integration tests:

  8. Visibility change cascades to documents, annotations, etc.
  9. Frontend correctly gates based on permissions

Phase 2: User-Level Sharing

Goal

Allow corpus owners to share their corpuses with specific users, granting them various permission levels.

Requirements

  1. Share mutation: Grant another user access to a corpus
  2. Revoke mutation: Remove a user's access
  3. Permission levels: Support granting specific permissions (READ, UPDATE, etc.)
  4. UI for sharing: Modal or panel to manage who has access
  5. Notifications: Optionally notify users when shared with them

Implementation Plan (High-Level)

2.1 Backend Mutations

class ShareCorpus(graphene.Mutation):
    """Share a corpus with another user."""
    class Arguments:
        corpus_id = graphene.ID(required=True)
        user_id = graphene.ID(required=True)
        permissions = graphene.List(graphene.String, required=True)

    # Requires PERMISSION on corpus
    # Calls set_permissions_for_obj_to_user()

class RevokeCorpusAccess(graphene.Mutation):
    """Remove a user's access to a corpus."""
    class Arguments:
        corpus_id = graphene.ID(required=True)
        user_id = graphene.ID(required=True)

    # Requires PERMISSION on corpus
    # Calls set_permissions_for_obj_to_user(user, corpus, [])

2.2 Frontend Components

  • ShareCorpusModal: UI for searching users and granting access
  • CorpusAccessList: Display current users with access
  • PermissionSelector: Dropdown for selecting permission levels

2.3 User Search Query

query SearchUsersForSharing($query: String!) {
  searchUsers(query: $query) {
    edges {
      node {
        id
        username
        email
      }
    }
  }
}

2.4 Notifications Integration

When sharing, optionally create a notification:

Notification.objects.create(
    recipient=target_user,
    notification_type=NotificationTypes.CORPUS_SHARED,
    actor=sharing_user,
    target_corpus=corpus,
    message=f"{sharing_user.username} shared '{corpus.title}' with you"
)

Security Considerations

  1. IDOR Prevention: Use visible_to_user() for user search to prevent enumeration
  2. Permission Escalation: Cannot grant permissions you don't have yourself
  3. Rate Limiting: Apply rate limits to prevent abuse
  4. Audit Trail: Log permission changes for security auditing

Migration Path

Phase 1 Rollout

  1. Deploy backend changes (new mutation, migration)
  2. Update frontend to use new mutation
  3. Remove deprecated is_public from UpdateCorpusMutation
  4. Monitor for issues

Phase 2 Rollout

  1. Deploy sharing mutations
  2. Deploy user search functionality
  3. Deploy frontend sharing UI
  4. Add notifications
  5. Documentation and user communication

Appendix: Current Permission Assignment

When Corpus is Created

# mutations.py:2562-2566
set_permissions_for_obj_to_user(
    user, corpus,
    [PermissionTypes.CRUD, PermissionTypes.PUBLISH]  # Missing PERMISSION!
)

Permission Types Breakdown

Permission Includes
CRUD CREATE, READ, UPDATE, DELETE
ALL CRUD + COMMENT + PUBLISH + PERMISSION

Relevant Database Tables

  • corpuses_corpususerobjectpermission - User-level permissions
  • corpuses_corpusgroupobjectpermission - Group-level permissions
  • auth_permission - Permission definitions