diff --git a/alembic/versions/0100_restrict_delete_grants.py b/alembic/versions/0100_restrict_delete_grants.py new file mode 100644 index 0000000..84581ab --- /dev/null +++ b/alembic/versions/0100_restrict_delete_grants.py @@ -0,0 +1,46 @@ +"""Restrict DELETE grants on sensitive tables. + +Removes DELETE privilege from crm_api and crm_worker on: +api_tokens, audit_log, notification_types, password_reset_tokens, +plugin_allowlist, plugin_migrations, plugins, sessions, +tenant_plugin_activation, tenants, user_tenants, users. + +crm_auth keeps DELETE on sessions + password_reset_tokens (for logout/reset). + +Revision ID: 0100 +""" + +from alembic import op + +revision = "0100" +down_revision = "0099" +branch_labels = None +depends_on = None + +# Tables where DELETE must be removed from crm_api and crm_worker +SENSITIVE_TABLES = [ + "api_tokens", + "audit_log", + "notification_types", + "password_reset_tokens", + "plugin_allowlist", + "plugin_migrations", + "plugins", + "sessions", + "tenant_plugin_activation", + "tenants", + "user_tenants", + "users", +] + + +def upgrade() -> None: + for table in SENSITIVE_TABLES: + op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_api;") + op.execute(f"REVOKE DELETE ON TABLE {table} FROM crm_worker;") + + +def downgrade() -> None: + for table in SENSITIVE_TABLES: + op.execute(f"GRANT DELETE ON TABLE {table} TO crm_api;") + op.execute(f"GRANT DELETE ON TABLE {table} TO crm_worker;") diff --git a/alembic/versions/0101_enable_rls_critical_tables.py b/alembic/versions/0101_enable_rls_critical_tables.py new file mode 100644 index 0000000..a91eea7 --- /dev/null +++ b/alembic/versions/0101_enable_rls_critical_tables.py @@ -0,0 +1,60 @@ +"""Enable RLS on critical tables missing it. + +Tables: api_tokens, sequences, sessions, tenant_plugin_activation, +user_tenants, password_reset_tokens. +Also adds tenant_id to guest_invitations and enables RLS. + +Revision ID: 0101 +""" + +from alembic import op +import sqlalchemy as sa + +revision = "0101" +down_revision = "0100" +branch_labels = None +depends_on = None + +# Tables that have tenant_id but no RLS +RLS_TABLES = [ + "api_tokens", + "sequences", + "sessions", + "tenant_plugin_activation", + "user_tenants", + "password_reset_tokens", +] + + +def upgrade() -> None: + # Enable RLS + create tenant isolation policy for each table + for table in RLS_TABLES: + op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY;") + op.execute( + f"CREATE POLICY {table}_tenant_isolation ON {table} " + f"FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid) " + f"WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);" + ) + + # guest_invitations: add tenant_id + enable RLS + op.add_column("guest_invitations", sa.Column("tenant_id", sa.UUID(), nullable=True)) + op.execute("CREATE INDEX ix_guest_invitations_tenant_id ON guest_invitations (tenant_id);") + op.execute("ALTER TABLE guest_invitations ENABLE ROW LEVEL SECURITY;") + op.execute( + "CREATE POLICY guest_invitations_tenant_isolation ON guest_invitations " + "FOR ALL USING (tenant_id = current_setting('app.current_tenant_id')::uuid) " + "WITH CHECK (tenant_id = current_setting('app.current_tenant_id')::uuid);" + ) + + +def downgrade() -> None: + # Drop guest_invitations RLS + tenant_id + op.execute("DROP POLICY IF EXISTS guest_invitations_tenant_isolation ON guest_invitations;") + op.execute("ALTER TABLE guest_invitations DISABLE ROW LEVEL SECURITY;") + op.execute("DROP INDEX IF EXISTS ix_guest_invitations_tenant_id;") + op.drop_column("guest_invitations", "tenant_id") + + # Drop RLS on other tables + for table in RLS_TABLES: + op.execute(f"DROP POLICY IF EXISTS {table}_tenant_isolation ON {table};") + op.execute(f"ALTER TABLE {table} DISABLE ROW LEVEL SECURITY;") diff --git a/app/core/permission_registry.py b/app/core/permission_registry.py index d6721aa..8792a2d 100644 --- a/app/core/permission_registry.py +++ b/app/core/permission_registry.py @@ -66,6 +66,66 @@ CORE_PERMISSIONS: list[dict[str, str]] = [ {"key": "workspaces:assign_users", "label": "Workspaces: Assign Users", "category": "core", "module": "workspaces"}, {"key": "workspaces:configure_modules", "label": "Workspaces: Configure Modules", "category": "core", "module": "workspaces"}, {"key": "system:admin", "label": "System: Admin (cross-tenant)", "category": "system", "module": "system"}, + # ── Plugin permissions (registered at startup, but also listed here for completeness) ── + {"key": "ai:read", "label": "AI: Read", "category": "core", "module": "ai"}, + {"key": "ai:write", "label": "AI: Write", "category": "core", "module": "ai"}, + {"key": "ai:agents", "label": "AI: Agents", "category": "core", "module": "ai"}, + {"key": "ai:config", "label": "AI: Config", "category": "core", "module": "ai"}, + {"key": "ai_proactive:read", "label": "AI Proactive: Read", "category": "core", "module": "ai_proactive"}, + {"key": "ai_proactive:write", "label": "AI Proactive: Write", "category": "core", "module": "ai_proactive"}, + {"key": "ai_proactive:config", "label": "AI Proactive: Config", "category": "core", "module": "ai_proactive"}, + {"key": "agents:read", "label": "Agents: Read", "category": "core", "module": "agents"}, + {"key": "agents:write", "label": "Agents: Write", "category": "core", "module": "agents"}, + {"key": "agents:delete", "label": "Agents: Delete", "category": "core", "module": "agents"}, + {"key": "agents:execute", "label": "Agents: Execute", "category": "core", "module": "agents"}, + {"key": "automation:read", "label": "Automation: Read", "category": "core", "module": "automation"}, + {"key": "automation:write", "label": "Automation: Write", "category": "core", "module": "automation"}, + {"key": "automation:delete", "label": "Automation: Delete", "category": "core", "module": "automation"}, + {"key": "automation:execute", "label": "Automation: Execute", "category": "core", "module": "automation"}, + {"key": "automation:admin", "label": "Automation: Admin", "category": "core", "module": "automation"}, + {"key": "automation:configure", "label": "Automation: Configure", "category": "core", "module": "automation"}, + {"key": "calendar:read", "label": "Calendar: Read", "category": "core", "module": "calendar"}, + {"key": "calendar:write", "label": "Calendar: Write", "category": "core", "module": "calendar"}, + {"key": "calendar:delete", "label": "Calendar: Delete", "category": "core", "module": "calendar"}, + {"key": "calendar:share", "label": "Calendar: Share", "category": "core", "module": "calendar"}, + {"key": "comm:read", "label": "Comm: Read", "category": "core", "module": "comm"}, + {"key": "comm:write", "label": "Comm: Write", "category": "core", "module": "comm"}, + {"key": "comm:delete", "label": "Comm: Delete", "category": "core", "module": "comm"}, + {"key": "comm:manage", "label": "Comm: Manage", "category": "core", "module": "comm"}, + {"key": "dashboard:read", "label": "Dashboard: Read", "category": "core", "module": "dashboard"}, + {"key": "dms:read", "label": "DMS: Read", "category": "core", "module": "dms"}, + {"key": "dms:write", "label": "DMS: Write", "category": "core", "module": "dms"}, + {"key": "dms:delete", "label": "DMS: Delete", "category": "core", "module": "dms"}, + {"key": "dms:share", "label": "DMS: Share", "category": "core", "module": "dms"}, + {"key": "entity_links:read", "label": "Entity Links: Read", "category": "core", "module": "entity_links"}, + {"key": "entity_links:write", "label": "Entity Links: Write", "category": "core", "module": "entity_links"}, + {"key": "entity_links:delete", "label": "Entity Links: Delete", "category": "core", "module": "entity_links"}, + {"key": "mail:read", "label": "Mail: Read", "category": "core", "module": "mail"}, + {"key": "mail:write", "label": "Mail: Write", "category": "core", "module": "mail"}, + {"key": "mail:delete", "label": "Mail: Delete", "category": "core", "module": "mail"}, + {"key": "mail:send", "label": "Mail: Send", "category": "core", "module": "mail"}, + {"key": "mail:share", "label": "Mail: Share", "category": "core", "module": "mail"}, + {"key": "mail:config", "label": "Mail: Config", "category": "core", "module": "mail"}, + {"key": "mcp:read", "label": "MCP: Read", "category": "core", "module": "mcp"}, + {"key": "mcp:write", "label": "MCP: Write", "category": "core", "module": "mcp"}, + {"key": "permissions:admin", "label": "Permissions: Admin", "category": "core", "module": "permissions"}, + {"key": "permissions:delegations:read", "label": "Permissions: Delegations: Read", "category": "core", "module": "permissions"}, + {"key": "permissions:delegations:write", "label": "Permissions: Delegations: Write", "category": "core", "module": "permissions"}, + {"key": "permissions:policies:read", "label": "Permissions: Policies: Read", "category": "core", "module": "permissions"}, + {"key": "permissions:policies:write", "label": "Permissions: Policies: Write", "category": "core", "module": "permissions"}, + {"key": "permissions:templates:read", "label": "Permissions: Templates: Read", "category": "core", "module": "permissions"}, + {"key": "permissions:templates:write", "label": "Permissions: Templates: Write", "category": "core", "module": "permissions"}, + {"key": "reports:read", "label": "Reports: Read", "category": "core", "module": "reports"}, + {"key": "reports:generate", "label": "Reports: Generate", "category": "core", "module": "reports"}, + {"key": "reports:manage_templates", "label": "Reports: Manage Templates", "category": "core", "module": "reports"}, + {"key": "search:read", "label": "Search: Read", "category": "core", "module": "search"}, + {"key": "search:admin", "label": "Search: Admin", "category": "core", "module": "search"}, + {"key": "tags:read", "label": "Tags: Read", "category": "core", "module": "tags"}, + {"key": "tags:write", "label": "Tags: Write", "category": "core", "module": "tags"}, + {"key": "tags:delete", "label": "Tags: Delete", "category": "core", "module": "tags"}, + {"key": "tasks:read", "label": "Tasks: Read", "category": "core", "module": "tasks"}, + {"key": "tasks:write", "label": "Tasks: Write", "category": "core", "module": "tasks"}, + {"key": "tasks:delete", "label": "Tasks: Delete", "category": "core", "module": "tasks"}, ] diff --git a/app/plugins/builtins/dms/routes.py b/app/plugins/builtins/dms/routes.py index 67e2001..bc2113b 100644 --- a/app/plugins/builtins/dms/routes.py +++ b/app/plugins/builtins/dms/routes.py @@ -97,6 +97,40 @@ def _sanitize_filename(filename: str) -> str: BLOCKED_EXTENSIONS = { ".exe", ".bat", ".cmd", ".sh", ".jar", ".com", ".scr", ".msi", ".dll", ".vbs", ".ps1", ".app", ".bin", ".reg", ".inf", + ".php", ".py", ".pl", ".asp", ".aspx", ".jsp", ".svg", ".htaccess", + ".phtml", ".pht", ".cgi", ".cfm", ".erb", +} + +# Allowed MIME types for upload validation +ALLOWED_MIME_PREFIXES = { + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument", + "application/vnd.oasis.opendocument", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/zip", + "application/gzip", + "application/x-tar", + "application/json", + "application/xml", + "application/rtf", + "application/x-7z-compressed", + "application/x-rar-compressed", + "text/plain", + "text/csv", + "text/html", + "text/markdown", + "image/png", + "image/jpeg", + "image/gif", + "image/webp", + "image/bmp", + "image/tiff", + "image/x-icon", + "audio/", + "video/", + "application/octet-stream", } @@ -467,6 +501,14 @@ async def upload_file( detail={"detail": "File type not allowed", "code": "blocked_filetype"}, ) + # MIME type validation: verify content_type against allowlist + mime_type = file.content_type or "application/octet-stream" + if not any(mime_type.startswith(prefix) for prefix in ALLOWED_MIME_PREFIXES): + raise HTTPException( + 400, + detail={"detail": f"MIME type '{mime_type}' not allowed", "code": "blocked_mimetype"}, + ) + # Validate folder exists if specified if fid is not None: folder_result = await db.execute( diff --git a/app/routes/entity_permissions.py b/app/routes/entity_permissions.py index dd5178f..8213794 100644 --- a/app/routes/entity_permissions.py +++ b/app/routes/entity_permissions.py @@ -86,6 +86,60 @@ async def create_entity_permission( raise HTTPException(status_code=400, detail=str(e)) +async def _check_entity_ownership( + db: AsyncSession, + tenant_id: uuid.UUID, + user_id: uuid.UUID, + entity_type: str, + entity_id: str, + is_system_admin: bool, +) -> None: + """Verify that the current user owns the entity or is system admin. + + Raises 403 if the user is neither owner nor system admin. + """ + if is_system_admin: + return + + from app.models.entity_permission import ENTITY_MODELS + from sqlalchemy import select + + model_info = ENTITY_MODELS.get(entity_type) + if model_info is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": f"Unknown entity type: {entity_type}", "code": "not_found"}, + ) + + model = model_info["model"] + try: + eid = uuid.UUID(entity_id) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"detail": "Invalid entity_id", "code": "invalid_id"}, + ) from None + + result = await db.execute( + select(model.owner_id).where( + model.id == eid, + model.tenant_id == tenant_id, + ) + ) + row = result.first() + if row is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail={"detail": "Entity not found", "code": "not_found"}, + ) + owner_id = row[0] + if owner_id is not None and owner_id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"detail": "Only the entity owner or system admin can modify permissions", "code": "forbidden"}, + ) + + @router.put("/{entity_type}/{entity_id}/{permission_id}") async def update_entity_permission( entity_type: str, @@ -103,6 +157,11 @@ async def update_entity_permission( _PERM_RATE_LIMIT_MAX, _PERM_RATE_LIMIT_WINDOW, ) + # Ownership check: only owner or system admin can update permissions + await _check_entity_ownership( + db, tenant_id, user_id, entity_type, entity_id, + current_user.get("is_system_admin", False), + ) try: return await entity_permission_service.update_permission( db, diff --git a/app/routes/system_settings.py b/app/routes/system_settings.py index e2192b7..6cddd60 100644 --- a/app/routes/system_settings.py +++ b/app/routes/system_settings.py @@ -20,11 +20,24 @@ async def get_system_settings( db: AsyncSession = Depends(get_db), current_user: dict = Depends(require_permission("settings:read")), ): - """Get system settings for the current tenant.""" + """Get system settings for the current tenant. + + Sensitive fields (tax_number, iban, bic) are masked for non-admin users. + """ tenant_id = uuid.UUID(current_user["tenant_id"]) result = await system_settings_service.get_system_settings(db, tenant_id) if result is None: return SystemSettingsResponse() + + # Mask sensitive fields for non-admin users + if not current_user.get("is_system_admin"): + if hasattr(result, "tax_number") and result.tax_number: + result.tax_number = "********" + if hasattr(result, "iban") and result.iban: + result.iban = "********" + if hasattr(result, "bic") and result.bic: + result.bic = "********" + return result diff --git a/app/routes/users.py b/app/routes/users.py index d7cfb55..9823481 100644 --- a/app/routes/users.py +++ b/app/routes/users.py @@ -63,6 +63,14 @@ async def create_user( user_id = uuid.UUID(current_user["user_id"]) role_id = _parse_role_id(body.role_id) + # Mass-Assignment protection: only system admin can create admin users + role = body.role + if role == "admin" and not current_user.get("is_system_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"detail": "Only system admin can create admin users", "code": "role_escalation_forbidden"}, + ) + try: user = await user_service.create_user( db, @@ -70,7 +78,7 @@ async def create_user( body.email, body.name, body.password, - body.role, + role, role_id, body.is_active, ) @@ -190,6 +198,13 @@ async def update_user( detail={"detail": "Cannot modify your own role or active status", "code": "self_modification_forbidden"}, ) + # Mass-Assignment protection: only system admin can change roles to admin + if body.role == "admin" and not current_user.get("is_system_admin"): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"detail": "Only system admin can assign admin role", "code": "role_escalation_forbidden"}, + ) + # Determine if role_id was explicitly sent (Pydantic v2) role_id_sent = "role_id" in body.model_fields_set diff --git a/app/schemas/attachment.py b/app/schemas/attachment.py index 1090b6f..589657a 100644 --- a/app/schemas/attachment.py +++ b/app/schemas/attachment.py @@ -10,7 +10,6 @@ class AttachmentResponse(BaseModel): entity_type: str entity_id: str filename: str - file_path: str mime_type: str file_size: int uploaded_by: str | None = None diff --git a/app/schemas/system_settings.py b/app/schemas/system_settings.py index f0e9b2b..a2ec0b6 100644 --- a/app/schemas/system_settings.py +++ b/app/schemas/system_settings.py @@ -41,6 +41,11 @@ class SystemSettingsResponse(BaseModel): company_legal_form: str | None = None company_street: str = "" company_city: str = "" + # Sensitive fields — masked in API response, only visible to system admin + tax_number: str | None = None + vat_id: str | None = None + iban: str | None = None + bic: str | None = None company_zip: str = "" company_country: str = "" tax_number: str | None = None