# Access Control System The PDF-TEI-Editor implements a multi-layered access control system that combines role-based access control (RBAC) with collection-based filtering. ## Overview The access control system operates at three levels: 1. **Role-Based Access Control (RBAC)** - Controls what operations users can perform 2. **Collection-Based Access Control** - Controls which documents users can see and edit 3. **Document-Level Access Control** - Controls visibility and editability of individual documents ## Architecture ### User → Groups → Collections Users are assigned to groups, and groups have access to specific collections. Documents belong to collections, and users can only access documents in collections their groups have access to. ``` User ↓ belongs to Groups (one or more) ↓ have access to Collections (one or more) ↓ contain Documents ``` ## Configuration Files Access control is configured through JSON files in the `data/db/` directory (or `config/` for defaults): ### users.json ```json [ { "username": "editor1", "fullname": "Jane Editor", "email": "editor1@example.com", "passwd_hash": "...", "roles": ["user", "annotator"], "groups": ["editors"], "session_id": null } ] ``` ### groups.json ```json [ { "id": "editors", "name": "Editors Group", "description": "Editors with access to manuscripts", "collections": ["manuscripts", "letters"] } ] ``` ### collections.json ```json [ { "id": "manuscripts", "name": "Medieval Manuscripts", "description": "Collection of medieval manuscript transcriptions" } ] ``` ### roles.json ```json [ { "id": "user", "roleName": "User", "description": "Basic user with read access" }, { "id": "annotator", "roleName": "Annotator", "description": "Can create and edit version files" }, { "id": "reviewer", "roleName": "Reviewer", "description": "Can edit gold standard files and promote versions" } ] ``` ## Wildcard Access (`*`) The wildcard `*` character grants unrestricted access at any level: ### User-Level Wildcards **Wildcard Roles**: Users with `"roles": ["*"]` have all permissions ```json { "username": "superadmin", "roles": ["*"], "groups": ["admin"] } ``` **Admin Role**: Users with `"roles": ["admin"]` have access to all collections ```json { "username": "admin", "roles": ["admin", "user"], "groups": ["staff"] } ``` **Wildcard Groups**: Users with `"groups": ["*"]` have access to all collections ```json { "username": "manager", "roles": ["user"], "groups": ["*"] } ``` ### Group-Level Wildcards **Wildcard Collections**: Groups with `"collections": ["*"]` grant access to all collections ```json { "id": "admin-group", "name": "Administrators", "collections": ["*"] } ``` ## Collection Access Resolution The system resolves collection access using this priority order: 1. **Check for wildcard roles** - If user has `*` or `admin` in roles → access to all collections 2. **Check for wildcard groups** - If user has `*` in groups → access to all collections 3. **Check each group** - For each group the user belongs to: - If group has `*` in collections → access to all collections - Otherwise, collect all specific collection IDs from the group 4. **Return result**: - `null` (if any wildcard was found) = access to all collections - `[]` (empty list) = no collection access - `["col1", "col2"]` = access to specific collections ## Implementation ### Helper Functions The [user_utils.py](../../fastapi_app/lib/permissions/user_utils.py) module provides collection access helpers: #### `get_user_collections(user, db_dir)` Returns the list of collections accessible to a user. ```python from fastapi_app.lib.permissions.user_utils import get_user_collections # Get accessible collections accessible_collections = get_user_collections(current_user, settings.db_dir) if accessible_collections is None: # User has access to all collections pass elif not accessible_collections: # User has no collection access pass else: # User has access to specific collections print(f"User can access: {accessible_collections}") ``` **Returns:** - `None` - User has access to all collections (wildcard) - `[]` - User has no collection access (anonymous or no groups) - `["col1", "col2", ...]` - User has access to specific collections #### `user_has_collection_access(user, collection_id, db_dir)` Checks if a user has access to a specific collection. ```python from fastapi_app.lib.permissions.user_utils import user_has_collection_access # Check access to specific collection if user_has_collection_access(current_user, 'manuscripts', settings.db_dir): # User has access pass ``` **Returns:** `True` if user has access, `False` otherwise ### API Endpoints #### Files List Endpoint The `/api/v1/files/list` endpoint ([files_list.py:155-178](../../fastapi_app/routers/files_list.py#L155-L178)) filters files based on collection access: ```python # Get user's accessible collections accessible_collections = get_user_collections(current_user, settings.db_dir) if accessible_collections is not None: # Filter documents by collections files_data = [] for doc_group in documents_map.values(): doc_collections = doc_group.collections or [] # Include if document has any accessible collection if any(col in accessible_collections for col in doc_collections): files_data.append(doc_group) else: # User has access to all collections files_data = list(documents_map.values()) # Apply document-level access control files_data = DocumentAccessFilter.filter_files_by_access(files_data, current_user) ``` #### File Save Endpoint The `/api/v1/files/save` endpoint ([files_save.py:56-89](../../fastapi_app/routers/files_save.py#L56-L89)) validates collection access before saving: ```python def _validate_collection_access(user, doc_collections, db_dir, logger_inst): """Validate user has access to document's collections.""" # Documents must have at least one collection if not doc_collections: raise HTTPException(403, "Cannot save file: document has no collections") # Check if user has access to any of the document's collections has_access = any( user_has_collection_access(user, col_id, db_dir) for col_id in doc_collections ) if not has_access: raise HTTPException( 403, f"You do not have access to any of this document's collections: {', '.join(doc_collections)}" ) ``` This validation is performed before: - Updating existing files - Creating new versions - Creating new gold standard files ## Document Collections Documents inherit their collections from their source PDF file. All TEI artifacts (gold standards and versions) for a document share the same collections. ```python # Get PDF file to inherit collections pdf_file = file_repo.get_pdf_for_document(doc_id) doc_collections = pdf_file.doc_collections if pdf_file else [] # Create file with inherited collections created_file = file_repo.insert_file(FileCreate( id=saved_hash, doc_id=doc_id, doc_collections=doc_collections, # Inherited from PDF # ... other fields )) ``` ## RBAC Manager Plugin The frontend RBAC Manager plugin ([app/src/plugins/rbac-manager.js](../../app/src/plugins/rbac-manager.js)) provides a user interface for managing access control. It allows administrators to: - View and manage users, groups, and collections - Assign users to groups - Configure group collection access - Manage user roles The RBAC Manager integrates with the backend API endpoints to provide real-time access control management. ## Management CLI The `bin/manage.py` script provides commands to manage users, groups, and collections from the command line. ### User Management ```bash # Add user to group ./bin/manage.py user add-group editor1 editors # Remove user from group ./bin/manage.py user remove-group editor1 editors # List users with their groups ./bin/manage.py user list ``` ### Group Management ```bash # Create group ./bin/manage.py group add editors "Editors Group" --description "Manuscript editors" # Add collection to group ./bin/manage.py group add-collection editors manuscripts # Add wildcard collection access ./bin/manage.py group add-collection admin-group "*" # Remove collection from group ./bin/manage.py group remove-collection editors manuscripts # List groups with their collections ./bin/manage.py group list ``` ### Collection Management ```bash # Create collection ./bin/manage.py collection add manuscripts "Manuscripts" --description "Medieval manuscripts" # List collections ./bin/manage.py collection list ``` ## Access Control Flow ### Reading Files (GET /api/v1/files/list) ``` 1. User authenticates 2. System loads user's groups from users.json 3. System resolves accessible collections: - Check for wildcards (*, admin role) - Collect collections from all user's groups 4. System queries all files from database 5. System filters files by collections: - Keep only documents in accessible collections 6. System applies document-level ACL: - Check visibility (public/private) - Check editability (editable/protected) 7. Return filtered file list ``` ### Writing Files (POST /api/v1/files/save) ``` 1. User authenticates 2. System determines document collections: - For existing files: use file's doc_collections - For new files: inherit from PDF's doc_collections 3. System validates collection access: - Resolve user's accessible collections - Check if user has access to any doc collection - Reject if no access 4. System validates role permissions: - Check if user can edit gold/versions 5. System saves file ``` ## Security Considerations ### Collection Requirement Documents **must** belong to at least one collection. Attempting to save a file with no collections will result in an HTTP 403 error. ### Anonymous Access Anonymous users (not authenticated) have: - No collection access - Only read access to public documents - No write access ### Wildcard Security Use wildcards carefully: - `"roles": ["*"]` grants superuser permissions - `"groups": ["*"]` grants access to all collections - `"collections": ["*"]` in a group grants all members access to all collections ### Collection Inheritance Collections are inherited from the source PDF file. Users cannot: - Change a document's collections via the save endpoint - Create files in collections they don't have access to - Edit files in collections they don't have access to ## Testing The [test_collection_access_control.py](../../tests/unit/fastapi/test_collection_access_control.py) test suite verifies: - Anonymous user has no collection access - Wildcard roles grant access to all collections - Admin role grants access to all collections - Wildcard groups grant access to all collections - Specific group collections work correctly - Multiple group memberships combine collections - Group wildcard collections grant access to all - Users with no groups have no collection access - Nonexistent groups don't grant access - Mixed wildcard and specific groups resolve correctly ### Test Isolation The tests use **isolated temporary directories** and do not modify the main application's data files in `data/db/`. Each test: 1. Creates a `tempfile.TemporaryDirectory()` for the test database 2. Passes the temporary `db_dir` to all utility functions 3. Cleans up the temporary directory after the test completes This ensures tests are: - **Safe** - Never modify production/development data - **Fast** - No I/O to main filesystem - **Isolated** - Each test has a clean environment - **Repeatable** - No side effects between test runs Run tests: ```bash uv run python -m pytest tests/unit/fastapi/test_collection_access_control.py -v ``` ## Examples ### Example 1: Basic Editor Setup ```json // users.json { "username": "editor1", "roles": ["user", "annotator"], "groups": ["manuscript-editors"] } // groups.json { "id": "manuscript-editors", "name": "Manuscript Editors", "collections": ["manuscripts"] } ``` **Result:** User can see and edit documents in the `manuscripts` collection. ### Example 2: Multi-Collection Access ```json // users.json { "username": "researcher1", "roles": ["user"], "groups": ["manuscripts-group", "letters-group"] } // groups.json [ { "id": "manuscripts-group", "collections": ["manuscripts"] }, { "id": "letters-group", "collections": ["letters", "correspondence"] } ] ``` **Result:** User can access documents in `manuscripts`, `letters`, and `correspondence` collections. ### Example 3: Administrator Setup ```json // users.json { "username": "admin", "roles": ["admin", "reviewer"], "groups": ["admin-group"] } // groups.json { "id": "admin-group", "name": "Administrators", "collections": ["*"] } ``` **Result:** User has access to all collections and all permissions. ### Example 4: Project Manager ```json // users.json { "username": "pm1", "roles": ["user"], "groups": ["*"] } ``` **Result:** User has access to all collections but limited to user-level permissions (read-only). ## Migration Notes If migrating from a system without collection-based access control: 1. **Create default collection**: ```bash ./bin/manage.py collection add default "Default Collection" ``` 2. **Create default group**: ```bash ./bin/manage.py group add default "Default Group" ./bin/manage.py group add-collection default default ``` 3. **Add all users to default group**: ```bash ./bin/manage.py user add-group user1 default ./bin/manage.py user add-group user2 default # ... for all users ``` 4. **Assign collections to existing documents** (requires database update): ```sql UPDATE files SET doc_collections = '["default"]' WHERE doc_collections IS NULL; ``` ## Document-Level Access Control Modes In addition to collection-based access control, the system supports three modes for document-level permissions. This is configured application-wide via `access-control.mode` in `config/config.json`. ### Configuration ```json { "access-control.mode": "role-based", "access-control.default-visibility": "collection", "access-control.default-editability": "owner" } ``` **Mode values:** - `role-based` (default) - No document-level permissions, only role restrictions - `owner-based` - Documents editable only by their creator - `granular` - Database-backed per-document visibility and editability settings ### Mode 1: Role-Based (Default) In role-based mode, document access is determined solely by user roles and file types: - **Gold files**: Only reviewers can edit - **Version files**: Annotators and reviewers can edit - **Deletion**: Only reviewers and document owners can delete No document-level permission UI is shown. This mode is suitable for teams where role-based restrictions are sufficient. ### Mode 2: Owner-Based In owner-based mode, documents are editable only by their creator: - Documents are read-only for everyone except the owner - Reviewers can delete any document - To edit a non-owned document, users must create their own version When a non-owner opens a document, they see a notification: "This document is owned by [username]. Create your own version to edit." ### Mode 3: Granular Granular mode provides per-document visibility and editability settings stored in a SQLite database (`data/db/permissions.db`). **Permission attributes:** - `visibility`: `'collection'` (visible to all with collection access) or `'owner'` (visible only to owner and reviewers) - `editability`: `'collection'` (editable by all) or `'owner'` (editable only by owner) - `owner`: Username of the document creator **UI:** Owners and reviewers see two toggle switches in the status bar: - **Visibility switch**: Toggle between "Visible to all" and "Visible to owner" - **Editability switch**: Toggle between "Editable by all" and "Editable by owner" **Default permissions for new documents:** - `visibility`: value of `access-control.default-visibility` (default: `collection`) - `editability`: value of `access-control.default-editability` (default: `owner`) ### Backend Implementation **Core modules:** - [access_control.py](../../fastapi_app/lib/permissions/access_control.py) - Mode-aware permission checking functions - [acl_utils.py](../../fastapi_app/lib/permissions/acl_utils.py) - Role checking and high-level permission API - [permissions_db.py](../../fastapi_app/lib/repository/permissions_db.py) - SQLite database for granular permissions **API endpoints (granular mode only):** - `GET /api/v1/files/access_control_mode` - Returns current mode and defaults - `GET /api/v1/files/permissions/{stable_id}` - Get permissions for a document - `POST /api/v1/files/set_permissions` - Set permissions for a document **Permission checking functions:** ```python from fastapi_app.lib.permissions.access_control import ( can_view_document, can_edit_document, can_delete_document, can_modify_permissions, can_promote_demote, check_file_access, # Backwards-compatible wrapper DocumentAccessFilter # For filtering file lists ) ``` **High-level API (handles mode internally):** ```python from fastapi_app.lib.permissions.acl_utils import ( get_access_control_mode, get_file_permissions, set_default_permissions_for_new_file, delete_permissions_for_file ) ``` ### Frontend Implementation The [access-control.js](../../app/src/plugins/access-control.js) plugin: 1. Fetches the access control mode on startup via `filesAccessControlMode()` 2. Shows/hides UI elements based on mode 3. In granular mode, displays switches for owners/reviewers to modify permissions 4. Enforces read-only state when user lacks edit permissions **ACL utilities in frontend:** ```javascript import { canEditDocumentWithPermissions, canViewDocumentWithPermissions, canEditFile, userHasReviewerRole, userHasAnnotatorRole } from '../modules/acl-utils.js' ``` ### Database Schema (Granular Mode) ```sql CREATE TABLE document_permissions ( stable_id TEXT PRIMARY KEY, visibility TEXT NOT NULL DEFAULT 'collection', editability TEXT NOT NULL DEFAULT 'owner', owner TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, CHECK (visibility IN ('collection', 'owner')), CHECK (editability IN ('collection', 'owner')) ) ``` ### Reviewer Override Rules Reviewers have elevated privileges but with intentional limitations: - **Can always**: View all documents, delete any document, modify permissions - **Cannot**: Edit documents with `editability: 'owner'` when not the owner (prevents accidental overwriting) - To edit such documents, reviewers must create their own version or change the editability first ## Troubleshooting ### User cannot see any documents **Check:** 1. User has at least one group: `./bin/manage.py user list` 2. Groups have collections: `./bin/manage.py group list` 3. Documents have collections assigned (check `doc_collections` in database) ### User can see documents but cannot edit **Check:** 1. User has required role (annotator/reviewer) for the operation 2. User has collection access to the document 3. Document's visibility/editability settings (document-level ACL) ### Wildcard not working **Check:** 1. Wildcard is exactly `"*"` (string with single asterisk) 2. Wildcard is in the correct field (roles/groups/collections) 3. No typos in JSON configuration files