Spaces:
Build error
Build error
| # 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 | |