diff --git a/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py new file mode 100644 index 0000000000..eabf1860a1 --- /dev/null +++ b/backend/open_webui/migrations/versions/f1e2d3c4b5a6_add_access_grant_table.py @@ -0,0 +1,350 @@ +"""Add access_grant table + +Revision ID: f1e2d3c4b5a6 +Revises: 8452d01d26d7 +Create Date: 2026-02-05 10:00:00.000000 + +Migrates from JSON access_control columns to normalized access_grant table. +Access control semantics: +- NULL: Public access (all users can read) -> insert user:* for read +- {}: Private/owner-only (no grants) -> insert nothing +- {read: {...}, write: {...}}: Custom permissions -> insert specific grants +""" + +from typing import Sequence, Union +import time +import uuid + +from alembic import op +import sqlalchemy as sa + +from open_webui.migrations.util import get_existing_tables + +revision: str = "f1e2d3c4b5a6" +down_revision: Union[str, None] = "8452d01d26d7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + existing_tables = set(get_existing_tables()) + + # Create access_grant table + if "access_grant" not in existing_tables: + op.create_table( + "access_grant", + sa.Column("id", sa.Text(), nullable=False, primary_key=True), + sa.Column("resource_type", sa.Text(), nullable=False), + sa.Column("resource_id", sa.Text(), nullable=False), + sa.Column("principal_type", sa.Text(), nullable=False), + sa.Column("principal_id", sa.Text(), nullable=False), + sa.Column("permission", sa.Text(), nullable=False), + sa.Column("created_at", sa.BigInteger(), nullable=False), + sa.UniqueConstraint( + "resource_type", + "resource_id", + "principal_type", + "principal_id", + "permission", + name="uq_access_grant_grant", + ), + ) + op.create_index( + "idx_access_grant_resource", + "access_grant", + ["resource_type", "resource_id"], + ) + op.create_index( + "idx_access_grant_principal", + "access_grant", + ["principal_type", "principal_id"], + ) + + # Backfill existing access_control JSON data + conn = op.get_bind() + + # Tables with access_control JSON columns: (table_name, resource_type) + resource_tables = [ + ("knowledge", "knowledge"), + ("prompt", "prompt"), + ("tool", "tool"), + ("model", "model"), + ("note", "note"), + ("channel", "channel"), + ("file", "file"), + ] + + now = int(time.time()) + inserted = set() + + for table_name, resource_type in resource_tables: + if table_name not in existing_tables: + continue + + # Query all rows + try: + result = conn.execute( + sa.text(f'SELECT id, access_control FROM "{table_name}"') + ) + rows = result.fetchall() + except Exception: + continue + + for row in rows: + resource_id = row[0] + access_control_json = row[1] + + # Handle NULL or JSON "null" = public access (user:* for read) + # Could be Python None (SQL NULL) or string "null" (JSON null) + # EXCEPTION: files with NULL are PRIVATE (owner-only), not public + is_null = ( + access_control_json is None or + access_control_json == "null" or + (isinstance(access_control_json, str) and access_control_json.strip().lower() == "null") + ) + if is_null: + # Files: NULL = private (no entry needed, owner has implicit access) + # Other resources: NULL = public (insert user:* for read) + if resource_type == "file": + continue # Private - no entry needed + + key = (resource_type, resource_id, "user", "*", "read") + if key not in inserted: + try: + conn.execute( + sa.text( + """ + INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at) + VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) + """ + ), + { + "id": str(uuid.uuid4()), + "resource_type": resource_type, + "resource_id": resource_id, + "principal_type": "user", + "principal_id": "*", + "permission": "read", + "created_at": now, + }, + ) + inserted.add(key) + except Exception: + pass + continue + + # Handle JSON parsing + if isinstance(access_control_json, str): + import json + + try: + access_control_json = json.loads(access_control_json) + except Exception: + continue + + # Handle {} = private/owner-only - NO entries needed + # Owner access is implicit, no grants to store + if not access_control_json or not isinstance(access_control_json, dict): + continue + + # Check if it's effectively empty (no read/write keys with content) + read_data = access_control_json.get("read", {}) + write_data = access_control_json.get("write", {}) + + has_read_grants = read_data.get("group_ids", []) or read_data.get( + "user_ids", [] + ) + has_write_grants = write_data.get("group_ids", []) or write_data.get( + "user_ids", [] + ) + + if not has_read_grants and not has_write_grants: + # Empty permissions = private, no grants needed + continue + + # Extract permissions and insert into access_grant table + for permission in ["read", "write"]: + perm_data = access_control_json.get(permission, {}) + if not perm_data: + continue + + for group_id in perm_data.get("group_ids", []): + key = (resource_type, resource_id, "group", group_id, permission) + if key in inserted: + continue + try: + conn.execute( + sa.text( + """ + INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at) + VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) + """ + ), + { + "id": str(uuid.uuid4()), + "resource_type": resource_type, + "resource_id": resource_id, + "principal_type": "group", + "principal_id": group_id, + "permission": permission, + "created_at": now, + }, + ) + inserted.add(key) + except Exception: + pass + + for user_id in perm_data.get("user_ids", []): + key = (resource_type, resource_id, "user", user_id, permission) + if key in inserted: + continue + try: + conn.execute( + sa.text( + """ + INSERT INTO access_grant (id, resource_type, resource_id, principal_type, principal_id, permission, created_at) + VALUES (:id, :resource_type, :resource_id, :principal_type, :principal_id, :permission, :created_at) + """ + ), + { + "id": str(uuid.uuid4()), + "resource_type": resource_type, + "resource_id": resource_id, + "principal_type": "user", + "principal_id": user_id, + "permission": permission, + "created_at": now, + }, + ) + inserted.add(key) + except Exception: + pass + + # Drop access_control columns from resource tables + for table_name, _ in resource_tables: + if table_name not in existing_tables: + continue + try: + op.drop_column(table_name, "access_control") + except Exception: + pass + + +def downgrade() -> None: + import json + + conn = op.get_bind() + + # Resource tables mapping: (table_name, resource_type) + resource_tables = [ + ("knowledge", "knowledge"), + ("prompt", "prompt"), + ("tool", "tool"), + ("model", "model"), + ("note", "note"), + ("channel", "channel"), + ("file", "file"), + ] + + # Step 1: Re-add access_control columns to resource tables + for table_name, _ in resource_tables: + try: + op.add_column( + table_name, sa.Column("access_control", sa.JSON(), nullable=True) + ) + except Exception: + pass + + # Step 2: Query access_grant table and reconstruct JSON for each resource + for table_name, resource_type in resource_tables: + try: + # Get all grants for this resource type + result = conn.execute( + sa.text(""" + SELECT resource_id, principal_type, principal_id, permission + FROM access_grant + WHERE resource_type = :resource_type + """), + {"resource_type": resource_type} + ) + rows = result.fetchall() + except Exception: + continue + + # Group by resource_id and reconstruct JSON structure + resource_grants = {} + for row in rows: + resource_id = row[0] + principal_type = row[1] + principal_id = row[2] + permission = row[3] + + if resource_id not in resource_grants: + resource_grants[resource_id] = { + "is_public": False, + "read": {"group_ids": [], "user_ids": []}, + "write": {"group_ids": [], "user_ids": []}, + } + + # Handle public access (user:* for read) + if principal_type == "user" and principal_id == "*" and permission == "read": + resource_grants[resource_id]["is_public"] = True + continue + + # Add to appropriate list + if permission in ["read", "write"]: + if principal_type == "group": + if principal_id not in resource_grants[resource_id][permission]["group_ids"]: + resource_grants[resource_id][permission]["group_ids"].append(principal_id) + elif principal_type == "user": + if principal_id not in resource_grants[resource_id][permission]["user_ids"]: + resource_grants[resource_id][permission]["user_ids"].append(principal_id) + + # Step 3: Update each resource with reconstructed JSON + for resource_id, grants in resource_grants.items(): + if grants["is_public"]: + # Public = NULL + access_control_value = None + elif (not grants["read"]["group_ids"] and not grants["read"]["user_ids"] and + not grants["write"]["group_ids"] and not grants["write"]["user_ids"]): + # No grants = should not happen (would mean no entries), default to {} + access_control_value = json.dumps({}) + else: + # Custom permissions + access_control_value = json.dumps({ + "read": grants["read"], + "write": grants["write"], + }) + + try: + conn.execute( + sa.text(f'UPDATE "{table_name}" SET access_control = :access_control WHERE id = :id'), + {"access_control": access_control_value, "id": resource_id} + ) + except Exception: + pass + + # Step 4: Set all resources WITHOUT entries to private + # For files: NULL means private (owner-only), so leave as NULL + # For other resources: {} means private, so update to {} + if resource_type != "file": + try: + conn.execute( + sa.text(f''' + UPDATE "{table_name}" + SET access_control = :private_value + WHERE id NOT IN ( + SELECT DISTINCT resource_id FROM access_grant WHERE resource_type = :resource_type + ) + AND access_control IS NULL + '''), + {"private_value": json.dumps({}), "resource_type": resource_type} + ) + except Exception: + pass + # For files, NULL stays NULL - no action needed + + # Step 5: Drop the access_grant table + op.drop_index("idx_access_grant_principal", table_name="access_grant") + op.drop_index("idx_access_grant_resource", table_name="access_grant") + op.drop_table("access_grant")