From 4b39cb920b0336fcb6bd473cc426d93d275f5987 Mon Sep 17 00:00:00 2001 From: deon-sanchez Date: Mon, 27 Jul 2026 09:11:47 -0600 Subject: [PATCH 01/23] feat: add enterprise feature flag and custom admin menu item - Introduced ENABLE_ENTERPRISE feature flag to control the visibility of the CustomAdminPageMenuItem in the AccountMenu. - Implemented CustomAdminPageMenuItem component for enterprise navigation. - Updated tests to reflect the new feature flag and ensure proper rendering behavior. --- .../AccountMenu/__tests__/account-menu.test.tsx | 1 + .../components/AccountMenu/index.tsx | 9 ++++++++- .../__tests__/custom-admin-page-menu-item.test.tsx | 12 ++++++++++++ .../components/custom-admin-page-menu-item.tsx | 8 ++++++++ src/frontend/src/customization/feature-flags.ts | 1 + 5 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/customization/components/__tests__/custom-admin-page-menu-item.test.tsx create mode 100644 src/frontend/src/customization/components/custom-admin-page-menu-item.tsx diff --git a/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/__tests__/account-menu.test.tsx b/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/__tests__/account-menu.test.tsx index 0c108f484d31..7bdceb5834f7 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/__tests__/account-menu.test.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/__tests__/account-menu.test.tsx @@ -28,6 +28,7 @@ jest.mock("@/customization/hooks/use-custom-navigate", () => ({ jest.mock("@/customization/feature-flags", () => ({ ENABLE_DATASTAX_LANGFLOW: false, + ENABLE_ENTERPRISE: true, })); jest.mock("@/stores/authStore", () => ({ diff --git a/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx b/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx index afed99eae308..c7576842e1ad 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx @@ -9,8 +9,12 @@ import { TWITTER_URL, } from "@/constants/constants"; import { useLogout } from "@/controllers/API/queries/auth"; +import { CustomAdminPageMenuItem } from "@/customization/components/custom-admin-page-menu-item"; import { CustomProfileIcon } from "@/customization/components/custom-profile-icon"; -import { ENABLE_DATASTAX_LANGFLOW } from "@/customization/feature-flags"; +import { + ENABLE_DATASTAX_LANGFLOW, + ENABLE_ENTERPRISE, +} from "@/customization/feature-flags"; import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; import useAuthStore from "@/stores/authStore"; import { useDarkStore } from "@/stores/darkStore"; @@ -119,6 +123,9 @@ export const AccountMenu = () => { )} + {ENABLE_ENTERPRISE && isAdmin && ( + navigate(path)} /> + )} { + it("does not render Enterprise navigation in OSS", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/frontend/src/customization/components/custom-admin-page-menu-item.tsx b/src/frontend/src/customization/components/custom-admin-page-menu-item.tsx new file mode 100644 index 000000000000..4006abf74fd0 --- /dev/null +++ b/src/frontend/src/customization/components/custom-admin-page-menu-item.tsx @@ -0,0 +1,8 @@ +export interface CustomAdminPageMenuItemProps { + onNavigate: (path: string) => void; +} + +export const CustomAdminPageMenuItem = (_: CustomAdminPageMenuItemProps) => + null; + +export default CustomAdminPageMenuItem; diff --git a/src/frontend/src/customization/feature-flags.ts b/src/frontend/src/customization/feature-flags.ts index d35b03365b46..1728ec16470d 100644 --- a/src/frontend/src/customization/feature-flags.ts +++ b/src/frontend/src/customization/feature-flags.ts @@ -28,3 +28,4 @@ export const ENABLE_FETCH_CREDENTIALS = false; // extension id to send to /api/v1/extensions/{id}/bundles/{name}/reload. export const ENABLE_EXTENSION_RELOAD = import.meta.env.LANGFLOW_EXTENSION_RELOAD_ENABLED === "true"; +export const ENABLE_ENTERPRISE = true; From a1c7b1b415563d9db5b5bc6bff6b0a9f4fe8c715 Mon Sep 17 00:00:00 2001 From: deon-sanchez Date: Wed, 29 Jul 2026 17:18:48 -0600 Subject: [PATCH 02/23] feat(sso): implement multi-identity support for users and enhance SSO client secret management --- ..._allow_multiple_sso_identities_per_user.py | 406 ++++++++++++++++++ .../services/database/models/__init__.py | 10 + .../services/database/models/auth/__init__.py | 13 +- .../services/database/models/auth/sso.py | 187 +++++++- .../database/models/auth/sso_secret.py | 156 +++++++ .../test_sso_instance_settings_migration.py | 182 ++++++++ .../test_sso_multi_identity_migration.py | 211 +++++++++ .../test_sso_protocol_settings_migration.py | 152 +++++++ .../unit/alembic/test_sso_secret_migration.py | 134 ++++++ .../test_sso_stable_connection_migration.py | 144 +++++++ src/backend/tests/unit/test_auth_settings.py | 35 ++ src/backend/tests/unit/test_sso_models.py | 242 ++++++++++- src/backend/tests/unit/test_sso_secrets.py | 61 +++ .../custom-login-sso-options.test.tsx | 10 + .../components/custom-login-sso-options.tsx | 4 + .../__tests__/LoginPage.a11y.test.tsx | 17 + src/frontend/src/pages/LoginPage/index.tsx | 2 + src/lfx/src/lfx/services/settings/auth.py | 33 ++ 18 files changed, 1961 insertions(+), 38 deletions(-) create mode 100644 src/backend/base/langflow/alembic/versions/e8f1a2b3c4d5_allow_multiple_sso_identities_per_user.py create mode 100644 src/backend/base/langflow/services/database/models/auth/sso_secret.py create mode 100644 src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py create mode 100644 src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py create mode 100644 src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py create mode 100644 src/backend/tests/unit/alembic/test_sso_secret_migration.py create mode 100644 src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py create mode 100644 src/backend/tests/unit/test_sso_secrets.py create mode 100644 src/frontend/src/customization/components/__tests__/custom-login-sso-options.test.tsx create mode 100644 src/frontend/src/customization/components/custom-login-sso-options.tsx diff --git a/src/backend/base/langflow/alembic/versions/e8f1a2b3c4d5_allow_multiple_sso_identities_per_user.py b/src/backend/base/langflow/alembic/versions/e8f1a2b3c4d5_allow_multiple_sso_identities_per_user.py new file mode 100644 index 000000000000..9d2d9c6803f3 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/e8f1a2b3c4d5_allow_multiple_sso_identities_per_user.py @@ -0,0 +1,406 @@ +"""update SSO identity, connection, and protocol contracts + +Revision ID: e8f1a2b3c4d5 +Revises: b7d5f9a3c2e4 +Create Date: 2026-07-29 + +Phase: EXPAND +""" + +from collections.abc import Sequence +from uuid import UUID + +import sqlalchemy as sa +import sqlmodel +from alembic import op +from langflow.utils import migration + +# revision identifiers, used by Alembic. +revision: str = "e8f1a2b3c4d5" +down_revision: str | None = "b7d5f9a3c2e4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_PROFILE_TABLE = "sso_user_profile" +_CONFIG_TABLE = "sso_config" +_SETTINGS_TABLE = "sso_settings" +_USER_ID_INDEX = "ix_sso_user_profile_user_id" +_USER_PROVIDER_INDEX = "uq_sso_user_profile_user_provider" +_CONFIG_SLUG_INDEX = "uq_sso_config_slug" +_UPDATED_BY_FK = "fk_sso_config_updated_by_user" +_SETTINGS_SINGLETON_CHECK = "ck_sso_settings_singleton" +_PROVIDER_SETTING_COLUMNS = ( + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + "client_id", +) + + +def _indexes(conn: sa.Connection, table_name: str) -> dict[str, dict]: + return {index["name"]: index for index in sa.inspect(conn).get_indexes(table_name)} + + +def _column_names(conn: sa.Connection, table_name: str) -> set[str]: + return {column["name"] for column in sa.inspect(conn).get_columns(table_name)} + + +def _foreign_keys(conn: sa.Connection, table_name: str) -> list[dict]: + return sa.inspect(conn).get_foreign_keys(table_name) + + +def _backfill_connection_identity(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + if not {"id", "slug", "display_name", "provider_name"} <= columns: + return + + table = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("slug"), + sa.column("display_name"), + sa.column("provider_name"), + ) + for row in conn.execute( + sa.select(table.c.id, table.c.slug, table.c.display_name, table.c.provider_name) + ).mappings(): + values = {} + if not row["slug"]: + values["slug"] = f"sso-{UUID(str(row['id'])).hex}" + if row["display_name"] is None: + values["display_name"] = row["provider_name"] + if values: + conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) + + +def _backfill_profile_connection_slugs(conn: sa.Connection) -> None: + if not migration.table_exists(_PROFILE_TABLE, conn): + return + config_columns = _column_names(conn, _CONFIG_TABLE) + profile_columns = _column_names(conn, _PROFILE_TABLE) + if not {"id", "slug", "provider_name"} <= config_columns or "sso_provider" not in profile_columns: + return + + config = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("slug"), + sa.column("provider_name"), + ) + profile = sa.table(_PROFILE_TABLE, sa.column("sso_provider")) + rows = conn.execute(sa.select(config.c.slug, config.c.provider_name).order_by(config.c.id)).all() + for row in rows: + if row.slug and row.provider_name: + conn.execute( + profile.update().where(profile.c.sso_provider == row.provider_name).values(sso_provider=row.slug) + ) + + +def _restore_profile_connection_names(conn: sa.Connection) -> None: + if not migration.table_exists(_PROFILE_TABLE, conn): + return + config_columns = _column_names(conn, _CONFIG_TABLE) + profile_columns = _column_names(conn, _PROFILE_TABLE) + if not {"id", "slug", "provider_name"} <= config_columns or "sso_provider" not in profile_columns: + return + + config = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("slug"), + sa.column("provider_name"), + ) + profile = sa.table(_PROFILE_TABLE, sa.column("sso_provider")) + rows = conn.execute(sa.select(config.c.slug, config.c.provider_name).order_by(config.c.id)).all() + for row in rows: + if row.slug and row.provider_name: + conn.execute( + profile.update().where(profile.c.sso_provider == row.slug).values(sso_provider=row.provider_name) + ) + + +def _backfill_provider_settings(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + if not {"id", "protocol", "provider_settings"} <= columns: + return + + selected_names = ["id", "protocol", "provider_settings"] + selected_names.extend(name for name in ("provider", *_PROVIDER_SETTING_COLUMNS) if name in columns) + table = sa.table( + _CONFIG_TABLE, + *(sa.column(name, sa.JSON() if name == "provider_settings" else None) for name in selected_names), + ) + + for row in conn.execute(sa.select(*(table.c[name] for name in selected_names))).mappings(): + protocol = row["protocol"] or row.get("provider") or "oidc" + provider_settings = dict(row["provider_settings"] or {}) + provider_settings.setdefault("protocol", protocol) + for name in _PROVIDER_SETTING_COLUMNS: + if name in row and name not in provider_settings: + provider_settings[name] = row[name] + conn.execute( + table.update().where(table.c.id == row["id"]).values(protocol=protocol, provider_settings=provider_settings) + ) + + +def _backfill_legacy_provider_columns(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + if not {"id", "protocol", "provider_settings", "provider"} <= columns: + return + + table = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("protocol"), + sa.column("provider_settings", sa.JSON()), + sa.column("provider"), + *(sa.column(name) for name in _PROVIDER_SETTING_COLUMNS), + ) + for row in conn.execute(sa.select(table.c.id, table.c.protocol, table.c.provider_settings)).mappings(): + provider_settings = row["provider_settings"] or {} + values = {"provider": row["protocol"] or provider_settings.get("protocol") or "oidc"} + values.update({name: provider_settings.get(name) for name in _PROVIDER_SETTING_COLUMNS}) + conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) + + +def _backfill_provider_name(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + if not {"id", "provider_name", "display_name"} <= columns: + return + + table = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("provider_name"), + sa.column("display_name"), + ) + conn.execute(table.update().where(table.c.provider_name.is_(None)).values(provider_name=table.c.display_name)) + + +def _create_and_backfill_sso_settings(conn: sa.Connection) -> None: + enforce_sso = False + if migration.table_exists(_CONFIG_TABLE, conn) and "enforce_sso" in _column_names(conn, _CONFIG_TABLE): + config = sa.table(_CONFIG_TABLE, sa.column("enforce_sso", sa.Boolean())) + enforce_sso = any(conn.execute(sa.select(config.c.enforce_sso)).scalars()) + + if not migration.table_exists(_SETTINGS_TABLE, conn): + op.create_table( + _SETTINGS_TABLE, + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("enforce_sso", sa.Boolean(), nullable=False), + sa.CheckConstraint("id = 1", name=_SETTINGS_SINGLETON_CHECK), + sa.PrimaryKeyConstraint("id"), + ) + + settings = sa.table( + _SETTINGS_TABLE, + sa.column("id", sa.Integer()), + sa.column("enforce_sso", sa.Boolean()), + ) + if conn.scalar(sa.select(sa.func.count()).select_from(settings).where(settings.c.id == 1)) == 0: + conn.execute(settings.insert().values(id=1, enforce_sso=enforce_sso)) + + +def _upgrade_instance_fields(conn: sa.Connection) -> None: + _create_and_backfill_sso_settings(conn) + columns = _column_names(conn, _CONFIG_TABLE) + if "sort_order" not in columns: + op.add_column(_CONFIG_TABLE, sa.Column("sort_order", sa.Integer(), nullable=True)) + if "updated_by" not in columns: + op.add_column(_CONFIG_TABLE, sa.Column("updated_by", sa.Uuid(), nullable=True)) + + config = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("sort_order", sa.Integer()), + ) + rows = conn.execute(sa.select(config.c.id, config.c.sort_order).order_by(config.c.id)).all() + for position, row in enumerate(rows): + if row.sort_order is None: + conn.execute(config.update().where(config.c.id == row.id).values(sort_order=position)) + + columns = _column_names(conn, _CONFIG_TABLE) + updated_by_foreign_key = next( + (fk for fk in _foreign_keys(conn, _CONFIG_TABLE) if fk["constrained_columns"] == ["updated_by"]), + None, + ) + with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: + batch_op.alter_column("sort_order", existing_type=sa.Integer(), nullable=False) + if updated_by_foreign_key is None: + batch_op.create_foreign_key( + _UPDATED_BY_FK, + "user", + ["updated_by"], + ["id"], + ondelete="SET NULL", + ) + if "enforce_sso" in columns: + batch_op.drop_column("enforce_sso") + + +def _downgrade_instance_fields(conn: sa.Connection) -> None: + if migration.table_exists(_CONFIG_TABLE, conn): + columns = _column_names(conn, _CONFIG_TABLE) + if "enforce_sso" not in columns: + op.add_column(_CONFIG_TABLE, sa.Column("enforce_sso", sa.Boolean(), nullable=True)) + + enforce_sso = False + if migration.table_exists(_SETTINGS_TABLE, conn): + settings = sa.table( + _SETTINGS_TABLE, + sa.column("id", sa.Integer()), + sa.column("enforce_sso", sa.Boolean()), + ) + stored_value = conn.scalar(sa.select(settings.c.enforce_sso).where(settings.c.id == 1)) + enforce_sso = bool(stored_value) + + config = sa.table(_CONFIG_TABLE, sa.column("enforce_sso", sa.Boolean())) + conn.execute(config.update().where(config.c.enforce_sso.is_(None)).values(enforce_sso=enforce_sso)) + + columns = _column_names(conn, _CONFIG_TABLE) + updated_by_foreign_key = next( + (fk for fk in _foreign_keys(conn, _CONFIG_TABLE) if fk["constrained_columns"] == ["updated_by"]), + None, + ) + with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: + batch_op.alter_column("enforce_sso", existing_type=sa.Boolean(), nullable=False) + if updated_by_foreign_key is not None and updated_by_foreign_key["name"]: + batch_op.drop_constraint(updated_by_foreign_key["name"], type_="foreignkey") + if "updated_by" in columns: + batch_op.drop_column("updated_by") + if "sort_order" in columns: + batch_op.drop_column("sort_order") + + if migration.table_exists(_SETTINGS_TABLE, conn): + op.drop_table(_SETTINGS_TABLE) + + +def _upgrade_sso_config(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + if "slug" not in columns: + op.add_column( + _CONFIG_TABLE, + sa.Column("slug", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + if "display_name" not in columns: + op.add_column( + _CONFIG_TABLE, + sa.Column("display_name", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + if "protocol" not in columns: + op.add_column( + _CONFIG_TABLE, + sa.Column("protocol", sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + if "provider_settings" not in columns: + op.add_column(_CONFIG_TABLE, sa.Column("provider_settings", sa.JSON(), nullable=True)) + + _backfill_connection_identity(conn) + _backfill_profile_connection_slugs(conn) + _backfill_provider_settings(conn) + columns = _column_names(conn, _CONFIG_TABLE) + indexes = _indexes(conn, _CONFIG_TABLE) + with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: + batch_op.alter_column( + "slug", + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.alter_column( + "display_name", + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.alter_column( + "protocol", + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.alter_column( + "provider_settings", + existing_type=sa.JSON(), + nullable=False, + ) + if _CONFIG_SLUG_INDEX not in indexes: + batch_op.create_index(_CONFIG_SLUG_INDEX, ["slug"], unique=True) + for name in ("provider", "provider_name", *_PROVIDER_SETTING_COLUMNS): + if name in columns: + batch_op.drop_column(name) + + +def _downgrade_sso_config(conn: sa.Connection) -> None: + columns = _column_names(conn, _CONFIG_TABLE) + for name in ("provider", "provider_name", *_PROVIDER_SETTING_COLUMNS): + if name not in columns: + op.add_column( + _CONFIG_TABLE, + sa.Column(name, sqlmodel.sql.sqltypes.AutoString(), nullable=True), + ) + + _backfill_legacy_provider_columns(conn) + _backfill_provider_name(conn) + _restore_profile_connection_names(conn) + columns = _column_names(conn, _CONFIG_TABLE) + indexes = _indexes(conn, _CONFIG_TABLE) + with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: + batch_op.alter_column( + "provider", + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + batch_op.alter_column( + "provider_name", + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=False, + ) + if _CONFIG_SLUG_INDEX in indexes: + batch_op.drop_index(_CONFIG_SLUG_INDEX) + for name in ("provider_settings", "protocol", "display_name", "slug"): + if name in columns: + batch_op.drop_column(name) + + +def upgrade() -> None: + conn = op.get_bind() + if migration.table_exists(_PROFILE_TABLE, conn): + indexes = _indexes(conn, _PROFILE_TABLE) + user_id_index = indexes.get(_USER_ID_INDEX) + with op.batch_alter_table(_PROFILE_TABLE, schema=None) as batch_op: + if user_id_index and user_id_index.get("unique"): + batch_op.drop_index(_USER_ID_INDEX) + batch_op.create_index(_USER_ID_INDEX, ["user_id"], unique=False) + elif user_id_index is None: + batch_op.create_index(_USER_ID_INDEX, ["user_id"], unique=False) + + if _USER_PROVIDER_INDEX not in indexes: + batch_op.create_index(_USER_PROVIDER_INDEX, ["user_id", "sso_provider"], unique=True) + + if migration.table_exists(_CONFIG_TABLE, conn): + _upgrade_sso_config(conn) + _upgrade_instance_fields(conn) + + +def downgrade() -> None: + conn = op.get_bind() + if migration.table_exists(_CONFIG_TABLE, conn): + _downgrade_instance_fields(conn) + _downgrade_sso_config(conn) + elif migration.table_exists(_SETTINGS_TABLE, conn): + op.drop_table(_SETTINGS_TABLE) + + if migration.table_exists(_PROFILE_TABLE, conn): + indexes = _indexes(conn, _PROFILE_TABLE) + user_id_index = indexes.get(_USER_ID_INDEX) + with op.batch_alter_table(_PROFILE_TABLE, schema=None) as batch_op: + if _USER_PROVIDER_INDEX in indexes: + batch_op.drop_index(_USER_PROVIDER_INDEX) + + if user_id_index and not user_id_index.get("unique"): + batch_op.drop_index(_USER_ID_INDEX) + batch_op.create_index(_USER_ID_INDEX, ["user_id"], unique=True) + elif user_id_index is None: + batch_op.create_index(_USER_ID_INDEX, ["user_id"], unique=True) diff --git a/src/backend/base/langflow/services/database/models/__init__.py b/src/backend/base/langflow/services/database/models/__init__.py index d4569871e198..8c3375e714ab 100644 --- a/src/backend/base/langflow/services/database/models/__init__.py +++ b/src/backend/base/langflow/services/database/models/__init__.py @@ -10,7 +10,12 @@ AuthzTeamMember, CasbinRule, SSOConfig, + SSOSecretError, + SSOSettings, SSOUserProfile, + decrypt_sso_client_secret, + encrypt_sso_client_secret, + is_sso_client_secret_envelope, ) from .deployment import Deployment from .deployment_provider_account import DeploymentProviderAccount @@ -64,6 +69,8 @@ "MessageIngestionRecord", "MessageTable", "SSOConfig", + "SSOSecretError", + "SSOSettings", "SSOUserProfile", "SignalType", "SpanTable", @@ -71,4 +78,7 @@ "TransactionTable", "User", "Variable", + "decrypt_sso_client_secret", + "encrypt_sso_client_secret", + "is_sso_client_secret_envelope", ] diff --git a/src/backend/base/langflow/services/database/models/auth/__init__.py b/src/backend/base/langflow/services/database/models/auth/__init__.py index 22623bdf4eae..87b2ecd979e6 100644 --- a/src/backend/base/langflow/services/database/models/auth/__init__.py +++ b/src/backend/base/langflow/services/database/models/auth/__init__.py @@ -10,7 +10,13 @@ SharePermissionLevel, ShareScope, ) -from .sso import SSOConfig, SSOUserProfile +from .sso import SSOConfig, SSOSettings, SSOUserProfile +from .sso_secret import ( + SSOSecretError, + decrypt_sso_client_secret, + encrypt_sso_client_secret, + is_sso_client_secret_envelope, +) __all__ = [ "AuthzAuditLog", @@ -22,7 +28,12 @@ "AuthzTeamMember", "CasbinRule", "SSOConfig", + "SSOSecretError", + "SSOSettings", "SSOUserProfile", "SharePermissionLevel", "ShareScope", + "decrypt_sso_client_secret", + "encrypt_sso_client_secret", + "is_sso_client_secret_envelope", ] diff --git a/src/backend/base/langflow/services/database/models/auth/sso.py b/src/backend/base/langflow/services/database/models/auth/sso.py index 3af71c8496fd..aae46d1e7025 100644 --- a/src/backend/base/langflow/services/database/models/auth/sso.py +++ b/src/backend/base/langflow/services/database/models/auth/sso.py @@ -8,22 +8,103 @@ ``langflow.services.database.models`` (e.g. ``SSOUserProfile``, ``SSOConfig``). """ +import re from datetime import datetime, timezone +from typing import Annotated, Any, Literal, TypeAlias from uuid import uuid4 import sqlalchemy as sa -from sqlalchemy import Column, ForeignKey, Index +from pydantic import BaseModel, ConfigDict, TypeAdapter, model_validator +from pydantic import Field as PydanticField +from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Index +from sqlalchemy.orm import validates from sqlmodel import Field, SQLModel +from typing_extensions import Self from langflow.schema.serialize import UUIDstr +from langflow.services.database.models.auth.sso_secret import is_sso_client_secret_envelope + +_SSO_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") + + +def _generate_sso_slug() -> str: + """Generate an opaque, URL-safe connection identifier.""" + return f"sso-{uuid4().hex}" + + +def _utc_now() -> datetime: + return datetime.now(timezone.utc) + + +class OIDCProviderSettings(BaseModel): + """OIDC-specific settings stored in ``sso_config.provider_settings``.""" + + model_config = ConfigDict(extra="forbid") + + protocol: Literal["oidc"] = "oidc" + discovery_url: str | None = None + redirect_uri: str | None = None + scopes: str | None = "openid email profile" + token_endpoint: str | None = None + authorization_endpoint: str | None = None + jwks_uri: str | None = None + issuer: str | None = None + client_id: str | None = None + + +# Add future protocol variants to this discriminated union. The database schema +# remains unchanged because every variant is stored in the same JSON column. +SSOProviderSettings: TypeAlias = Annotated[OIDCProviderSettings, PydanticField(discriminator="protocol")] +_PROVIDER_SETTINGS_ADAPTER = TypeAdapter(SSOProviderSettings) + + +def _validate_provider_settings(protocol: str, value: object) -> SSOProviderSettings: + settings = _PROVIDER_SETTINGS_ADAPTER.validate_python(value) + if settings.protocol != protocol: + msg = f"provider_settings protocol {settings.protocol!r} does not match sso_config.protocol {protocol!r}" + raise ValueError(msg) + return settings + + +class _ProviderSettingsJSON(sa.TypeDecorator): + """Persist validated provider settings as JSON and restore their Pydantic type.""" + + impl = sa.JSON + cache_ok = True + + def process_bind_param( + self, + value: SSOProviderSettings | dict[str, Any] | None, + _dialect: sa.engine.Dialect, + ) -> dict[str, Any] | None: + if value is None: + return None + settings = _PROVIDER_SETTINGS_ADAPTER.validate_python(value) + return _PROVIDER_SETTINGS_ADAPTER.dump_python(settings, mode="json") + + def process_result_value( + self, + value: dict[str, Any] | None, + _dialect: sa.engine.Dialect, + ) -> SSOProviderSettings | None: + if value is None: + return None + return _PROVIDER_SETTINGS_ADAPTER.validate_python(value) class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg] - """SSO profile per user. Used by the SSO plugin for JIT provisioning and login.""" + """SSO profile per user. + + ``sso_provider`` stores the immutable ``SSOConfig.slug`` as a documented-soft + reference; no database foreign key is intentionally enforced. + """ __tablename__ = "sso_user_profile" - # Use Index(unique=True) to match migration (create_index); avoids model/DB mismatch. - __table_args__ = (Index("uq_sso_user_profile_provider_user", "sso_provider", "sso_user_id", unique=True),) + # Use Index(unique=True) to match migrations (create_index); avoids model/DB mismatch. + __table_args__ = ( + Index("uq_sso_user_profile_provider_user", "sso_provider", "sso_user_id", unique=True), + Index("uq_sso_user_profile_user_provider", "user_id", "sso_provider", unique=True), + ) id: UUIDstr = Field(default_factory=uuid4, primary_key=True) user_id: UUIDstr = Field( @@ -31,11 +112,10 @@ class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg] sa.Uuid(), ForeignKey("user.id", ondelete="CASCADE"), nullable=False, - unique=True, index=True, ) ) - sso_provider: str = Field() + sso_provider: str = Field(description="Immutable SSOConfig.slug connection identifier") sso_user_id: str = Field() email: str | None = Field(default=None, index=True) sso_last_login_at: datetime | None = Field(default=None) @@ -44,29 +124,38 @@ class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg] class SSOConfig(SQLModel, table=True): # type: ignore[call-arg] - """SSO provider configuration (persisted in DB). Used by the SSO plugin.""" + """SSO provider configuration (persisted in DB). Used by the SSO plugin. + + ``client_secret_encrypted`` is an at-rest ciphertext envelope and must never + be returned by a read path. Consumers encrypt/decrypt it with the helpers in + ``sso_secret``. + """ __tablename__ = "sso_config" + __table_args__ = (Index("uq_sso_config_slug", "slug", unique=True),) id: UUIDstr = Field(default_factory=uuid4, primary_key=True) - provider: str = Field(description="oidc, saml, ldap") - provider_name: str = Field() + slug: str = Field(default_factory=_generate_sso_slug, description="Immutable URL-safe connection identifier") + display_name: str = Field(description="Mutable admin-facing connection label") + protocol: str = Field(default="oidc", description="Protocol discriminator for provider_settings") enabled: bool = Field(default=True) - enforce_sso: bool = Field(default=False) - client_id: str | None = Field(default=None) - client_secret_encrypted: str | None = Field(default=None) - discovery_url: str | None = Field(default=None) - redirect_uri: str | None = Field(default=None) - scopes: str | None = Field(default="openid email profile") + sort_order: int = Field(default=0, description="Login-button display order") + client_secret_encrypted: str | None = Field( + default=None, + description="Versioned ciphertext envelope; never serialize in a read response", + ) + provider_settings: SSOProviderSettings = Field( + default_factory=OIDCProviderSettings, + sa_column=Column(_ProviderSettingsJSON(), nullable=False), + ) email_claim: str = Field(default="email") username_claim: str = Field(default="preferred_username") user_id_claim: str = Field(default="sub") - token_endpoint: str | None = Field(default=None) - authorization_endpoint: str | None = Field(default=None) - jwks_uri: str | None = Field(default=None) - issuer: str | None = Field(default=None) - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: datetime = Field(default_factory=_utc_now) + updated_at: datetime = Field( + default_factory=_utc_now, + sa_column=Column(DateTime(), nullable=False, onupdate=_utc_now), + ) created_by: UUIDstr | None = Field( default=None, sa_column=Column( @@ -75,3 +164,59 @@ class SSOConfig(SQLModel, table=True): # type: ignore[call-arg] nullable=True, ), ) + updated_by: UUIDstr | None = Field( + default=None, + sa_column=Column( + sa.Uuid(), + ForeignKey("user.id", ondelete="SET NULL"), + nullable=True, + ), + ) + + def __init__(self, **data: Any) -> None: + slug = data.get("slug") + if slug is not None and not _SSO_SLUG_PATTERN.fullmatch(slug): + msg = "SSOConfig.slug must contain only lowercase letters, numbers, and single hyphens" + raise ValueError(msg) + protocol = data.get("protocol", "oidc") + data["protocol"] = protocol + data["provider_settings"] = _validate_provider_settings( + protocol, + data.get("provider_settings", OIDCProviderSettings()), + ) + super().__init__(**data) + + @model_validator(mode="after") + def validate_provider_settings_protocol(self) -> Self: + self.provider_settings = _validate_provider_settings(self.protocol, self.provider_settings) + return self + + @validates("client_secret_encrypted") + def validate_client_secret_envelope(self, _key: str, value: str | None) -> str | None: + """Reject plaintext or malformed client secrets on model writes.""" + if value is not None and not is_sso_client_secret_envelope(value): + msg = "client_secret_encrypted must be a versioned SSO secret envelope" + raise ValueError(msg) + return value + + +class SSOSettings(SQLModel, table=True): # type: ignore[call-arg] + """Singleton instance-level SSO policy settings.""" + + __tablename__ = "sso_settings" + __table_args__ = (CheckConstraint("id = 1", name="ck_sso_settings_singleton"),) + + id: int = Field(default=1, primary_key=True) + enforce_sso: bool = Field(default=False) + + +@sa.event.listens_for(SSOConfig, "before_update") +def _prevent_sso_config_slug_update( + _mapper: sa.orm.Mapper[SSOConfig], + _connection: sa.Connection, + target: SSOConfig, +) -> None: + """Keep the connection identifier immutable after persistence.""" + if sa.inspect(target).attrs.slug.history.has_changes(): + msg = "SSOConfig.slug is immutable after insert" + raise ValueError(msg) diff --git a/src/backend/base/langflow/services/database/models/auth/sso_secret.py b/src/backend/base/langflow/services/database/models/auth/sso_secret.py new file mode 100644 index 000000000000..d63b888cc6a8 --- /dev/null +++ b/src/backend/base/langflow/services/database/models/auth/sso_secret.py @@ -0,0 +1,156 @@ +"""Versioned at-rest encryption contract for SSO client secrets. + +The ``sso_config.client_secret_encrypted`` column stores only envelopes emitted +by :func:`encrypt_sso_client_secret`; SSO read paths must never serialize or +otherwise return that column. + +Envelope v1 is:: + + lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:: + +``ciphertext`` includes the 128-bit GCM authentication tag. The 256-bit AES key +is derived from the existing ``LANGFLOW_SECRET_KEY`` (``AuthSettings.SECRET_KEY``) +with HKDF-SHA256, a fixed domain-separation salt, and the distinct +``langflow/sso/client-secret/encryption`` info label. No additional setting or +environment variable is required. + +Key/format rotation procedure: + +1. Add a new envelope/KDF version while retaining decryption support for v1. +2. Re-encrypt every non-null column value internally, using the old version to + decrypt and the new version to encrypt. Never expose plaintext through a read + response or log. +3. Verify no old-version envelopes remain, then remove the old decryptor in a + later release. A future KMS-backed key therefore changes envelope handling, + not the database schema. +""" + +from __future__ import annotations + +import base64 +import binascii +import os +from typing import TYPE_CHECKING + +from cryptography.exceptions import InvalidTag +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF + +if TYPE_CHECKING: + from lfx.services.settings.service import SettingsService + +_ENVELOPE_VERSION = "v1" +_KDF_VERSION = "hkdf-sha256-v1" +_ALGORITHM = "aes-256-gcm" +_PREFIX = "lf-sso" +_HEADER = f"{_PREFIX}:{_ENVELOPE_VERSION}:{_KDF_VERSION}:{_ALGORITHM}" +_AAD = _HEADER.encode() +_HKDF_SALT = b"langflow/sso/client-secret/hkdf-salt/v1" +_HKDF_INFO = b"langflow/sso/client-secret/encryption" +_NONCE_BYTES = 12 +_TAG_BYTES = 16 +_ENVELOPE_PARTS = 6 +_HEADER_PARTS = 4 + + +class SSOSecretError(ValueError): + """Raised when an SSO secret cannot be encoded or decoded safely.""" + + +def _master_key(settings_service: SettingsService | None) -> bytes: + if settings_service is None: + from langflow.services.deps import get_settings_service + + settings_service = get_settings_service() + + secret_key = settings_service.auth_settings.SECRET_KEY.get_secret_value() + if not secret_key: + msg = "LANGFLOW_SECRET_KEY is required for SSO secret encryption" + raise SSOSecretError(msg) + return secret_key.encode() + + +def _derive_key(settings_service: SettingsService | None) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=_HKDF_SALT, + info=_HKDF_INFO, + ).derive(_master_key(settings_service)) + + +def _encode(value: bytes) -> str: + return base64.urlsafe_b64encode(value).rstrip(b"=").decode() + + +def _decode(value: str) -> bytes: + padding = "=" * (-len(value) % 4) + try: + return base64.b64decode(value + padding, altchars=b"-_", validate=True) + except (binascii.Error, ValueError) as exc: + msg = "Invalid base64url data in SSO secret envelope" + raise SSOSecretError(msg) from exc + + +def _parse_envelope(envelope: str) -> tuple[bytes, bytes]: + parts = envelope.split(":") + if len(parts) != _ENVELOPE_PARTS or ":".join(parts[:_HEADER_PARTS]) != _HEADER: + msg = "Unsupported SSO client-secret envelope or key-derivation version" + raise SSOSecretError(msg) + + nonce = _decode(parts[4]) + ciphertext = _decode(parts[5]) + if len(nonce) != _NONCE_BYTES or len(ciphertext) < _TAG_BYTES: + msg = "Invalid SSO client-secret envelope payload" + raise SSOSecretError(msg) + return nonce, ciphertext + + +def is_sso_client_secret_envelope(value: object) -> bool: + """Return whether a value is a structurally valid current-version envelope.""" + if not isinstance(value, str): + return False + try: + _parse_envelope(value) + except SSOSecretError: + return False + return True + + +def encrypt_sso_client_secret( + client_secret: str, + settings_service: SettingsService | None = None, +) -> str: + """Encrypt an SSO client secret into the current versioned envelope.""" + if not isinstance(client_secret, str): + msg = "SSO client secret must be a string" + raise TypeError(msg) + + nonce = os.urandom(_NONCE_BYTES) + ciphertext = AESGCM(_derive_key(settings_service)).encrypt(nonce, client_secret.encode(), _AAD) + return f"{_HEADER}:{_encode(nonce)}:{_encode(ciphertext)}" + + +def decrypt_sso_client_secret( + envelope: str, + settings_service: SettingsService | None = None, +) -> str: + """Decrypt a supported SSO client-secret envelope.""" + if not isinstance(envelope, str): + msg = "SSO client-secret envelope must be a string" + raise TypeError(msg) + + nonce, ciphertext = _parse_envelope(envelope) + + try: + plaintext = AESGCM(_derive_key(settings_service)).decrypt(nonce, ciphertext, _AAD) + except InvalidTag as exc: + msg = "Unable to decrypt SSO client secret with LANGFLOW_SECRET_KEY" + raise SSOSecretError(msg) from exc + + try: + return plaintext.decode() + except UnicodeDecodeError as exc: + msg = "Decrypted SSO client secret is not valid UTF-8" + raise SSOSecretError(msg) from exc diff --git a/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py b/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py new file mode 100644 index 000000000000..7575880735c7 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py @@ -0,0 +1,182 @@ +"""Migration contract for multi-connection and instance-level SSO fields.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import sqlalchemy as sa +from alembic import command + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 + + +def _config_values( + *, + config_id: str, + provider_name: str, + enforce_sso: bool, + timestamp: datetime, +) -> dict: + return { + "id": config_id, + "provider": "oidc", + "provider_name": provider_name, + "enabled": True, + "enforce_sso": enforce_sso, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + } + + +def test_sso_instance_fields_upgrade_and_downgrade_preserve_seeded_rows(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + profile_user_id = str(uuid4()) + updater_user_id = str(uuid4()) + first_config_id = str(UUID(int=1)) + second_config_id = str(UUID(int=2)) + profile_id = str(uuid4()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + user.insert(), + [ + { + "id": profile_user_id, + "username": "sso-instance-profile-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + { + "id": updater_user_id, + "username": "sso-instance-updater", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ], + ) + connection.execute( + sso_config.insert(), + [ + _config_values( + config_id=second_config_id, + provider_name="Second OIDC", + enforce_sso=False, + timestamp=timestamp, + ), + _config_values( + config_id=first_config_id, + provider_name="First OIDC", + enforce_sso=True, + timestamp=timestamp, + ), + ], + ) + connection.execute( + sso_user_profile.insert(), + { + "id": profile_id, + "user_id": profile_user_id, + "sso_provider": "first-oidc", + "sso_user_id": "subject-1", + "email": "user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + inspector = sa.inspect(connection) + config_columns = {column["name"] for column in inspector.get_columns("sso_config")} + foreign_keys = inspector.get_foreign_keys("sso_config") + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_settings = sa.Table("sso_settings", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + assert {"sort_order", "updated_by"} <= config_columns + assert "enforce_sso" not in config_columns + assert any( + foreign_key["constrained_columns"] == ["updated_by"] + and foreign_key["referred_table"] == "user" + and foreign_key["options"].get("ondelete") == "SET NULL" + for foreign_key in foreign_keys + ) + + settings_rows = connection.execute(sa.select(sso_settings)).mappings().all() + assert settings_rows == [{"id": 1, "enforce_sso": True}] + + ordered_configs = ( + connection.execute( + sa.select(sso_config.c.id, sso_config.c.enabled, sso_config.c.sort_order).order_by( + sso_config.c.sort_order, + sso_config.c.id, + ) + ) + .mappings() + .all() + ) + assert [str(row["id"]) for row in ordered_configs] == [first_config_id, second_config_id] + assert [row["sort_order"] for row in ordered_configs] == [0, 1] + assert all(row["enabled"] for row in ordered_configs) + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() + + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + inspector = sa.inspect(connection) + config_columns = {column["name"] for column in inspector.get_columns("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + assert "enforce_sso" in config_columns + assert {"sort_order", "updated_by"}.isdisjoint(config_columns) + assert not inspector.has_table("sso_settings") + assert connection.execute(sa.select(sso_config.c.enforce_sso)).scalars().all() == [True, True] + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() diff --git a/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py b/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py new file mode 100644 index 000000000000..48a5bf8973ad --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py @@ -0,0 +1,211 @@ +"""Migration contract for multiple SSO identities per user.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from alembic import command +from sqlalchemy.exc import IntegrityError + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret +_USER_ID_INDEX = "ix_sso_user_profile_user_id" +_USER_PROVIDER_INDEX = "uq_sso_user_profile_user_provider" +_PROVIDER_IDENTITY_INDEX = "uq_sso_user_profile_provider_user" +_TEST_PASSWORD = "hashed" # noqa: S105 + + +def _profile_values( + *, + profile_id: str, + user_id: str, + provider: str, + sso_user_id: str, + timestamp: datetime, +) -> dict: + return { + "id": profile_id, + "user_id": user_id, + "sso_provider": provider, + "sso_user_id": sso_user_id, + "email": None, + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + } + + +def test_sso_multi_identity_upgrade_and_downgrade_preserve_seeded_rows(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + user_id = str(uuid4()) + other_user_id = str(uuid4()) + config_id = str(uuid4()) + original_profile_id = str(uuid4()) + second_profile_id = str(uuid4()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + connection.execute( + user.insert(), + [ + { + "id": user_id, + "username": "sso-migration-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + { + "id": other_user_id, + "username": "sso-migration-other-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ], + ) + connection.execute( + sso_config.insert(), + { + "id": config_id, + "provider": "oidc", + "provider_name": "Primary OIDC", + "enabled": True, + "enforce_sso": False, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_user_profile.insert(), + _profile_values( + profile_id=original_profile_id, + user_id=user_id, + provider="oidc-primary", + sso_user_id="subject-1", + timestamp=timestamp, + ), + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + inspector = sa.inspect(connection) + indexes = {index["name"]: index for index in inspector.get_indexes("sso_user_profile")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + assert not indexes[_USER_ID_INDEX]["unique"] + assert indexes[_USER_PROVIDER_INDEX]["unique"] + assert indexes[_PROVIDER_IDENTITY_INDEX]["unique"] + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_config).where(sso_config.c.id == config_id) + ) + == 1 + ) + assert ( + connection.scalar( + sa.select(sa.func.count()) + .select_from(sso_user_profile) + .where(sso_user_profile.c.id == original_profile_id) + ) + == 1 + ) + + with engine.begin() as connection: + connection.execute( + sso_user_profile.insert(), + _profile_values( + profile_id=second_profile_id, + user_id=user_id, + provider="saml", + sso_user_id="subject-2", + timestamp=timestamp, + ), + ) + + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.execute( + sso_user_profile.insert(), + _profile_values( + profile_id=str(uuid4()), + user_id=user_id, + provider="saml", + sso_user_id="different-subject", + timestamp=timestamp, + ), + ) + + with pytest.raises(IntegrityError), engine.begin() as connection: + connection.execute( + sso_user_profile.insert(), + _profile_values( + profile_id=str(uuid4()), + user_id=other_user_id, + provider="oidc-primary", + sso_user_id="subject-1", + timestamp=timestamp, + ), + ) + + with engine.begin() as connection: + connection.execute(sso_user_profile.delete().where(sso_user_profile.c.id == second_profile_id)) + finally: + engine.dispose() + + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + indexes = {index["name"]: index for index in sa.inspect(connection).get_indexes("sso_user_profile")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + assert indexes[_USER_ID_INDEX]["unique"] + assert _USER_PROVIDER_INDEX not in indexes + assert indexes[_PROVIDER_IDENTITY_INDEX]["unique"] + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_config).where(sso_config.c.id == config_id) + ) + == 1 + ) + assert ( + connection.scalar( + sa.select(sa.func.count()) + .select_from(sso_user_profile) + .where(sso_user_profile.c.id == original_profile_id) + ) + == 1 + ) + finally: + engine.dispose() diff --git a/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py new file mode 100644 index 000000000000..d0bc78ee3fef --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py @@ -0,0 +1,152 @@ +"""Migration contract for typed SSO provider settings.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import command + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 +_TEST_ENCRYPTED_SECRET = "encrypted-secret" # noqa: S105 +_PROVIDER_SETTING_COLUMNS = { + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + "client_id", +} + + +def test_sso_provider_settings_upgrade_and_downgrade_preserve_seeded_rows(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + user_id = str(uuid4()) + config_id = str(uuid4()) + profile_id = str(uuid4()) + expected_settings = { + "protocol": "oidc", + "discovery_url": "https://idp.example.com/.well-known/openid-configuration", + "redirect_uri": "/api/v1/login/callback", + "scopes": "openid email profile groups", + "token_endpoint": "https://idp.example.com/token", + "authorization_endpoint": "https://idp.example.com/authorize", + "jwks_uri": "https://idp.example.com/jwks", + "issuer": "https://idp.example.com", + "client_id": "client-id", + } + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + user.insert(), + { + "id": user_id, + "username": "sso-protocol-migration-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_config.insert(), + { + "id": config_id, + "provider": expected_settings["protocol"], + "provider_name": "Primary OIDC", + "enabled": True, + "enforce_sso": False, + "client_secret_encrypted": _TEST_ENCRYPTED_SECRET, + **{name: expected_settings[name] for name in _PROVIDER_SETTING_COLUMNS}, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_user_profile.insert(), + { + "id": profile_id, + "user_id": user_id, + "sso_provider": "primary-oidc", + "sso_user_id": "subject-1", + "email": "user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + inspector = sa.inspect(connection) + columns = {column["name"] for column in inspector.get_columns("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + config_row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() + + assert {"protocol", "provider_settings", "client_secret_encrypted"} <= columns + assert {"provider", *_PROVIDER_SETTING_COLUMNS}.isdisjoint(columns) + assert config_row["protocol"] == "oidc" + assert config_row["provider_settings"] == expected_settings + assert config_row["client_secret_encrypted"] == _TEST_ENCRYPTED_SECRET + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() + + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + columns = {column["name"] for column in sa.inspect(connection).get_columns("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + config_row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() + + assert {"provider", "client_secret_encrypted", *_PROVIDER_SETTING_COLUMNS} <= columns + assert {"protocol", "provider_settings"}.isdisjoint(columns) + assert config_row["provider"] == "oidc" + assert {name: config_row[name] for name in _PROVIDER_SETTING_COLUMNS} == { + name: expected_settings[name] for name in _PROVIDER_SETTING_COLUMNS + } + assert config_row["client_secret_encrypted"] == _TEST_ENCRYPTED_SECRET + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() diff --git a/src/backend/tests/unit/alembic/test_sso_secret_migration.py b/src/backend/tests/unit/alembic/test_sso_secret_migration.py new file mode 100644 index 000000000000..103ef1f5f6a0 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_secret_migration.py @@ -0,0 +1,134 @@ +"""Migration contract for versioned SSO client-secret ciphertext.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + +import sqlalchemy as sa +from alembic import command +from langflow.services.database.models.auth import decrypt_sso_client_secret, encrypt_sso_client_secret +from pydantic import SecretStr + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 +_PLAINTEXT_SECRET = "migration-oidc-client-secret" # noqa: S105 + + +def _settings(): + return SimpleNamespace( + auth_settings=SimpleNamespace(SECRET_KEY=SecretStr("migration-test-langflow-secret-key-material")) + ) + + +def test_sso_secret_upgrade_and_downgrade_preserve_seeded_ciphertext(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + user_id = str(uuid4()) + config_id = str(uuid4()) + profile_id = str(uuid4()) + encrypted_secret = encrypt_sso_client_secret(_PLAINTEXT_SECRET, _settings()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + user.insert(), + { + "id": user_id, + "username": "sso-secret-migration-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_config.insert(), + { + "id": config_id, + "provider": "oidc", + "provider_name": "Encrypted OIDC", + "enabled": True, + "enforce_sso": False, + "client_secret_encrypted": encrypted_secret, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_user_profile.insert(), + { + "id": profile_id, + "user_id": user_id, + "sso_provider": "encrypted-oidc", + "sso_user_id": "subject-1", + "email": "user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + stored_secret = connection.scalar( + sa.select(sso_config.c.client_secret_encrypted).where(sso_config.c.id == config_id) + ) + + assert stored_secret == encrypted_secret + assert _PLAINTEXT_SECRET not in stored_secret + assert decrypt_sso_client_secret(stored_secret, _settings()) == _PLAINTEXT_SECRET + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() + + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + stored_secret = connection.scalar( + sa.select(sso_config.c.client_secret_encrypted).where(sso_config.c.id == config_id) + ) + + assert stored_secret == encrypted_secret + assert decrypt_sso_client_secret(stored_secret, _settings()) == _PLAINTEXT_SECRET + assert ( + connection.scalar( + sa.select(sa.func.count()).select_from(sso_user_profile).where(sso_user_profile.c.id == profile_id) + ) + == 1 + ) + finally: + engine.dispose() diff --git a/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py b/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py new file mode 100644 index 000000000000..645a7ee7451f --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py @@ -0,0 +1,144 @@ +"""Migration contract for stable SSO connection identity.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import sqlalchemy as sa +from alembic import command + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_REVISION = "e8f1a2b3c4d5" # pragma: allowlist secret +_SLUG_INDEX = "uq_sso_config_slug" +_TEST_PASSWORD = "hashed" # noqa: S105 + + +def test_sso_connection_identity_upgrade_and_downgrade_preserve_seeded_rows(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + user_id = str(uuid4()) + config_id = str(uuid4()) + profile_id = str(uuid4()) + expected_slug = f"sso-{UUID(config_id).hex}" + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + user.insert(), + { + "id": user_id, + "username": "sso-stable-identity-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_config.insert(), + { + "id": config_id, + "provider": "oidc", + "provider_name": "Primary OIDC", + "enabled": True, + "enforce_sso": False, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_user_profile.insert(), + { + "id": profile_id, + "user_id": user_id, + "sso_provider": "Primary OIDC", + "sso_user_id": "subject-1", + "email": "user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + inspector = sa.inspect(connection) + columns = {column["name"]: column for column in inspector.get_columns("sso_config")} + indexes = {index["name"]: index for index in inspector.get_indexes("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + config_row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() + profile_row = ( + connection.execute(sa.select(sso_user_profile).where(sso_user_profile.c.id == profile_id)) + .mappings() + .one() + ) + + assert {"slug", "display_name"} <= columns.keys() + assert "provider_name" not in columns + assert not columns["slug"]["nullable"] + assert not columns["display_name"]["nullable"] + assert indexes[_SLUG_INDEX]["unique"] + assert config_row["slug"] == expected_slug + assert config_row["display_name"] == "Primary OIDC" + assert profile_row["sso_provider"] == config_row["slug"] + + connection.execute( + sso_config.update().where(sso_config.c.id == config_id).values(display_name="Renamed OIDC") + ) + resolved_config = ( + connection.execute(sa.select(sso_config).where(sso_config.c.slug == profile_row["sso_provider"])) + .mappings() + .one() + ) + assert resolved_config["display_name"] == "Renamed OIDC" + finally: + engine.dispose() + + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.connect() as connection: + inspector = sa.inspect(connection) + columns = {column["name"]: column for column in inspector.get_columns("sso_config")} + indexes = {index["name"]: index for index in inspector.get_indexes("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + config_row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() + profile_row = ( + connection.execute(sa.select(sso_user_profile).where(sso_user_profile.c.id == profile_id)) + .mappings() + .one() + ) + + assert "provider_name" in columns + assert {"slug", "display_name"}.isdisjoint(columns) + assert _SLUG_INDEX not in indexes + assert config_row["provider_name"] == "Renamed OIDC" + assert profile_row["sso_provider"] == "Renamed OIDC" + finally: + engine.dispose() diff --git a/src/backend/tests/unit/test_auth_settings.py b/src/backend/tests/unit/test_auth_settings.py index 228b64d4c012..5f63d7945b5e 100644 --- a/src/backend/tests/unit/test_auth_settings.py +++ b/src/backend/tests/unit/test_auth_settings.py @@ -139,3 +139,38 @@ def test_invalid_api_key_source_from_env_var(self, tmp_path: Path, monkeypatch): monkeypatch.setenv("LANGFLOW_API_KEY_SOURCE", "invalid") with pytest.raises(ValidationError): AuthSettings(CONFIG_DIR=cfg_dir) + + +class TestSsoUrlSettings: + def test_sso_url_settings_are_declared_with_descriptions(self, tmp_path: Path): + settings = AuthSettings(CONFIG_DIR=tmp_path.as_posix()) + + assert settings.SSO_LOGIN_URL is None + assert settings.SSO_REDIRECT_URL is None + assert AuthSettings.model_fields["SSO_LOGIN_URL"].description + assert AuthSettings.model_fields["SSO_REDIRECT_URL"].description + + @pytest.mark.parametrize("setting_name", ["SSO_LOGIN_URL", "SSO_REDIRECT_URL"]) + def test_sso_url_setting_loads_relative_path_from_environment(self, setting_name: str, tmp_path: Path, monkeypatch): + env_name = f"LANGFLOW_{setting_name}" + monkeypatch.setenv(env_name, "/api/v1/sso/callback") + + settings = AuthSettings(CONFIG_DIR=tmp_path.as_posix()) + + assert getattr(settings, setting_name) == "/api/v1/sso/callback" + + @pytest.mark.parametrize("setting_name", ["SSO_LOGIN_URL", "SSO_REDIRECT_URL"]) + @pytest.mark.parametrize( + "off_origin_url", + [ + "https://attacker.example/sso", + "//attacker.example/sso", + "///attacker.example/sso", + "\\\\attacker.example\\sso", + ], + ) + def test_sso_url_setting_rejects_absolute_off_origin_url( + self, setting_name: str, off_origin_url: str, tmp_path: Path + ): + with pytest.raises(ValidationError, match=setting_name): + AuthSettings(CONFIG_DIR=tmp_path.as_posix(), **{setting_name: off_origin_url}) diff --git a/src/backend/tests/unit/test_sso_models.py b/src/backend/tests/unit/test_sso_models.py index dd997d361424..8068f929655f 100644 --- a/src/backend/tests/unit/test_sso_models.py +++ b/src/backend/tests/unit/test_sso_models.py @@ -4,9 +4,14 @@ CASCADE delete, unique constraints, and default values. """ +from datetime import datetime, timezone +from types import SimpleNamespace + import pytest -from langflow.services.database.models.auth.sso import SSOConfig, SSOUserProfile +from langflow.services.database.models.auth import decrypt_sso_client_secret, encrypt_sso_client_secret +from langflow.services.database.models.auth.sso import OIDCProviderSettings, SSOConfig, SSOSettings, SSOUserProfile from langflow.services.database.models.user.model import User +from pydantic import SecretStr, ValidationError from sqlalchemy import event from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import create_async_engine @@ -16,6 +21,14 @@ # Placeholder for User.password in tests (not a real secret) _TEST_PASSWORD = "hashed" # noqa: S105 +_TEST_PLAINTEXT_SECRET = "oidc-client-secret" # noqa: S105 + + +@pytest.fixture(name="sso_secret_settings") +def sso_secret_settings_fixture(): + return SimpleNamespace( + auth_settings=SimpleNamespace(SECRET_KEY=SecretStr("unit-test-langflow-secret-key-material")) + ) @pytest.fixture(name="sso_db_engine") @@ -77,17 +90,37 @@ async def test_create_and_read_sso_user_profile(self, sso_async_session): assert profile.created_at is not None assert profile.updated_at is not None - async def test_user_id_unique_constraint(self, sso_async_session): - """Cannot create two SSO profiles for the same user.""" - user = User(username="unique_user", password=_TEST_PASSWORD) + async def test_user_can_have_profiles_for_distinct_providers(self, sso_async_session): + """One user can have one SSO profile for each distinct provider.""" + user = User(username="multi_provider_user", password=_TEST_PASSWORD) + sso_async_session.add(user) + await sso_async_session.commit() + await sso_async_session.refresh(user) + + profiles = [ + SSOUserProfile(user_id=user.id, sso_provider="oidc-primary", sso_user_id="sub-1"), + SSOUserProfile(user_id=user.id, sso_provider="oidc-secondary", sso_user_id="sub-2"), + SSOUserProfile(user_id=user.id, sso_provider="saml", sso_user_id="sub-3"), + ] + sso_async_session.add_all(profiles) + await sso_async_session.commit() + + result = await sso_async_session.exec( + select(SSOUserProfile).where(SSOUserProfile.user_id == user.id).order_by(SSOUserProfile.sso_provider) + ) + assert [profile.sso_provider for profile in result.all()] == ["oidc-primary", "oidc-secondary", "saml"] + + async def test_composite_unique_user_id_sso_provider(self, sso_async_session): + """A user cannot have two SSO profiles for the same provider.""" + user = User(username="unique_user_provider", password=_TEST_PASSWORD) sso_async_session.add(user) await sso_async_session.commit() await sso_async_session.refresh(user) - sso_async_session.add(SSOUserProfile(user_id=user.id, sso_provider="oidc", sso_user_id="sub-1")) + sso_async_session.add(SSOUserProfile(user_id=user.id, sso_provider="oidc-primary", sso_user_id="sub-1")) await sso_async_session.commit() - duplicate = SSOUserProfile(user_id=user.id, sso_provider="saml", sso_user_id="sub-2") + duplicate = SSOUserProfile(user_id=user.id, sso_provider="oidc-primary", sso_user_id="sub-2") sso_async_session.add(duplicate) with pytest.raises(IntegrityError, match=r"UNIQUE constraint failed|unique constraint"): await sso_async_session.commit() @@ -149,39 +182,216 @@ async def test_default_timestamps_set(self, sso_async_session): class TestSSOConfig: """SSOConfig model tests against real database.""" - async def test_create_and_read_sso_config(self, sso_async_session): + async def test_create_and_read_sso_config(self, sso_async_session, sso_secret_settings): """Create and read SSOConfig.""" + encrypted_secret = encrypt_sso_client_secret(_TEST_PLAINTEXT_SECRET, sso_secret_settings) config = SSOConfig( - provider="oidc", - provider_name="Test OIDC", + protocol="oidc", + display_name="Test OIDC", + client_secret_encrypted=encrypted_secret, + provider_settings={ + "protocol": "oidc", + "client_id": "client-id", + "discovery_url": "https://idp.example.com/.well-known/openid-configuration", + "redirect_uri": "/api/v1/login/callback", + "scopes": "openid email profile groups", + "token_endpoint": "https://idp.example.com/token", + "authorization_endpoint": "https://idp.example.com/authorize", + "jwks_uri": "https://idp.example.com/jwks", + "issuer": "https://idp.example.com", + }, ) sso_async_session.add(config) await sso_async_session.commit() await sso_async_session.refresh(config) assert config.id is not None - assert config.provider == "oidc" - assert config.provider_name == "Test OIDC" + assert config.protocol == "oidc" + assert config.display_name == "Test OIDC" + assert config.slug.startswith("sso-") + assert isinstance(config.provider_settings, OIDCProviderSettings) + assert config.provider_settings.client_id == "client-id" + assert config.provider_settings.discovery_url == "https://idp.example.com/.well-known/openid-configuration" + assert config.provider_settings.scopes == "openid email profile groups" + assert config.client_secret_encrypted == encrypted_secret assert config.enabled is True - assert config.enforce_sso is False - assert config.scopes == "openid email profile" + assert config.sort_order == 0 assert config.email_claim == "email" assert config.username_claim == "preferred_username" assert config.user_id_claim == "sub" assert config.created_at is not None assert config.updated_at is not None + assert config.updated_by is None async def test_default_values(self, sso_async_session): """Default values are applied when not specified.""" - config = SSOConfig(provider="oidc", provider_name="Default Test") + config = SSOConfig(protocol="oidc", display_name="Default Test") sso_async_session.add(config) await sso_async_session.commit() await sso_async_session.refresh(config) + assert config.protocol == "oidc" + assert config.provider_settings == OIDCProviderSettings() assert config.enabled is True - assert config.enforce_sso is False - assert config.scopes == "openid email profile" + assert config.sort_order == 0 assert config.email_claim == "email" assert config.username_claim == "preferred_username" assert config.user_id_claim == "sub" assert config.created_by is None + assert config.updated_by is None + + async def test_multiple_enabled_configs_have_deterministic_sort_order_and_one_instance_policy( + self, sso_async_session + ): + settings = SSOSettings(enforce_sso=True) + configs = [ + SSOConfig(display_name="Second", sort_order=20), + SSOConfig(display_name="First", sort_order=10), + ] + sso_async_session.add_all([settings, *configs]) + await sso_async_session.commit() + + result = await sso_async_session.exec( + select(SSOConfig).where(SSOConfig.enabled.is_(True)).order_by(SSOConfig.sort_order, SSOConfig.id) + ) + assert [config.display_name for config in result.all()] == ["First", "Second"] + assert (await sso_async_session.get(SSOSettings, 1)).enforce_sso is True + assert "enforce_sso" not in SSOConfig.__table__.columns + + sso_async_session.add(SSOSettings(id=2, enforce_sso=False)) + with pytest.raises(IntegrityError, match=r"CHECK constraint failed|check constraint"): + await sso_async_session.commit() + + async def test_updated_at_and_updated_by_are_maintained(self, sso_async_session): + updater = User(username="sso_config_updater", password=_TEST_PASSWORD) + config = SSOConfig(display_name="Audited connection") + sso_async_session.add_all([updater, config]) + await sso_async_session.commit() + await sso_async_session.refresh(updater) + await sso_async_session.refresh(config) + + old_timestamp = datetime(2000, 1, 1, tzinfo=timezone.utc) + config.updated_at = old_timestamp + await sso_async_session.commit() + + config.display_name = "Updated connection" + config.updated_by = updater.id + await sso_async_session.commit() + await sso_async_session.refresh(config) + assert config.updated_at.replace(tzinfo=timezone.utc) > old_timestamp + assert config.updated_by == updater.id + + await sso_async_session.delete(updater) + await sso_async_session.commit() + await sso_async_session.refresh(config) + assert config.updated_by is None + + async def test_client_secret_is_stored_as_ciphertext_envelope(self, sso_async_session, sso_secret_settings): + encrypted = encrypt_sso_client_secret(_TEST_PLAINTEXT_SECRET, sso_secret_settings) + config = SSOConfig( + display_name="Encrypted secret connection", + client_secret_encrypted=encrypted, + ) + sso_async_session.add(config) + await sso_async_session.commit() + await sso_async_session.refresh(config) + + stored_value = config.client_secret_encrypted + assert stored_value is not None + assert _TEST_PLAINTEXT_SECRET not in stored_value + assert decrypt_sso_client_secret(stored_value, sso_secret_settings) == _TEST_PLAINTEXT_SECRET + + async def test_client_secret_rejects_plaintext_model_writes(self): + with pytest.raises(ValueError, match="versioned SSO secret envelope"): + SSOConfig( + display_name="Plaintext secret connection", + client_secret_encrypted=_TEST_PLAINTEXT_SECRET, + ) + + async def test_display_name_update_preserves_profile_connection(self, sso_async_session): + """Changing the label does not change the profile's stable connection identity.""" + user = User(username="stable_connection_user", password=_TEST_PASSWORD) + config = SSOConfig(display_name="Original connection name") + sso_async_session.add_all([user, config]) + await sso_async_session.commit() + await sso_async_session.refresh(user) + await sso_async_session.refresh(config) + + original_slug = config.slug + profile = SSOUserProfile( + user_id=user.id, + sso_provider=original_slug, + sso_user_id="stable-subject", + ) + sso_async_session.add(profile) + await sso_async_session.commit() + + config.display_name = "Renamed connection" + await sso_async_session.commit() + await sso_async_session.refresh(profile) + + result = await sso_async_session.exec(select(SSOConfig).where(SSOConfig.slug == profile.sso_provider)) + resolved_config = result.one() + assert config.slug == original_slug + assert profile.sso_provider == original_slug + assert resolved_config.display_name == "Renamed connection" + + async def test_slug_is_url_safe_unique_and_immutable(self, sso_async_session): + config = SSOConfig(display_name="Primary connection") + sso_async_session.add(config) + await sso_async_session.commit() + await sso_async_session.refresh(config) + + assert config.slug.replace("-", "").isalnum() + assert config.slug == config.slug.lower() + + config.slug = "sso-replacement" + with pytest.raises(ValueError, match="immutable after insert"): + await sso_async_session.commit() + + async def test_duplicate_slug_is_rejected(self, sso_async_session): + slug = "sso-fixed-connection" + sso_async_session.add(SSOConfig(slug=slug, display_name="First connection")) + await sso_async_session.commit() + + sso_async_session.add(SSOConfig(slug=slug, display_name="Second connection")) + with pytest.raises(IntegrityError, match=r"UNIQUE constraint failed|unique constraint"): + await sso_async_session.commit() + + async def test_invalid_slug_is_rejected(self): + with pytest.raises(ValueError, match="lowercase letters"): + SSOConfig(slug="Not URL safe!", display_name="Invalid") + + async def test_provider_settings_reject_protocol_mismatch(self): + with pytest.raises(ValueError, match="does not match"): + SSOConfig( + protocol="saml", + display_name="Invalid", + provider_settings={"protocol": "oidc"}, + ) + + async def test_provider_settings_reject_invalid_oidc_payload(self): + with pytest.raises(ValidationError, match="saml_metadata_url"): + SSOConfig( + protocol="oidc", + display_name="Invalid", + provider_settings={ + "protocol": "oidc", + "saml_metadata_url": "https://idp.example.com/metadata", + }, + ) + + async def test_protocol_specific_settings_are_only_in_json_column(self): + columns = SSOConfig.__table__.columns + assert {"protocol", "provider_settings", "client_secret_encrypted"} <= set(columns.keys()) + assert { + "provider", + "client_id", + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + }.isdisjoint(columns.keys()) diff --git a/src/backend/tests/unit/test_sso_secrets.py b/src/backend/tests/unit/test_sso_secrets.py new file mode 100644 index 000000000000..2ca93721488c --- /dev/null +++ b/src/backend/tests/unit/test_sso_secrets.py @@ -0,0 +1,61 @@ +"""Tests for the versioned SSO client-secret encryption contract.""" + +from types import SimpleNamespace + +import pytest +from langflow.services.database.models import ( + SSOSecretError, + decrypt_sso_client_secret, + encrypt_sso_client_secret, +) +from pydantic import SecretStr + +_PLAINTEXT = "downstream-oidc-client-secret" +_DEFAULT_SECRET_KEY = "unit-test-langflow-secret-key-material" # noqa: S105 + + +def _settings(secret_key: str | None = None): + return SimpleNamespace(auth_settings=SimpleNamespace(SECRET_KEY=SecretStr(secret_key or _DEFAULT_SECRET_KEY))) + + +def test_sso_client_secret_round_trip_uses_versioned_envelope(): + encrypted = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + + assert decrypt_sso_client_secret(encrypted, _settings()) == _PLAINTEXT + assert _PLAINTEXT not in encrypted + assert encrypted.startswith("lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:") + + +def test_sso_client_secret_defaults_to_existing_langflow_secret_key(monkeypatch): + from langflow.services import deps + + settings = _settings() + monkeypatch.setattr(deps, "get_settings_service", lambda: settings) + + encrypted = encrypt_sso_client_secret(_PLAINTEXT) + + assert decrypt_sso_client_secret(encrypted) == _PLAINTEXT + + +def test_sso_client_secret_encryption_uses_random_nonces(): + first = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + second = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + + assert first != second + assert decrypt_sso_client_secret(first, _settings()) == _PLAINTEXT + assert decrypt_sso_client_secret(second, _settings()) == _PLAINTEXT + + +def test_sso_client_secret_rejects_wrong_langflow_secret_key(): + encrypted = encrypt_sso_client_secret(_PLAINTEXT, _settings("original-langflow-secret-key")) + + with pytest.raises(SSOSecretError, match="LANGFLOW_SECRET_KEY"): + decrypt_sso_client_secret(encrypted, _settings("different-langflow-secret-key")) + + +def test_sso_client_secret_rejects_unknown_envelope_version(): + encrypted = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + unknown_version = encrypted.replace(":v1:", ":v2:", 1) + + with pytest.raises(SSOSecretError, match="version"): + decrypt_sso_client_secret(unknown_version, _settings()) diff --git a/src/frontend/src/customization/components/__tests__/custom-login-sso-options.test.tsx b/src/frontend/src/customization/components/__tests__/custom-login-sso-options.test.tsx new file mode 100644 index 000000000000..3208f2291789 --- /dev/null +++ b/src/frontend/src/customization/components/__tests__/custom-login-sso-options.test.tsx @@ -0,0 +1,10 @@ +import { render } from "@testing-library/react"; +import CustomLoginSsoOptions from "../custom-login-sso-options"; + +describe("CustomLoginSsoOptions", () => { + it("renders no content in the OSS build", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/src/frontend/src/customization/components/custom-login-sso-options.tsx b/src/frontend/src/customization/components/custom-login-sso-options.tsx new file mode 100644 index 000000000000..0baa3be16dd0 --- /dev/null +++ b/src/frontend/src/customization/components/custom-login-sso-options.tsx @@ -0,0 +1,4 @@ +// OSS no-op; downstream overlays replace this with SSO login options, including their spacing and divider. +export default function CustomLoginSsoOptions() { + return null; +} diff --git a/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx b/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx index 6737e2a86fdf..e8e61a43eeed 100644 --- a/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx +++ b/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx @@ -6,6 +6,7 @@ import { axe } from "@/utils/a11y-test"; import LoginPage from "../index"; const mockLoginMutate = jest.fn(); +const mockCustomLoginSsoOptions = jest.fn((): React.ReactNode => null); jest.mock("@/assets/LangflowLogo.svg?react", () => ({ __esModule: true, @@ -38,6 +39,11 @@ jest.mock("@/customization/components/custom-link", () => ({ ), })); +jest.mock("@/customization/components/custom-login-sso-options", () => ({ + __esModule: true, + default: () => mockCustomLoginSsoOptions(), +})); + jest.mock("@/hooks/use-sanitize-redirect-url", () => ({ useSanitizeRedirectUrl: jest.fn(), })); @@ -54,6 +60,7 @@ function renderLoginPage() { describe("LoginPage accessibility", () => { beforeEach(() => { jest.clearAllMocks(); + mockCustomLoginSsoOptions.mockReturnValue(null); useAlertStore.setState({ notificationList: [], tempNotificationList: [], @@ -84,6 +91,16 @@ describe("LoginPage accessibility", () => { expect(mockLoginMutate).not.toHaveBeenCalled(); }); + it("renders the downstream login options customization slot", () => { + mockCustomLoginSsoOptions.mockReturnValue( +
, + ); + + renderLoginPage(); + + expect(screen.getByTestId("custom-login-sso-options")).toBeInTheDocument(); + }); + it("should_have_no_axe_violations", async () => { const { container } = renderLoginPage(); diff --git a/src/frontend/src/pages/LoginPage/index.tsx b/src/frontend/src/pages/LoginPage/index.tsx index 543662183523..f08e896db733 100644 --- a/src/frontend/src/pages/LoginPage/index.tsx +++ b/src/frontend/src/pages/LoginPage/index.tsx @@ -7,6 +7,7 @@ import ShadTooltip from "@/components/common/shadTooltipComponent"; import { extractApiErrorMessage } from "@/controllers/API/helpers/extract-api-error-message"; import { useLoginUser } from "@/controllers/API/queries/auth"; import { CustomLink } from "@/customization/components/custom-link"; +import CustomLoginSsoOptions from "@/customization/components/custom-login-sso-options"; import { useSanitizeRedirectUrl } from "@/hooks/use-sanitize-redirect-url"; import { appendErrorSuggestion, @@ -113,6 +114,7 @@ export default function LoginPage(): JSX.Element { {t("auth.loginTitle")} +
diff --git a/src/frontend/tests/a11y/auth-pages.a11y.spec.ts b/src/frontend/tests/a11y/auth-pages.a11y.spec.ts index e464100a5d63..2314756ec970 100644 --- a/src/frontend/tests/a11y/auth-pages.a11y.spec.ts +++ b/src/frontend/tests/a11y/auth-pages.a11y.spec.ts @@ -74,6 +74,20 @@ async function driveLoginEmpty(page: LangflowPage) { await expect(page.getByRole("button", { name: /sign in/i })).toBeVisible(); } +async function expectPageToReflow(page: LangflowPage) { + const dimensions = await page.evaluate(() => ({ + clientWidth: document.documentElement.clientWidth, + scrollWidth: document.documentElement.scrollWidth, + })); + expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); +} + +async function driveLoginMobile(page: LangflowPage) { + await page.setViewportSize({ width: 320, height: 800 }); + await driveLoginEmpty(page); + await expectPageToReflow(page); +} + async function driveLoginValidation(page: LangflowPage) { await disableAutoLogin(page); await page.goto("/login"); @@ -107,6 +121,12 @@ async function driveSignupEmpty(page: LangflowPage) { await expect(page.getByRole("button", { name: /sign up/i })).toBeVisible(); } +async function driveSignupMobile(page: LangflowPage) { + await page.setViewportSize({ width: 320, height: 800 }); + await driveSignupEmpty(page); + await expectPageToReflow(page); +} + async function driveSignupMismatch(page: LangflowPage) { await disableAutoLogin(page); await page.goto("/signup"); @@ -163,9 +183,11 @@ const AUTH_STATES: Array<{ drive: (page: LangflowPage) => Promise; }> = [ { label: "auth-login-empty", drive: driveLoginEmpty }, + { label: "auth-login-mobile", drive: driveLoginMobile }, { label: "auth-login-validation", drive: driveLoginValidation }, { label: "auth-login-error-toast", drive: driveLoginErrorToast }, { label: "auth-signup-empty", drive: driveSignupEmpty }, + { label: "auth-signup-mobile", drive: driveSignupMobile }, { label: "auth-signup-mismatch", drive: driveSignupMismatch }, { label: "auth-signup-error-toast", drive: driveSignupErrorToast }, { label: "auth-admin-login-empty", drive: driveAdminLoginEmpty }, @@ -192,7 +214,7 @@ test.describe("auth page accessibility", () => { for (const state of AUTH_STATES) { test( `scans ${state.label} (${theme.name})`, - { tag: ["@release"] }, + { tag: ["@release", "@workspace"] }, async ({ page }) => { await theme.force(page); await state.drive(page); From c902a228718c6a4a61b45062be2bccda2fac3798 Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 5 Aug 2026 09:12:07 -0700 Subject: [PATCH 21/23] fix: address SSO review findings --- .secrets.baseline | 468 ++++++++-------- ...f0a1b2d_add_sso_config_invariant_checks.py | 135 ++++- ...a2b3c_complete_sso_expand_compatibility.py | 506 ++++++++++++++++++ ...add_sso_plugin_tables_sso_user_profile_.py | 4 +- ..._allow_multiple_sso_identities_per_user.py | 193 +++---- .../services/database/models/auth/sso.py | 151 +++++- .../unit/alembic/test_migration_execution.py | 102 +++- .../test_sso_instance_settings_migration.py | 3 +- .../test_sso_protocol_settings_migration.py | 6 +- ...est_sso_rolling_compatibility_migration.py | 437 +++++++++++++++ .../test_sso_stable_connection_migration.py | 119 +++- src/backend/tests/unit/test_auth_settings.py | 19 + src/backend/tests/unit/test_sso_models.py | 164 +++++- src/frontend/src/locales/de.json | 1 + src/frontend/src/locales/en.json | 1 + src/frontend/src/locales/es.json | 1 + src/frontend/src/locales/fr.json | 1 + src/frontend/src/locales/ja.json | 1 + src/frontend/src/locales/pt.json | 1 + src/frontend/src/locales/zh-Hans.json | 1 + .../__tests__/LoginPage.a11y.test.tsx | 9 +- .../__tests__/dot-grid-background.test.tsx | 91 ++++ .../components/dot-grid-background.tsx | 102 +++- src/frontend/src/pages/LoginPage/index.tsx | 1 + .../__tests__/SignUpPage.a11y.test.tsx | 21 +- src/frontend/src/pages/SignUpPage/index.tsx | 29 +- src/lfx/src/lfx/services/settings/auth.py | 9 +- 27 files changed, 2106 insertions(+), 470 deletions(-) create mode 100644 src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py create mode 100644 src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py create mode 100644 src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx diff --git a/.secrets.baseline b/.secrets.baseline index a78d23faf567..1132d9343a10 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2405,7 +2405,7 @@ "filename": "src/backend/tests/unit/test_sso_models.py", "hashed_secret": "6318553899daae2941718c02508aeee938af1a1c", "is_verified": false, - "line_number": 18, + "line_number": 36, "is_secret": false } ], @@ -2871,7 +2871,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "a24f75c63a48219c05df0f39831e66aa1faa6189", "is_verified": false, - "line_number": 245, + "line_number": 246, "is_secret": false }, { @@ -2879,7 +2879,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d66dc430b7aff7717100701ed8a112a9601384ed", "is_verified": false, - "line_number": 254, + "line_number": 255, "is_secret": false }, { @@ -2887,7 +2887,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "94173eade3ec4ad38c084c42138773fa4220afcf", "is_verified": false, - "line_number": 255, + "line_number": 256, "is_secret": false }, { @@ -2895,7 +2895,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "eea025da01ac30af81911443bd4e7e6bc02dd458", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -2903,7 +2903,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cdd92c9d2578e0cdc2e57591425298e0d2ecaaed", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -2911,7 +2911,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02d9eee31941ef494b8b63c50484720e21e9a92b", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -2919,7 +2919,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e7cc230ba82f53830501c9e07f40554ee59596d3", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -2927,7 +2927,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "33401d90fa17dd65aeb2468f398353281be85596", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -2935,7 +2935,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e686334c3bec530179fc0d6f81b75005b8a3541f", "is_verified": false, - "line_number": 636, + "line_number": 637, "is_secret": false }, { @@ -2943,7 +2943,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7981d45b19cca6f0be5538f9864663e7685bf052", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -2951,7 +2951,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cfd32aee63f6617c1aa367ff901f9f16abc25ccc", "is_verified": false, - "line_number": 680, + "line_number": 681, "is_secret": false }, { @@ -2959,7 +2959,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7f9b4ce8fafe27fd7f28e30503ff90be2c51f6f1", "is_verified": false, - "line_number": 687, + "line_number": 688, "is_secret": false }, { @@ -2967,7 +2967,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "a6b7fa3036809eeab8bb104d9c93e8acff5ea081", "is_verified": false, - "line_number": 695, + "line_number": 696, "is_secret": false }, { @@ -2975,14 +2975,14 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "1233410303742c5b32ae1e69c45ea8cbe59433d4", "is_verified": false, - "line_number": 696 + "line_number": 697 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02eed8625057933776c899e6e60175a552231af3", "is_verified": false, - "line_number": 703, + "line_number": 704, "is_secret": false }, { @@ -2990,7 +2990,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "5781fbe4c19d47db72e814fe889cab2ee138b254", "is_verified": false, - "line_number": 969, + "line_number": 970, "is_secret": false }, { @@ -2998,7 +2998,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "0098f0e36ee02376e287e99d76328b76cbf395b8", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -3006,7 +3006,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "85accf06c43563c063d399baf5e1d4dbc2bd69b0", "is_verified": false, - "line_number": 1351, + "line_number": 1352, "is_secret": false }, { @@ -3014,7 +3014,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7af90a388f3f51da50bd0ccfc074ac83a5e77abf", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -3022,7 +3022,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e116d3fdaa720e8822922071931a7da18b34ddd3", "is_verified": false, - "line_number": 1551, + "line_number": 1552, "is_secret": false }, { @@ -3030,7 +3030,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "06e10503631d5b1680e4a08e3a5e0ab109812c48", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -3038,7 +3038,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "de70a102ac9fe37a0714c25453e50afb4f078359", "is_verified": false, - "line_number": 1720, + "line_number": 1721, "is_secret": false }, { @@ -3046,21 +3046,21 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "16bce207c70bb100ece74b75c4c4a79e262cfd5c", "is_verified": false, - "line_number": 1722 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "42174448e882bf43f51c70d0ed3f57d377bb74bb", "is_verified": false, - "line_number": 1723 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "da5b4695b6132090955c121e932d6f9b833c0b1d", "is_verified": false, - "line_number": 1784, + "line_number": 1785, "is_secret": false }, { @@ -3068,7 +3068,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d4f39052ccfaf9ef5b8fd360ed75d23cf12fa2c4", "is_verified": false, - "line_number": 1785, + "line_number": 1786, "is_secret": false }, { @@ -3076,7 +3076,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "bfc0c1d4d70399476ea81e40a48418ed9962832d", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -3084,7 +3084,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "2a24f50d36559883d2bc391e4e99f9a7afd56ef9", "is_verified": false, - "line_number": 1808, + "line_number": 1809, "is_secret": false }, { @@ -3092,7 +3092,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fd0543de99a00bd6e67f3e522a18ed417089b6b4", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -3100,7 +3100,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fa4b98297401af5e6ab7a402330a12715e891325", "is_verified": false, - "line_number": 1956, + "line_number": 1957, "is_secret": false }, { @@ -3108,7 +3108,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9c46c22e7a8ff423706b990fc02c85e0d324f134", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -3116,7 +3116,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "91cde4236a3754b4b57733682edfc50571d954c6", "is_verified": false, - "line_number": 1971, + "line_number": 1972, "is_secret": false }, { @@ -3124,7 +3124,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9625c6b66710b648d27ee284940849a6b5abf3f0", "is_verified": false, - "line_number": 1974, + "line_number": 1975, "is_secret": false }, { @@ -3132,7 +3132,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "370c53187d8e550db85edabdcb86547dab96e300", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -3140,7 +3140,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc57a2140542893b4c9396b6a7186b26351c6fe5", "is_verified": false, - "line_number": 1988, + "line_number": 1989, "is_secret": false }, { @@ -3148,7 +3148,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d922d16c3bbc818b6fab4e8d2662fa361309fa9d", "is_verified": false, - "line_number": 2203, + "line_number": 2204, "is_secret": false }, { @@ -3156,7 +3156,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "8ea601e5cac549eef50d2743f915e4af54135789", "is_verified": false, - "line_number": 2204, + "line_number": 2205, "is_secret": false }, { @@ -3164,7 +3164,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e498ebe977c948f4d1b7eddd6bb77be398ed4d7c", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -3172,7 +3172,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02e680473cb1ee7473e2cdb5b0b6c48cd8aae081", "is_verified": false, - "line_number": 2211, + "line_number": 2212, "is_secret": false }, { @@ -3180,7 +3180,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc50b2aac5d886516a06afb79b4931f4ee66422d", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -3294,7 +3294,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "198b73ffd1a6d9c83101382c6df255edac0d3625", "is_verified": false, - "line_number": 271, + "line_number": 272, "is_secret": false }, { @@ -3302,7 +3302,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "51d69ae6046d2a3be245449d5b38165d5d78def5", "is_verified": false, - "line_number": 273, + "line_number": 274, "is_secret": false }, { @@ -3310,7 +3310,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2e19560960addfd442570c5b2830afc1115cd3f0", "is_verified": false, - "line_number": 275, + "line_number": 276, "is_secret": false }, { @@ -3318,7 +3318,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "ad0cb64bb04fb2dd4e7072fca59f7f23eb025df9", "is_verified": false, - "line_number": 281, + "line_number": 282, "is_secret": false }, { @@ -3326,7 +3326,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "c2d404cb7bad34de0af2136b8efa809ea96170fa", "is_verified": false, - "line_number": 283, + "line_number": 284, "is_secret": false }, { @@ -3334,21 +3334,21 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "4d5967896006c8a626ecef45e6312f6fc7d5b52f", "is_verified": false, - "line_number": 286 + "line_number": 287 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2a2f556b3e73e091f1e21b95ae5bbca523ab1a2a", "is_verified": false, - "line_number": 287 + "line_number": 288 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/en.json", "hashed_secret": "1abaf3f0bb2d459a0fdefe08084901710603a6be", "is_verified": false, - "line_number": 299, + "line_number": 300, "is_secret": false }, { @@ -3356,7 +3356,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "47acd2028cf81b5da88ddeedb2aea4eca4b71fbd", "is_verified": false, - "line_number": 430, + "line_number": 431, "is_secret": false }, { @@ -3364,7 +3364,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "c23d0f85d66cef95852d6a63811ab919de76b02a", "is_verified": false, - "line_number": 462, + "line_number": 463, "is_secret": false }, { @@ -3372,7 +3372,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "76b33d23eee6aaa96f11abb4cbf1e5abd66aa46d", "is_verified": false, - "line_number": 566, + "line_number": 567, "is_secret": false }, { @@ -3380,7 +3380,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e9013532fd4966ed4b3aab1ae711b21f180e4c26", "is_verified": false, - "line_number": 567, + "line_number": 568, "is_secret": false }, { @@ -3388,7 +3388,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f16563f8a1751c141fd1c47148369ca1c380bb1", "is_verified": false, - "line_number": 606, + "line_number": 607, "is_secret": false }, { @@ -3396,7 +3396,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2c4e1babc2d1448dfcfdd24fb92068644227d6f1", "is_verified": false, - "line_number": 699, + "line_number": 700, "is_secret": false }, { @@ -3404,7 +3404,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "7ce9180d54b399cafbe6515ca2ca4710a6b9554c", "is_verified": false, - "line_number": 705, + "line_number": 706, "is_secret": false }, { @@ -3412,7 +3412,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "924cf2c6cefbba9e88dc676bd2575b7f21f9661e", "is_verified": false, - "line_number": 712, + "line_number": 713, "is_secret": false }, { @@ -3420,7 +3420,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "9d6bd29f94e1e565bd5de8f44c875638b26e85b6", "is_verified": false, - "line_number": 713, + "line_number": 714, "is_secret": false }, { @@ -3428,7 +3428,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8e77e1ff96d9be727f25fc393a48f767935660db", "is_verified": false, - "line_number": 1220, + "line_number": 1221, "is_secret": false }, { @@ -3436,7 +3436,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "4a7c565d4c4430e3bb8fa6c560125d5eb37e7c3d", "is_verified": false, - "line_number": 1447, + "line_number": 1448, "is_secret": false }, { @@ -3444,7 +3444,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "29538b53771fff5bad78e3a48a3a8f88c2f141d2", "is_verified": false, - "line_number": 1527, + "line_number": 1528, "is_secret": false }, { @@ -3452,7 +3452,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8d24e5afd2e9b40d21e4300c893a486aab979795", "is_verified": false, - "line_number": 1528, + "line_number": 1529, "is_secret": false }, { @@ -3460,7 +3460,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "ed05045c1666dd556d9a09f9eb6889f42a81aa5a", "is_verified": false, - "line_number": 1624, + "line_number": 1625, "is_secret": false }, { @@ -3468,7 +3468,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f616a4d4abc959d505a5b83f1b3fa1a6bce6b2e", "is_verified": false, - "line_number": 1626, + "line_number": 1627, "is_secret": false }, { @@ -3476,7 +3476,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "1bbb8fd38e8fef4280c7218b961ae2ceeb83bbc9", "is_verified": false, - "line_number": 1627, + "line_number": 1628, "is_secret": false }, { @@ -3484,7 +3484,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "0b8eacdde75bb24c6f29adf660f50ca4d9846358", "is_verified": false, - "line_number": 1629, + "line_number": 1630, "is_secret": false }, { @@ -3492,7 +3492,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8cde7462c1ce55676dc45124068d844ad3b86dcf", "is_verified": false, - "line_number": 1650, + "line_number": 1651, "is_secret": false }, { @@ -3500,7 +3500,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "afbf9aed8c22c3faf4d92dccadabe324d49bc5a8", "is_verified": false, - "line_number": 1881, + "line_number": 1882, "is_secret": false }, { @@ -3508,7 +3508,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "651f66f27bbfaed040c836e22515162e20c4fa0d", "is_verified": false, - "line_number": 1895, + "line_number": 1896, "is_secret": false }, { @@ -3516,7 +3516,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e38ef0e653c39ce1a4dde13da87333c43ced6cab", "is_verified": false, - "line_number": 1980, + "line_number": 1981, "is_secret": false }, { @@ -3524,7 +3524,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "a3e4ec7cb1a57129b074bdd0b9403a008a7f0019", "is_verified": false, - "line_number": 1998, + "line_number": 1999, "is_secret": false } ], @@ -3582,7 +3582,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "fc4cb17d803c31094c9d646d708e73a8f618f1f3", "is_verified": false, - "line_number": 245, + "line_number": 246, "is_secret": false }, { @@ -3590,7 +3590,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "cd21bce173804809539fac60a9168e184f12b284", "is_verified": false, - "line_number": 254, + "line_number": 255, "is_secret": false }, { @@ -3598,7 +3598,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c37c841b6c7b009dad7db06b8d78c5a60e231dce", "is_verified": false, - "line_number": 255, + "line_number": 256, "is_secret": false }, { @@ -3606,7 +3606,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "13a8cca2ca0db558c6a15f4d856d5137f2498f20", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -3614,7 +3614,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "5457a352b60f70e37ab5066d7cadbbe5e0271ef1", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -3622,7 +3622,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "82842c3c911be2b0d80d3c5f8d94b52ac2413de9", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -3630,7 +3630,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "f5503a4b8c5a1977fffa2f0e95d877eb7c7c4ee6", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -3638,7 +3638,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "192b20f92ccd805d527886bed6301b7b371fe348", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -3646,7 +3646,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "765294f5f2dd1748dbd78e9b59afc3a1787697a2", "is_verified": false, - "line_number": 636, + "line_number": 637, "is_secret": false }, { @@ -3654,7 +3654,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "d469771eafcb03655d11eed0f3e8811f2c721d02", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -3662,7 +3662,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "9b230eabf26be3b9d702c89641b984f98d206061", "is_verified": false, - "line_number": 680, + "line_number": 681, "is_secret": false }, { @@ -3670,7 +3670,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "dbfcc263a0fdacc0d688abea5294c8c4968f6dcb", "is_verified": false, - "line_number": 687, + "line_number": 688, "is_secret": false }, { @@ -3678,7 +3678,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "d4c5cdf17c6b6a46dd939ad3f9304e726e60384f", "is_verified": false, - "line_number": 695, + "line_number": 696, "is_secret": false }, { @@ -3686,7 +3686,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "701b1c64da678bb0b94154c2027363e6b7a7909b", "is_verified": false, - "line_number": 696, + "line_number": 697, "is_secret": false }, { @@ -3694,7 +3694,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "8014424f8694a36a30603e437661446e2eb8c299", "is_verified": false, - "line_number": 703, + "line_number": 704, "is_secret": false }, { @@ -3702,7 +3702,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c2051bb5c40f7970a7dfe6cc779623aab444c55e", "is_verified": false, - "line_number": 969, + "line_number": 970, "is_secret": false }, { @@ -3710,7 +3710,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "8986077949f64e6ae0027b081075699ce824e0dd", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -3718,7 +3718,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "96457c5a9db1de39b24d9043cdbe6360f2b19f86", "is_verified": false, - "line_number": 1351, + "line_number": 1352, "is_secret": false }, { @@ -3726,7 +3726,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "0f89cb19175c596fc2ae829f5c09d4d04512cc0d", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -3734,7 +3734,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bd8021713f01b31ab9cb3aa0a88e9a88c6531598", "is_verified": false, - "line_number": 1551, + "line_number": 1552, "is_secret": false }, { @@ -3742,7 +3742,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "d35e35ce246c917e0d30a073363f12eb6b412d5e", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -3750,21 +3750,21 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6848bf99f0e8cbaa9e3afe15724b0592761c9895", "is_verified": false, - "line_number": 1722 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/es.json", "hashed_secret": "71e1d05cafdbe483092571dc8fa209cb4fb602d2", "is_verified": false, - "line_number": 1723 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bd2d890bd01b9843827f1583db8f9ee99564f270", "is_verified": false, - "line_number": 1784, + "line_number": 1785, "is_secret": false }, { @@ -3772,7 +3772,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "b245ddc4e5dfcb57cba8d08000e59c5dc0809ceb", "is_verified": false, - "line_number": 1785, + "line_number": 1786, "is_secret": false }, { @@ -3780,7 +3780,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "443fd1115c9bd30afaac31a42fc192b86476f288", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -3788,7 +3788,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "5c324bbf8f0a15d640a2ca93b4bb0183d106edd8", "is_verified": false, - "line_number": 1808, + "line_number": 1809, "is_secret": false }, { @@ -3796,7 +3796,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "49d8b438094e36554d1d2ec73dbc4adbdca1a891", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -3804,7 +3804,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bdd17febdd742a928650952ec63b0ab0d7eeddac", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -3812,7 +3812,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "15e4f8d5f1d2759b1e83f9082a8ce8e194eb5600", "is_verified": false, - "line_number": 1971, + "line_number": 1972, "is_secret": false }, { @@ -3820,7 +3820,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c8700cb4716f270e7c37191104366e7b1cadc9de", "is_verified": false, - "line_number": 1974, + "line_number": 1975, "is_secret": false }, { @@ -3828,7 +3828,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "60b0610326aa371e079775047f4e66c77aabb0a2", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -3836,7 +3836,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "0147f29ee8d8343d8d70f94b71b0053b268461e3", "is_verified": false, - "line_number": 1988, + "line_number": 1989, "is_secret": false }, { @@ -3844,7 +3844,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "3c89182cf4fc330080cfb2352105827a904d5691", "is_verified": false, - "line_number": 2203, + "line_number": 2204, "is_secret": false }, { @@ -3852,7 +3852,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "998c11b06dfd04593f289507da2b499cf4bca6e8", "is_verified": false, - "line_number": 2204, + "line_number": 2205, "is_secret": false }, { @@ -3860,7 +3860,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "ccaa726ee57e4ccdab0a28886273feee26d647ec", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -3868,7 +3868,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "2e329e7c25daa340c17b719f5371248f691de123", "is_verified": false, - "line_number": 2211, + "line_number": 2212, "is_secret": false }, { @@ -3876,7 +3876,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6e09157ed6a7516c860d26d0747fb229badf190a", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -3950,7 +3950,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "45c1ab5429738bffb961d2445c838901298f97cc", "is_verified": false, - "line_number": 254, + "line_number": 255, "is_secret": false }, { @@ -3958,7 +3958,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "af0189df8c27e3378d3692a62ce4e75985a787e5", "is_verified": false, - "line_number": 255, + "line_number": 256, "is_secret": false }, { @@ -3966,7 +3966,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "a51009a48a2d62cd6a67ebf1989fa680dc4506be", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -3974,7 +3974,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d40974ec3d7496c834d5cfd3287e73336dcf7bf2", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -3982,7 +3982,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "5146a90f97b0950b25e7ce9fb097fefa5a3d6817", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -3990,7 +3990,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7af1b1a9f5075623b9535583703bef93d789dfaf", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -3998,7 +3998,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "cb3228774ef442aa34e93da66e3c2ebbac09a3b6", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -4006,7 +4006,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d3a999088e8965ab9ce5c092d636171c69665666", "is_verified": false, - "line_number": 636, + "line_number": 637, "is_secret": false }, { @@ -4014,7 +4014,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "8e55bc411656a3791cd177161db4595e24e459ee", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -4022,7 +4022,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "c1a3d196da57a31bc8f209f520d7b1453bf18313", "is_verified": false, - "line_number": 695, + "line_number": 696, "is_secret": false }, { @@ -4030,7 +4030,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d2c76f41b31e908dc84bc33fa1a3f210f70cf49a", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -4038,7 +4038,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d3f815b74ab709a72cf9cdce437053fa0dce6287", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -4046,7 +4046,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7f49bb36f7d78041a9c8e79fdead35277249f701", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -4054,7 +4054,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "88362d0d044cf4144e78f646e101110e3f44a9e5", "is_verified": false, - "line_number": 1720, + "line_number": 1721, "is_secret": false }, { @@ -4062,21 +4062,21 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "f924297673a9a6a8f9362349954b1e5273549e28", "is_verified": false, - "line_number": 1722 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "649624ecf03baa824fd2f66c653ef47a10159656", "is_verified": false, - "line_number": 1723 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "89400786d11d1ee5d936d6cd1890e99e4e3aa54b", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -4084,7 +4084,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "b1df0ac48f734b370fc58c591ef0772266f88bb4", "is_verified": false, - "line_number": 1808, + "line_number": 1809, "is_secret": false }, { @@ -4092,7 +4092,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "541b6268f8abceae80c498212280ee7136eba255", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -4100,7 +4100,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "ff959bbe348ba1b8176852cd2e3d6ba8dbf4062d", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -4108,7 +4108,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "73f311b6a3b5bb2b45eccad848db62d85ae37b58", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -4116,7 +4116,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7294ab5b6d4cf3ea2deab49cecb0a7baacd16447", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -4124,7 +4124,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "8dfdd0969287ef24a795d71e17f53f148d37d766", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -4150,7 +4150,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "20cff9da46719805d7fe563353ad67624227b054", "is_verified": false, - "line_number": 245, + "line_number": 246, "is_secret": false }, { @@ -4158,7 +4158,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "72eaf58c3aa4f501f65b26053d3802e3bd7d5620", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -4166,7 +4166,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "3199448fd2effa51a31410243be3e2aa21843521", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -4174,7 +4174,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "712dfa64243423e83279b034519831d3d43f1222", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -4182,7 +4182,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "6d3f6918ae280307281aca3f1dc9489d44565214", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -4190,7 +4190,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "4479d5f53df4c7c7bf5e956086be99f06a1de389", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -4198,7 +4198,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "f94bedb507c27a67d2641721479db8680ce9388e", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -4206,7 +4206,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "6dbe389b0fad2a6c97e803c3b6b5a2d8d22522b5", "is_verified": false, - "line_number": 680, + "line_number": 681, "is_secret": false }, { @@ -4214,7 +4214,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "8ce3126ccfb3229f34fe26a2d087ec4e4e159b9d", "is_verified": false, - "line_number": 687, + "line_number": 688, "is_secret": false }, { @@ -4222,7 +4222,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "de6f8e3f2efe3d1041ea12f5151130da05447df8", "is_verified": false, - "line_number": 703, + "line_number": 704, "is_secret": false }, { @@ -4230,7 +4230,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "dcfb876c95540f212731659ce9603d9741a763cc", "is_verified": false, - "line_number": 969, + "line_number": 970, "is_secret": false }, { @@ -4238,7 +4238,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "cd5eda56c4e5deaeeb92d6154d05c3dc9264553f", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -4246,7 +4246,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "d4d29807d5f0a184bd28b2dad2a034da1970042a", "is_verified": false, - "line_number": 1351, + "line_number": 1352, "is_secret": false }, { @@ -4254,7 +4254,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "3cf78799d5f2ac03456fc62edd2ad65120b64f05", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -4262,7 +4262,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "fcbab870c3fac2547fc10dfe4d134111aabc18b8", "is_verified": false, - "line_number": 1551, + "line_number": 1552, "is_secret": false }, { @@ -4270,7 +4270,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "d46f0990d79bb487d60ff3a855f6916f3e4e6a1a", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -4278,7 +4278,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "c0428438c20615c80570a0bd88158db6a5cc6003", "is_verified": false, - "line_number": 1784, + "line_number": 1785, "is_secret": false }, { @@ -4286,7 +4286,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "c22e6661861b4203426d47e3a1a50363c09c812e", "is_verified": false, - "line_number": 1785, + "line_number": 1786, "is_secret": false }, { @@ -4294,7 +4294,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b360f8c2eb4e5aeab3a63c711ffe95b4fa24afde", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -4302,7 +4302,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b6416594e1de4cfc0351c6562ab936519130f5b2", "is_verified": false, - "line_number": 1795, + "line_number": 1796, "is_secret": false }, { @@ -4310,7 +4310,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b7cd1a2885e8998336c89a87d4df110a6b66e2f2", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -4318,7 +4318,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "5e8bcadfaab440649c70e43a015503e9109a7bfa", "is_verified": false, - "line_number": 1956, + "line_number": 1957, "is_secret": false }, { @@ -4326,7 +4326,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "44ed46c9ee10a3a7308c6d1a5df4cb082c57b9e0", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -4334,7 +4334,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "20e6aa34a3dd984dd602c294ee85714b53a0a872", "is_verified": false, - "line_number": 1971, + "line_number": 1972, "is_secret": false }, { @@ -4342,7 +4342,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b5426a913c9da60eff621ee15e0b195bf6aaa8da", "is_verified": false, - "line_number": 1974, + "line_number": 1975, "is_secret": false }, { @@ -4350,7 +4350,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "415c03a82cd5f390ad3e28dd82c88e36fb540548", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -4358,7 +4358,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "9e7ba6c71dc246eee615e88e31861a563e33b42d", "is_verified": false, - "line_number": 1988, + "line_number": 1989, "is_secret": false }, { @@ -4366,7 +4366,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "18a887dd97555ab726e7f4d4d999dedd236763eb", "is_verified": false, - "line_number": 2203, + "line_number": 2204, "is_secret": false }, { @@ -4374,7 +4374,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "e4777a4b7b9c6fd1911762e35405c0b6b80ae735", "is_verified": false, - "line_number": 2204, + "line_number": 2205, "is_secret": false }, { @@ -4382,7 +4382,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "8847d054d32b57b7a5633fed58444d7237209e24", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -4390,7 +4390,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "369cb27892790b429dacabb4ee12cdfd63d6c250", "is_verified": false, - "line_number": 2211, + "line_number": 2212, "is_secret": false }, { @@ -4398,7 +4398,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b9743a370a7e12046cbb762dda96137a0327fc28", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -4464,7 +4464,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "8390eaf853b58ba7ac38db1b8a2cc0808e701547", "is_verified": false, - "line_number": 245, + "line_number": 246, "is_secret": false }, { @@ -4472,7 +4472,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6fc474866c39ce50d555b284bb6c530d22db3f7c", "is_verified": false, - "line_number": 254, + "line_number": 255, "is_secret": false }, { @@ -4480,7 +4480,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e2a8853c024be2da10167eb42b19c6cc259f66db", "is_verified": false, - "line_number": 255, + "line_number": 256, "is_secret": false }, { @@ -4488,7 +4488,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6ab7275b94c1be125facd6df7ebdfee8b26931e3", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -4496,7 +4496,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "da4345e5dde7d1d6f387ec90d089cb0a944843cf", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -4504,7 +4504,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "15444730dd1d753e5ab5717d1dc9a76f1540352d", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -4512,7 +4512,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "265a547151e5ac45c15a75c94d14dd585066e379", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -4520,7 +4520,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "42f1178697edfac6f6b2532587187fdd33accaf0", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -4528,7 +4528,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "60743e0ecf39ab99f572bb2f4358ce144499395c", "is_verified": false, - "line_number": 636, + "line_number": 637, "is_secret": false }, { @@ -4536,7 +4536,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e8d5154134abcd60165e058618f2cf069841ee38", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -4544,7 +4544,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "1422160d3d897876d721df10805d9fe293add09d", "is_verified": false, - "line_number": 680, + "line_number": 681, "is_secret": false }, { @@ -4552,7 +4552,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "cc0bedebf2fadcc1bfbfa9daf41bba27a2d9aad0", "is_verified": false, - "line_number": 687, + "line_number": 688, "is_secret": false }, { @@ -4560,7 +4560,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "7caaac3befa935700513d4123b881c383af03452", "is_verified": false, - "line_number": 695, + "line_number": 696, "is_secret": false }, { @@ -4568,14 +4568,14 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "36ffc06a145b35e699588e21f91d0847c758b761", "is_verified": false, - "line_number": 696 + "line_number": 697 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "792615431c8645d5df08db0bf84f5db8014f4a17", "is_verified": false, - "line_number": 703, + "line_number": 704, "is_secret": false }, { @@ -4583,7 +4583,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "861c6c413b2f9ca56854fb7602d3c690d8a1dc97", "is_verified": false, - "line_number": 969, + "line_number": 970, "is_secret": false }, { @@ -4591,7 +4591,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4306c81dbceadb0effaac422f16658897a8034ef", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -4599,7 +4599,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "539811754f4eced6174b2365b581aca032a29904", "is_verified": false, - "line_number": 1351, + "line_number": 1352, "is_secret": false }, { @@ -4607,7 +4607,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "8610717164ef598531238326ede1ac540dd7a74c", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -4615,7 +4615,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "50e3a9595f402d5afdd0ee67c71b68cf57bab78d", "is_verified": false, - "line_number": 1551, + "line_number": 1552, "is_secret": false }, { @@ -4623,7 +4623,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "29022bd1c2181fbd36940c13cc3fa70041b48ab6", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -4631,21 +4631,21 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "9fc16a99501e66da0391bf67c7a16b236b23bed9", "is_verified": false, - "line_number": 1722 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6e40094f370649da3568ca1c747fb5a17575c2be", "is_verified": false, - "line_number": 1723 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "924dffdc29e36152ac5c5ad72472d4012c453619", "is_verified": false, - "line_number": 1784, + "line_number": 1785, "is_secret": false }, { @@ -4653,7 +4653,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "3cf10d8f342037e71cf0fa4f902939cce046db88", "is_verified": false, - "line_number": 1785, + "line_number": 1786, "is_secret": false }, { @@ -4661,7 +4661,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "83107836c3fbb4f7126e1c62127d867f2115c66e", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -4669,7 +4669,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e5de519e1707a85ae531c311d440ee66c9d92013", "is_verified": false, - "line_number": 1808, + "line_number": 1809, "is_secret": false }, { @@ -4677,7 +4677,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "5e210e2b1f3abec2110bc9be8e64d7f330fa1077", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -4685,7 +4685,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f0b397ea447a3fa2ad71bf2275fef3c57c19ced", "is_verified": false, - "line_number": 1956, + "line_number": 1957, "is_secret": false }, { @@ -4693,7 +4693,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "9a36a9b7c6bfc059b4f02372fff29d59a7d28956", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -4701,7 +4701,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "d041f1238574c9fda80f329a69df103dd0362201", "is_verified": false, - "line_number": 1971, + "line_number": 1972, "is_secret": false }, { @@ -4709,7 +4709,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "cb9c85cb748ddd0400375630f5ec0206759ddb89", "is_verified": false, - "line_number": 1974, + "line_number": 1975, "is_secret": false }, { @@ -4717,7 +4717,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f426df5e9101c108190dccfd037a953d0d402f2", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -4725,7 +4725,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "01817544892d96dedca2ad884fa493fea87ba133", "is_verified": false, - "line_number": 1988, + "line_number": 1989, "is_secret": false }, { @@ -4733,7 +4733,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "187a0dc92fd69a86bd0e675941e7f1898e3b55e1", "is_verified": false, - "line_number": 2203, + "line_number": 2204, "is_secret": false }, { @@ -4741,7 +4741,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "685a15b5a75fafecd74ad06561f50711e97d7275", "is_verified": false, - "line_number": 2204, + "line_number": 2205, "is_secret": false }, { @@ -4749,7 +4749,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "47a5deeb29d9b4f9d88464d050560a7f7b294ddd", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -4757,7 +4757,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "32c3af10cab241e0b425cebc42831cbaa236f1c6", "is_verified": false, - "line_number": 2211, + "line_number": 2212, "is_secret": false }, { @@ -4765,7 +4765,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4cb21cbeac2b9488b0fde6c6291caf37245c54c2", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -4791,7 +4791,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "5df1d83e18c6e5903e3f4805f0af1415fd050ef7", "is_verified": false, - "line_number": 245, + "line_number": 246, "is_secret": false }, { @@ -4799,7 +4799,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "3244c8c5d271a1057fc569daacc04432f9c870f7", "is_verified": false, - "line_number": 463, + "line_number": 464, "is_secret": false }, { @@ -4807,7 +4807,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "e15b9952a55ccd2f94bf52984458b57a7b1f8fdd", "is_verified": false, - "line_number": 464, + "line_number": 465, "is_secret": false }, { @@ -4815,7 +4815,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "0ecfe8c3af9e1c44ecb32a27b2e873ea862d972a", "is_verified": false, - "line_number": 487, + "line_number": 488, "is_secret": false }, { @@ -4823,7 +4823,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8ccc7338429262ed661178a7bc14e6d9f0d86661", "is_verified": false, - "line_number": 559, + "line_number": 560, "is_secret": false }, { @@ -4831,7 +4831,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "15017ab3ec0dda97b754524421ed4dcff8bc2252", "is_verified": false, - "line_number": 634, + "line_number": 635, "is_secret": false }, { @@ -4839,7 +4839,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "4d24cbb80b72bf05ad9710c3a801359cf417a91a", "is_verified": false, - "line_number": 652, + "line_number": 653, "is_secret": false }, { @@ -4847,7 +4847,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d9bbe5c31c394c33128697ada5ad6bb2386b2d50", "is_verified": false, - "line_number": 680, + "line_number": 681, "is_secret": false }, { @@ -4855,7 +4855,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "47d79393f1549ed6bc311a334cda7f88bea287cc", "is_verified": false, - "line_number": 687, + "line_number": 688, "is_secret": false }, { @@ -4863,7 +4863,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "596d019fb62944b96319dc89f44d017cd8fb7152", "is_verified": false, - "line_number": 703, + "line_number": 704, "is_secret": false }, { @@ -4871,7 +4871,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "f316aecc6ce46d58af4c90ec4c007a3cb2339949", "is_verified": false, - "line_number": 969, + "line_number": 970, "is_secret": false }, { @@ -4879,7 +4879,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8559e3a2fe94eb8b5201083eb5e4f50680934376", "is_verified": false, - "line_number": 1350, + "line_number": 1351, "is_secret": false }, { @@ -4887,7 +4887,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "bbfbbb23d7af0d8f4b452ca1cf95af7bbe98b547", "is_verified": false, - "line_number": 1351, + "line_number": 1352, "is_secret": false }, { @@ -4895,7 +4895,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "925ec0cb3d802b1c10bd74612833338e38f123e9", "is_verified": false, - "line_number": 1479, + "line_number": 1480, "is_secret": false }, { @@ -4903,7 +4903,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "6d09a8bbd2e67a11d0f0be76d5c2f1bc239327bc", "is_verified": false, - "line_number": 1551, + "line_number": 1552, "is_secret": false }, { @@ -4911,7 +4911,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "00e66990421091c8a0f9084a1997c317ffe34000", "is_verified": false, - "line_number": 1717, + "line_number": 1718, "is_secret": false }, { @@ -4919,7 +4919,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d370647062f8a9ebf57183122e6aacd46c2a7bda", "is_verified": false, - "line_number": 1784, + "line_number": 1785, "is_secret": false }, { @@ -4927,7 +4927,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7d2d5daebdd522b7de7fab94e153cbea4620c369", "is_verified": false, - "line_number": 1786, + "line_number": 1787, "is_secret": false }, { @@ -4935,7 +4935,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "74a3dd23222f2bc1888171506f585281009f79a4", "is_verified": false, - "line_number": 1819, + "line_number": 1820, "is_secret": false }, { @@ -4943,7 +4943,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "c10e8dbe79654ae8ed496c4c50f5e188b7924a4e", "is_verified": false, - "line_number": 1956, + "line_number": 1957, "is_secret": false }, { @@ -4951,7 +4951,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7223cfc50b72fe9b091b46275f561526aba9e705", "is_verified": false, - "line_number": 1969, + "line_number": 1970, "is_secret": false }, { @@ -4959,7 +4959,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "4aa9a3231780af9a37413cb7034ecdf72d43cfec", "is_verified": false, - "line_number": 1971, + "line_number": 1972, "is_secret": false }, { @@ -4967,7 +4967,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "68af7627d36cc6b923f5928a198165857f1ba5f1", "is_verified": false, - "line_number": 1974, + "line_number": 1975, "is_secret": false }, { @@ -4975,7 +4975,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7acd3659ef48c980ce4aef1d0f2f218ff25efc89", "is_verified": false, - "line_number": 1979, + "line_number": 1980, "is_secret": false }, { @@ -4983,7 +4983,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7443c6e0ef4c013461140fb1aa2a7a60b40a0b89", "is_verified": false, - "line_number": 1988, + "line_number": 1989, "is_secret": false }, { @@ -4991,7 +4991,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "3c274d81d83ceba5bfaaaf2ac4f6b0aedcf431a0", "is_verified": false, - "line_number": 2203, + "line_number": 2204, "is_secret": false }, { @@ -4999,7 +4999,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "583ddc85a613ceef2e54f9d2251113f3acef73a3", "is_verified": false, - "line_number": 2204, + "line_number": 2205, "is_secret": false }, { @@ -5007,7 +5007,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8494b7db80f8bfedeef8cc5697bed5339cd1a4ee", "is_verified": false, - "line_number": 2210, + "line_number": 2211, "is_secret": false }, { @@ -5015,7 +5015,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d45af2467cb991008a273eb0c4590fa9a96fc497", "is_verified": false, - "line_number": 2211, + "line_number": 2212, "is_secret": false }, { @@ -5023,7 +5023,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8da5daf1ef780fe5e37ae02bee48cbdb023c7101", "is_verified": false, - "line_number": 2221, + "line_number": 2222, "is_secret": false } ], @@ -7301,5 +7301,5 @@ } ] }, - "generated_at": "2026-08-05T13:21:40Z" + "generated_at": "2026-08-05T16:25:25Z" } diff --git a/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py b/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py index 3de8285dfa56..9552363fcfe6 100644 --- a/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py +++ b/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py @@ -7,6 +7,7 @@ Phase: EXPAND """ +import re from collections.abc import Sequence from urllib.parse import urlsplit @@ -27,12 +28,19 @@ # later batch_alter drops of protocol / provider_settings on SQLite. _PROTOCOL_CHECK = "ck_sso_config_protocol_consistency" _ENABLED_CHECK = "ck_sso_config_enabled_complete" +_CLIENT_SECRET_CHECK = "ck_sso_config_client_secret_envelope" # noqa: S105 # pragma: allowlist secret _LEGACY_DOUBLED_PROTOCOL_CHECK = "ck_sso_config_ck_sso_config_protocol_consistency" _LEGACY_DOUBLED_ENABLED_CHECK = "ck_sso_config_ck_sso_config_enabled_complete" +_LEGACY_DOUBLED_CLIENT_SECRET_CHECK = "ck_sso_config_ck_sso_config_client_secret_envelope" # noqa: S105 # pragma: allowlist secret _PROTOCOL_CHECK_ALIASES = (_PROTOCOL_CHECK, _LEGACY_DOUBLED_PROTOCOL_CHECK) _ENABLED_CHECK_ALIASES = (_ENABLED_CHECK, _LEGACY_DOUBLED_ENABLED_CHECK) +_CLIENT_SECRET_CHECK_ALIASES = (_CLIENT_SECRET_CHECK, _LEGACY_DOUBLED_CLIENT_SECRET_CHECK) _SLUG_TRIGGER = "trg_sso_config_slug_immutable" _POSTGRES_TRIGGER_FUNCTION = "prevent_sso_config_slug_update" +_SUPPORTED_PROTOCOLS = ("oidc", "saml", "ldap") +_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" +_ENVELOPE_NONCE_LENGTH = 16 +_ENVELOPE_MIN_CIPHERTEXT_LENGTH = 22 _REMOTE_URL_FIELDS = ( "discovery_url", "token_endpoint", @@ -40,6 +48,7 @@ "jwks_uri", "issuer", ) +_INVALID_PERCENT_ESCAPE_PATTERN = re.compile(r"%(?![0-9A-Fa-f]{2})") def _config_table() -> sa.Table: @@ -47,9 +56,14 @@ def _config_table() -> sa.Table: return sa.Table( _CONFIG_TABLE, metadata, - sa.Column("id", sa.Uuid()), + # Leave the id untyped so comparisons reuse the exact DBAPI value. + # SQLite installations can contain either 32-character UUID hex or + # historical hyphenated UUID strings, and coercing through ``sa.Uuid`` + # would normalize the latter before UPDATE and fail to match the row. + sa.Column("id"), sa.Column("slug", sa.String()), sa.Column("protocol", sa.String()), + sa.Column("provider", sa.String()), sa.Column("enabled", sa.Boolean()), sa.Column("client_secret_encrypted", sa.String()), sa.Column("provider_settings", sa.JSON()), @@ -64,23 +78,76 @@ def _nonblank_json_string(json_column: sa.Column, key: str) -> sa.ColumnElement[ def _http_json_url_or_null(json_column: sa.Column, key: str) -> sa.ColumnElement[bool]: value = json_column[key].as_string() normalized = sa.func.lower(value) - return sa.or_(value.is_(None), normalized.like("http://%"), normalized.like("https://%")) + has_no_whitespace = sa.and_( + value.not_like("% %"), + value.not_like("%\t%"), + value.not_like("%\n%"), + value.not_like("%\r%"), + ) + valid_http_host_start = sa.and_( + normalized.like("http://%"), + sa.func.length(value) > len("http://"), + sa.func.substr(value, len("http://") + 1, 1).not_in(("/", "\\", "?", "#", ":")), + ) + valid_https_host_start = sa.and_( + normalized.like("https://%"), + sa.func.length(value) > len("https://"), + sa.func.substr(value, len("https://") + 1, 1).not_in(("/", "\\", "?", "#", ":")), + ) + return sa.or_(value.is_(None), sa.and_(has_no_whitespace, sa.or_(valid_http_host_start, valid_https_host_start))) -def _protocol_check(table: sa.Table) -> sa.ColumnElement[bool]: +def _protocol_check( + table: sa.Table, + *, + allow_supported_mismatch: bool = False, +) -> sa.ColumnElement[bool]: settings_protocol = table.c.provider_settings["protocol"].as_string() - return sa.and_( - table.c.protocol == "oidc", + synchronized = sa.and_( + table.c.protocol.in_(_SUPPORTED_PROTOCOLS), settings_protocol.is_not(None), settings_protocol == table.c.protocol, ) + if allow_supported_mismatch: + # SQLite has no way for a BEFORE trigger to assign NEW values. Its + # EXPAND compatibility trigger is therefore AFTER UPDATE, so admit a + # supported temporary mismatch long enough for that trigger to choose + # one representation and make the row coherent. Invalid protocols are + # still rejected before the trigger runs. + synchronized = sa.and_( + table.c.protocol.in_(_SUPPORTED_PROTOCOLS), + settings_protocol.in_(_SUPPORTED_PROTOCOLS), + ) + return sa.or_( + # Temporary N-1 INSERT state. SQLite evaluates CHECK constraints before + # the head revision's AFTER INSERT compatibility trigger can populate + # the new representation. + sa.and_( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.is_not(None), + ), + synchronized, + ) def _enabled_check(table: sa.Table) -> sa.ColumnElement[bool]: settings = table.c.provider_settings return sa.or_( + # See _protocol_check: the compatibility trigger immediately fills the + # typed fields. Final constraints then validate the synchronized row. + sa.and_( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.is_not(None), + ), table.c.enabled.is_(False), + # Historical Enterprise plugins can continue executing their released + # SAML/LDAP rows during the rolling window. OIDC-only completeness is + # enforced below without mutating those legacy configurations. + table.c.protocol.in_(("saml", "ldap")), sa.and_( + table.c.protocol == "oidc", table.c.client_secret_encrypted.is_not(None), _nonblank_json_string(settings, "client_id"), sa.or_( @@ -96,14 +163,39 @@ def _enabled_check(table: sa.Table) -> sa.ColumnElement[bool]: ) +def _client_secret_check(table: sa.Table) -> sa.ColumnElement[bool]: + secret = table.c.client_secret_encrypted + separator_position = len(_ENVELOPE_HEADER) + _ENVELOPE_NONCE_LENGTH + 1 + minimum_length = separator_position + _ENVELOPE_MIN_CIPHERTEXT_LENGTH + return sa.or_( + secret.is_(None), + sa.and_( + sa.func.substr(secret, 1, len(_ENVELOPE_HEADER)) == _ENVELOPE_HEADER, + sa.func.substr(secret, separator_position, 1) == ":", + sa.func.length(secret) >= minimum_length, + ), + ) + + def _is_http_url(value: object) -> bool: if not isinstance(value, str) or not value.strip(): return False try: - parsed = urlsplit(value.strip()) + parsed = urlsplit(value) + valid = ( + parsed.scheme.lower() in {"http", "https"} + and bool(parsed.netloc) + and parsed.hostname is not None + and not any(character.isspace() for character in value) + and _INVALID_PERCENT_ESCAPE_PATTERN.search(value) is None + and "%" not in parsed.hostname + ) + if valid: + # Accessing ``port`` forces urllib to reject malformed ports. + _ = parsed.port except ValueError: return False - return parsed.scheme.lower() in {"http", "https"} and parsed.hostname is not None + return valid def _disable_invalid_enabled_configs(conn: sa.Connection, table: sa.Table) -> None: @@ -111,11 +203,16 @@ def _disable_invalid_enabled_configs(conn: sa.Connection, table: sa.Table) -> No rows = conn.execute( sa.select( table.c.id, + table.c.protocol, table.c.client_secret_encrypted, table.c.provider_settings, ).where(table.c.enabled.is_(True)) ).mappings() for row in rows: + # Preserve historical Enterprise protocols exactly. Their released + # plugin remains responsible for protocol-specific completeness. + if row["protocol"] != "oidc": + continue settings = row["provider_settings"] or {} has_client_id = isinstance(settings.get("client_id"), str) and bool(settings["client_id"].strip()) has_discovery = _is_http_url(settings.get("discovery_url")) @@ -137,7 +234,7 @@ def _raise_for_protocol_mismatches(conn: sa.Connection, table: sa.Table) -> None invalid_ids = [ str(row.id) for row in conn.execute(sa.select(table.c.id, table.c.protocol, table.c.provider_settings)) - if row.protocol != "oidc" + if row.protocol not in _SUPPORTED_PROTOCOLS or not isinstance(row.provider_settings, dict) or row.provider_settings.get("protocol") != row.protocol ] @@ -160,24 +257,36 @@ def _create_checks(conn: sa.Connection, table: sa.Table) -> None: existing = _existing_check_names(conn) need_protocol = not existing.intersection(_PROTOCOL_CHECK_ALIASES) need_enabled = not existing.intersection(_ENABLED_CHECK_ALIASES) - if not need_protocol and not need_enabled: + need_client_secret = not existing.intersection(_CLIENT_SECRET_CHECK_ALIASES) + if not need_protocol and not need_enabled and not need_client_secret: return if conn.dialect.name == "sqlite": with op.batch_alter_table(_CONFIG_TABLE, recreate="always") as batch_op: if need_protocol: - batch_op.create_check_constraint(op.f(_PROTOCOL_CHECK), _protocol_check(table)) + batch_op.create_check_constraint( + op.f(_PROTOCOL_CHECK), + _protocol_check(table, allow_supported_mismatch=True), + ) if need_enabled: batch_op.create_check_constraint(op.f(_ENABLED_CHECK), _enabled_check(table)) + if need_client_secret: + batch_op.create_check_constraint(op.f(_CLIENT_SECRET_CHECK), _client_secret_check(table)) return if need_protocol: op.create_check_constraint(op.f(_PROTOCOL_CHECK), _CONFIG_TABLE, _protocol_check(table)) if need_enabled: op.create_check_constraint(op.f(_ENABLED_CHECK), _CONFIG_TABLE, _enabled_check(table)) + if need_client_secret: + op.create_check_constraint(op.f(_CLIENT_SECRET_CHECK), _CONFIG_TABLE, _client_secret_check(table)) def _drop_checks(conn: sa.Connection) -> None: existing = _existing_check_names(conn) - to_drop = [name for name in (*_ENABLED_CHECK_ALIASES, *_PROTOCOL_CHECK_ALIASES) if name in existing] + to_drop = [ + name + for name in (*_CLIENT_SECRET_CHECK_ALIASES, *_ENABLED_CHECK_ALIASES, *_PROTOCOL_CHECK_ALIASES) + if name in existing + ] if not to_drop: return if conn.dialect.name == "sqlite": @@ -200,7 +309,7 @@ def _create_slug_trigger(conn: sa.Connection) -> None: CREATE TRIGGER {_SLUG_TRIGGER} BEFORE UPDATE OF slug ON {_CONFIG_TABLE} FOR EACH ROW - WHEN NEW.slug IS NOT OLD.slug + WHEN OLD.slug IS NOT NULL AND NEW.slug IS NOT OLD.slug BEGIN SELECT RAISE(ABORT, 'SSOConfig.slug is immutable after insert'); END @@ -215,7 +324,7 @@ def _create_slug_trigger(conn: sa.Connection) -> None: CREATE OR REPLACE FUNCTION {_POSTGRES_TRIGGER_FUNCTION}() RETURNS trigger AS $$ BEGIN - IF NEW.slug IS DISTINCT FROM OLD.slug THEN + IF OLD.slug IS NOT NULL AND NEW.slug IS DISTINCT FROM OLD.slug THEN RAISE EXCEPTION 'SSOConfig.slug is immutable after insert'; END IF; RETURN NEW; diff --git a/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py b/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py new file mode 100644 index 000000000000..f3d3503ded1e --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py @@ -0,0 +1,506 @@ +"""complete rolling-compatible SSO schema expansion + +Revision ID: 8d9e0f1a2b3c +Revises: 7c8e9f0a1b2d +Create Date: 2026-08-05 + +Phase: EXPAND + +The preceding SSO revisions deliberately retain both the released scalar +columns and the new typed columns. Temporary triggers in this revision keep +those representations coherent while N-1 and N services coexist. A future +CONTRACT revision may remove these triggers and the legacy columns only after +all released consumers have switched to the new representation. + +The released client-secret column is the deliberate exception to write +compatibility: a plaintext N-1 write is rejected by the envelope constraint +rather than reintroducing plaintext credentials. Secret rotation during the +rolling window must go through the N admin API, which encrypts before storage. + +``provider_name`` also remains the stable legacy identity key during EXPAND. +Changing the N-only ``display_name`` therefore does not rewrite it (or any +profiles); a future CONTRACT migration can re-key profiles atomically. + +The original SSO table migration shipped ``created_at`` and ``updated_at`` as +UTC-naive timestamps. PostgreSQL is corrected forward here rather than by +editing that already-applied migration. SQLite has no distinct timezone-aware +datetime affinity, so the timestamp conversion is intentionally a no-op there. +""" + +# ruff: noqa: S608 + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op +from langflow.utils import migration + +# revision identifiers, used by Alembic. +revision: str = "8d9e0f1a2b3c" # pragma: allowlist secret +down_revision: str | None = "7c8e9f0a1b2d" # pragma: allowlist secret +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_CONFIG_TABLE = "sso_config" +_SETTINGS_TABLE = "sso_settings" +_TIMESTAMP_COLUMNS = ("created_at", "updated_at") +_PROVIDER_SETTING_COLUMNS = ( + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + "client_id", +) +_COMPAT_COLUMNS = { + "id", + "slug", + "display_name", + "provider_name", + "protocol", + "provider", + "provider_settings", + "enforce_sso", + *_PROVIDER_SETTING_COLUMNS, +} + +_SQLITE_CONFIG_INSERT_TRIGGER = "trg_sso_config_expand_compat_insert" +_SQLITE_CONFIG_UPDATE_TRIGGER = "trg_sso_config_expand_compat_update" +_SQLITE_CONFIG_ENFORCE_TRIGGER = "trg_sso_config_expand_compat_enforce" +_SQLITE_SETTINGS_ENFORCE_TRIGGER = "trg_sso_settings_expand_compat_enforce" +_POSTGRES_CONFIG_FUNCTION = "sync_sso_config_expand_compat" +_POSTGRES_CONFIG_TRIGGER = "trg_sso_config_expand_compat" +_POSTGRES_CONFIG_ENFORCE_FUNCTION = "sync_sso_config_enforce_sso_compat" +_POSTGRES_CONFIG_ENFORCE_TRIGGER = "trg_sso_config_enforce_sso_compat" +_POSTGRES_SETTINGS_ENFORCE_FUNCTION = "sync_sso_settings_enforce_sso_compat" +_POSTGRES_SETTINGS_ENFORCE_TRIGGER = "trg_sso_settings_enforce_sso_compat" + + +def _column_names(conn: sa.Connection, table_name: str) -> set[str]: + return {column["name"] for column in sa.inspect(conn).get_columns(table_name)} + + +def _has_compatibility_schema(conn: sa.Connection) -> bool: + return ( + migration.table_exists(_CONFIG_TABLE, conn) + and migration.table_exists(_SETTINGS_TABLE, conn) + and _column_names(conn, _CONFIG_TABLE) >= _COMPAT_COLUMNS + ) + + +def _sqlite_provider_settings_json(prefix: str = "NEW") -> str: + pairs = [f"'protocol', COALESCE({prefix}.provider, {prefix}.protocol, 'oidc')"] + pairs.extend(f"'{name}', {prefix}.{name}" for name in _PROVIDER_SETTING_COLUMNS) + return f"json_object({', '.join(pairs)})" + + +def _sqlite_legacy_settings_changed() -> str: + return " OR ".join(f"NEW.{name} IS NOT OLD.{name}" for name in ("provider", *_PROVIDER_SETTING_COLUMNS)) + + +def _sqlite_insert_assignments() -> str: + settings_json = _sqlite_provider_settings_json() + assignments = [ + "slug = COALESCE(NEW.slug, 'sso-' || lower(replace(CAST(NEW.id AS TEXT), '-', '')))", + "display_name = COALESCE(NEW.display_name, NEW.provider_name)", + "provider_name = COALESCE(NEW.provider_name, NEW.display_name)", + "protocol = COALESCE(NEW.protocol, NEW.provider, json_extract(NEW.provider_settings, '$.protocol'), 'oidc')", + "provider = COALESCE(NEW.provider, NEW.protocol, json_extract(NEW.provider_settings, '$.protocol'), 'oidc')", + f"provider_settings = COALESCE(NEW.provider_settings, {settings_json})", + """enforce_sso = CASE + WHEN NEW.enforce_sso = 1 THEN 1 + ELSE COALESCE((SELECT enforce_sso FROM sso_settings WHERE id = 1), 0) + END""", + ] + assignments.extend( + f"{name} = COALESCE(NEW.{name}, json_extract(NEW.provider_settings, '$.{name}'))" + for name in _PROVIDER_SETTING_COLUMNS + ) + return ",\n ".join(assignments) + + +def _sqlite_update_assignments() -> str: + legacy_settings_changed = _sqlite_legacy_settings_changed() + settings_json = _sqlite_provider_settings_json() + assignments = [ + """display_name = CASE + WHEN NEW.display_name IS NOT OLD.display_name THEN NEW.display_name + WHEN NEW.provider_name IS NOT OLD.provider_name THEN NEW.provider_name + ELSE NEW.display_name + END""", + """provider_name = CASE + WHEN NEW.provider_name IS NOT OLD.provider_name THEN NEW.provider_name + ELSE OLD.provider_name + END""", + """protocol = CASE + WHEN NEW.protocol IS NOT OLD.protocol THEN NEW.protocol + WHEN NEW.provider IS NOT OLD.provider THEN NEW.provider + WHEN NEW.provider_settings IS NOT OLD.provider_settings + THEN json_extract(NEW.provider_settings, '$.protocol') + ELSE NEW.protocol + END""", + """provider = CASE + WHEN NEW.protocol IS NOT OLD.protocol THEN NEW.protocol + WHEN NEW.provider IS NOT OLD.provider THEN NEW.provider + WHEN NEW.provider_settings IS NOT OLD.provider_settings + THEN json_extract(NEW.provider_settings, '$.protocol') + ELSE NEW.provider + END""", + f"""provider_settings = CASE + WHEN NEW.protocol IS NOT OLD.protocol + THEN json_set(COALESCE(NEW.provider_settings, '{{}}'), '$.protocol', NEW.protocol) + WHEN NEW.provider IS NOT OLD.provider + AND NEW.provider_settings IS NOT OLD.provider_settings + THEN json_set(COALESCE(NEW.provider_settings, '{{}}'), '$.protocol', NEW.provider) + WHEN NEW.provider IS NOT OLD.provider THEN {settings_json} + WHEN NEW.provider_settings IS NOT OLD.provider_settings THEN NEW.provider_settings + WHEN {legacy_settings_changed} THEN {settings_json} + ELSE NEW.provider_settings + END""", + ] + assignments.extend( + f"""{name} = CASE + WHEN NEW.provider_settings IS NOT OLD.provider_settings + THEN json_extract(NEW.provider_settings, '$.{name}') + ELSE NEW.{name} + END""" + for name in _PROVIDER_SETTING_COLUMNS + ) + return ",\n ".join(assignments) + + +def _create_sqlite_compatibility_triggers() -> None: + _drop_sqlite_compatibility_triggers() + update_columns = ", ".join( + ("display_name", "provider_name", "protocol", "provider", "provider_settings", *_PROVIDER_SETTING_COLUMNS) + ) + changed = " OR ".join( + f"NEW.{name} IS NOT OLD.{name}" + for name in ( + "display_name", + "provider_name", + "protocol", + "provider", + "provider_settings", + *_PROVIDER_SETTING_COLUMNS, + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SQLITE_CONFIG_INSERT_TRIGGER} + AFTER INSERT ON {_CONFIG_TABLE} + FOR EACH ROW + BEGIN + UPDATE {_CONFIG_TABLE} + SET {_sqlite_insert_assignments()} + WHERE id = NEW.id; + UPDATE {_SETTINGS_TABLE} + SET enforce_sso = 1 + WHERE id = 1 AND NEW.enforce_sso = 1; + UPDATE {_CONFIG_TABLE} + SET enforce_sso = 1 + WHERE NEW.enforce_sso = 1 AND enforce_sso IS NOT 1; + END + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SQLITE_CONFIG_UPDATE_TRIGGER} + AFTER UPDATE OF {update_columns} ON {_CONFIG_TABLE} + FOR EACH ROW + WHEN {changed} + BEGIN + UPDATE {_CONFIG_TABLE} + SET {_sqlite_update_assignments()} + WHERE id = NEW.id; + END + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SQLITE_CONFIG_ENFORCE_TRIGGER} + AFTER UPDATE OF enforce_sso ON {_CONFIG_TABLE} + FOR EACH ROW + WHEN NEW.enforce_sso IS NOT OLD.enforce_sso + BEGIN + UPDATE {_SETTINGS_TABLE} SET enforce_sso = NEW.enforce_sso WHERE id = 1; + UPDATE {_CONFIG_TABLE} + SET enforce_sso = NEW.enforce_sso + WHERE enforce_sso IS NOT NEW.enforce_sso; + END + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SQLITE_SETTINGS_ENFORCE_TRIGGER} + AFTER UPDATE OF enforce_sso ON {_SETTINGS_TABLE} + FOR EACH ROW + WHEN NEW.enforce_sso IS NOT OLD.enforce_sso + BEGIN + UPDATE {_CONFIG_TABLE} + SET enforce_sso = NEW.enforce_sso + WHERE enforce_sso IS NOT NEW.enforce_sso; + END + """ + ) + ) + + +def _drop_sqlite_compatibility_triggers() -> None: + for name in ( + _SQLITE_CONFIG_INSERT_TRIGGER, + _SQLITE_CONFIG_UPDATE_TRIGGER, + _SQLITE_CONFIG_ENFORCE_TRIGGER, + _SQLITE_SETTINGS_ENFORCE_TRIGGER, + ): + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {name}")) + + +def _postgres_provider_settings_json() -> str: + pairs = ["'protocol', COALESCE(NEW.provider, NEW.protocol, 'oidc')"] + pairs.extend(f"'{name}', NEW.{name}" for name in _PROVIDER_SETTING_COLUMNS) + return f"json_build_object({', '.join(pairs)})" + + +def _postgres_legacy_settings_changed() -> str: + return " OR ".join(f"NEW.{name} IS DISTINCT FROM OLD.{name}" for name in ("provider", *_PROVIDER_SETTING_COLUMNS)) + + +def _create_postgres_compatibility_triggers() -> None: + _drop_postgres_compatibility_triggers() + legacy_settings_changed = _postgres_legacy_settings_changed() + settings_json = _postgres_provider_settings_json() + legacy_insert_assignments = "\n".join( + f"NEW.{name} := COALESCE(NEW.{name}, NEW.provider_settings ->> '{name}');" for name in _PROVIDER_SETTING_COLUMNS + ) + legacy_update_assignments = "\n".join( + f"""IF NEW.provider_settings::jsonb IS DISTINCT FROM OLD.provider_settings::jsonb THEN + NEW.{name} := NEW.provider_settings ->> '{name}'; + END IF;""" + for name in _PROVIDER_SETTING_COLUMNS + ) + op.execute( + sa.text( + f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_CONFIG_FUNCTION}() + RETURNS trigger AS $$ + DECLARE + provider_settings_changed boolean := false; + protocol_changed boolean := false; + provider_changed boolean := false; + legacy_settings_changed boolean := false; + BEGIN + IF TG_OP = 'INSERT' THEN + NEW.slug := COALESCE(NEW.slug, 'sso-' || replace(NEW.id::text, '-', '')); + NEW.display_name := COALESCE(NEW.display_name, NEW.provider_name); + NEW.provider_name := COALESCE(NEW.provider_name, NEW.display_name); + NEW.protocol := COALESCE( + NEW.protocol, + NEW.provider, + NEW.provider_settings ->> 'protocol', + 'oidc' + ); + NEW.provider := COALESCE( + NEW.provider, + NEW.protocol, + NEW.provider_settings ->> 'protocol', + 'oidc' + ); + NEW.provider_settings := COALESCE(NEW.provider_settings, {settings_json}); + {legacy_insert_assignments} + IF NEW.enforce_sso IS NOT TRUE THEN + NEW.enforce_sso := ( + SELECT enforce_sso FROM {_SETTINGS_TABLE} WHERE id = 1 + ); + END IF; + RETURN NEW; + END IF; + + -- Capture the writer's source representation before mutating + -- NEW. Otherwise a legacy provider + scalar update makes our + -- own json mutation look like an N-side write and loses the + -- changed legacy scalar. + provider_settings_changed := + NEW.provider_settings::jsonb IS DISTINCT FROM OLD.provider_settings::jsonb; + protocol_changed := NEW.protocol IS DISTINCT FROM OLD.protocol; + provider_changed := NEW.provider IS DISTINCT FROM OLD.provider; + legacy_settings_changed := {legacy_settings_changed}; + + IF NEW.provider_name IS DISTINCT FROM OLD.provider_name + AND NEW.display_name IS NOT DISTINCT FROM OLD.display_name THEN + NEW.display_name := NEW.provider_name; + END IF; + + IF protocol_changed THEN + NEW.provider := NEW.protocol; + NEW.provider_settings := jsonb_set( + COALESCE(NEW.provider_settings::jsonb, '{{}}'::jsonb), + '{{protocol}}', + to_jsonb(NEW.protocol) + )::json; + ELSIF provider_changed THEN + NEW.protocol := NEW.provider; + NEW.provider_settings := jsonb_set( + COALESCE(NEW.provider_settings::jsonb, '{{}}'::jsonb), + '{{protocol}}', + to_jsonb(NEW.provider) + )::json; + ELSIF provider_settings_changed THEN + NEW.protocol := NEW.provider_settings ->> 'protocol'; + NEW.provider := NEW.protocol; + END IF; + + IF provider_settings_changed OR protocol_changed THEN + {legacy_update_assignments} + ELSIF legacy_settings_changed THEN + NEW.provider_settings := {settings_json}; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_POSTGRES_CONFIG_TRIGGER} + BEFORE INSERT OR UPDATE OF + display_name, provider_name, protocol, provider, provider_settings, + {", ".join(_PROVIDER_SETTING_COLUMNS)} + ON {_CONFIG_TABLE} + FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_CONFIG_FUNCTION}() + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_CONFIG_ENFORCE_FUNCTION}() + RETURNS trigger AS $$ + BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + IF TG_OP = 'INSERT' AND NEW.enforce_sso IS NOT TRUE THEN + RETURN NULL; + END IF; + UPDATE {_SETTINGS_TABLE} SET enforce_sso = NEW.enforce_sso WHERE id = 1; + UPDATE {_CONFIG_TABLE} + SET enforce_sso = NEW.enforce_sso + WHERE enforce_sso IS DISTINCT FROM NEW.enforce_sso; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_POSTGRES_CONFIG_ENFORCE_TRIGGER} + AFTER INSERT OR UPDATE OF enforce_sso ON {_CONFIG_TABLE} + FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_CONFIG_ENFORCE_FUNCTION}() + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_SETTINGS_ENFORCE_FUNCTION}() + RETURNS trigger AS $$ + BEGIN + IF pg_trigger_depth() > 1 THEN + RETURN NULL; + END IF; + UPDATE {_CONFIG_TABLE} + SET enforce_sso = NEW.enforce_sso + WHERE enforce_sso IS DISTINCT FROM NEW.enforce_sso; + RETURN NULL; + END; + $$ LANGUAGE plpgsql + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_POSTGRES_SETTINGS_ENFORCE_TRIGGER} + AFTER UPDATE OF enforce_sso ON {_SETTINGS_TABLE} + FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_SETTINGS_ENFORCE_FUNCTION}() + """ + ) + ) + + +def _drop_postgres_compatibility_triggers() -> None: + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_POSTGRES_CONFIG_TRIGGER} ON {_CONFIG_TABLE}")) + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_POSTGRES_CONFIG_ENFORCE_TRIGGER} ON {_CONFIG_TABLE}")) + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_POSTGRES_SETTINGS_ENFORCE_TRIGGER} ON {_SETTINGS_TABLE}")) + op.execute(sa.text(f"DROP FUNCTION IF EXISTS {_POSTGRES_CONFIG_FUNCTION}()")) + op.execute(sa.text(f"DROP FUNCTION IF EXISTS {_POSTGRES_CONFIG_ENFORCE_FUNCTION}()")) + op.execute(sa.text(f"DROP FUNCTION IF EXISTS {_POSTGRES_SETTINGS_ENFORCE_FUNCTION}()")) + + +def _create_compatibility_triggers(conn: sa.Connection) -> None: + if not _has_compatibility_schema(conn): + return + if conn.dialect.name == "sqlite": + _create_sqlite_compatibility_triggers() + elif conn.dialect.name == "postgresql": + _create_postgres_compatibility_triggers() + + +def _drop_compatibility_triggers(conn: sa.Connection) -> None: + if not migration.table_exists(_CONFIG_TABLE, conn) or not migration.table_exists(_SETTINGS_TABLE, conn): + return + if conn.dialect.name == "sqlite": + _drop_sqlite_compatibility_triggers() + elif conn.dialect.name == "postgresql": + _drop_postgres_compatibility_triggers() + + +def _convert_timestamps(conn: sa.Connection, *, timezone_aware: bool) -> None: + if conn.dialect.name != "postgresql" or not migration.table_exists(_CONFIG_TABLE, conn): + return + columns = _column_names(conn, _CONFIG_TABLE) + for name in _TIMESTAMP_COLUMNS: + if name not in columns: + continue + if timezone_aware: + op.execute( + sa.text( + f"ALTER TABLE {_CONFIG_TABLE} " + f"ALTER COLUMN {name} TYPE TIMESTAMP WITH TIME ZONE " + f"USING {name} AT TIME ZONE 'UTC'" + ) + ) + else: + op.execute( + sa.text( + f"ALTER TABLE {_CONFIG_TABLE} " + f"ALTER COLUMN {name} TYPE TIMESTAMP WITHOUT TIME ZONE " + f"USING {name} AT TIME ZONE 'UTC'" + ) + ) + + +def upgrade() -> None: + conn = op.get_bind() + _convert_timestamps(conn, timezone_aware=True) + _create_compatibility_triggers(conn) + + +def downgrade() -> None: + conn = op.get_bind() + _drop_compatibility_triggers(conn) + _convert_timestamps(conn, timezone_aware=False) diff --git a/src/backend/base/langflow/alembic/versions/b1c2d3e4f5a6_add_sso_plugin_tables_sso_user_profile_.py b/src/backend/base/langflow/alembic/versions/b1c2d3e4f5a6_add_sso_plugin_tables_sso_user_profile_.py index 42a5f5f87886..edce7cf8c892 100644 --- a/src/backend/base/langflow/alembic/versions/b1c2d3e4f5a6_add_sso_plugin_tables_sso_user_profile_.py +++ b/src/backend/base/langflow/alembic/versions/b1c2d3e4f5a6_add_sso_plugin_tables_sso_user_profile_.py @@ -43,8 +43,8 @@ def upgrade() -> None: sa.Column("authorization_endpoint", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("jwks_uri", sqlmodel.sql.sqltypes.AutoString(), nullable=True), sa.Column("issuer", sqlmodel.sql.sqltypes.AutoString(), nullable=True), - sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), - sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("created_by", sa.Uuid(), nullable=True), sa.ForeignKeyConstraint(["created_by"], ["user.id"], ondelete="SET NULL"), sa.PrimaryKeyConstraint("id"), diff --git a/src/backend/base/langflow/alembic/versions/e9f2a3b4c5d6_allow_multiple_sso_identities_per_user.py b/src/backend/base/langflow/alembic/versions/e9f2a3b4c5d6_allow_multiple_sso_identities_per_user.py index 8cea16525a44..08b9f2ee43aa 100644 --- a/src/backend/base/langflow/alembic/versions/e9f2a3b4c5d6_allow_multiple_sso_identities_per_user.py +++ b/src/backend/base/langflow/alembic/versions/e9f2a3b4c5d6_allow_multiple_sso_identities_per_user.py @@ -7,7 +7,6 @@ Phase: EXPAND """ -import os from collections.abc import Sequence from uuid import UUID @@ -45,25 +44,19 @@ # application models, whose shape changes independently of this revision. _ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" _ENVELOPE_PART_COUNT = 6 - - -def _external_auth_provider() -> str | None: - """Return the provider key owned by OSS EXTERNAL_AUTH, if configured. - - ``sso_user_profile.sso_provider`` has two independent writers: the SSO plugin - (which this revision re-keys onto ``sso_config.slug``) and the OSS - EXTERNAL_AUTH flow in ``services/auth/service.py``, which writes - ``EXTERNAL_AUTH_PROVIDER`` verbatim. Those rows belong to a different feature - and must never be re-keyed here — rewriting one silently breaks that user's - login and JIT-provisions a duplicate account on their next sign-in. - """ - return os.environ.get("LANGFLOW_EXTERNAL_AUTH_PROVIDER", "").strip() or None +_ENVELOPE_NONCE_LENGTH = 16 +_ENVELOPE_MIN_CIPHERTEXT_LENGTH = 22 def _is_secret_envelope(value: object) -> bool: """Return whether a stored secret is already a versioned ciphertext envelope.""" + if not isinstance(value, str) or not value.startswith(_ENVELOPE_HEADER): + return False + parts = value.split(":") return ( - isinstance(value, str) and value.startswith(_ENVELOPE_HEADER) and len(value.split(":")) == _ENVELOPE_PART_COUNT + len(parts) == _ENVELOPE_PART_COUNT + and len(parts[-2]) == _ENVELOPE_NONCE_LENGTH + and len(parts[-1]) >= _ENVELOPE_MIN_CIPHERTEXT_LENGTH ) @@ -103,75 +96,6 @@ def _backfill_connection_identity(conn: sa.Connection) -> None: conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) -def _backfill_profile_connection_slugs(conn: sa.Connection) -> None: - if not migration.table_exists(_PROFILE_TABLE, conn): - return - config_columns = _column_names(conn, _CONFIG_TABLE) - profile_columns = _column_names(conn, _PROFILE_TABLE) - if not {"id", "slug", "provider_name"} <= config_columns or "sso_provider" not in profile_columns: - return - - config = sa.table( - _CONFIG_TABLE, - sa.column("id"), - sa.column("slug"), - sa.column("provider_name"), - ) - profile = sa.table(_PROFILE_TABLE, sa.column("sso_provider")) - external_provider = _external_auth_provider() - rows = conn.execute(sa.select(config.c.slug, config.c.provider_name).order_by(config.c.id)).all() - # Build provider_name -> [slug, ...] for rows that will re-key profiles. Duplicate - # names would otherwise rewrite the same profiles onto whichever config still - # matches the legacy name first, silently mis-binding the rest. - slugs_by_provider_name: dict[str, list[str]] = {} - for row in rows: - if not (row.slug and row.provider_name): - continue - # Leave EXTERNAL_AUTH-owned identities alone; see _external_auth_provider. - if external_provider is not None and row.provider_name == external_provider: - continue - slugs_by_provider_name.setdefault(row.provider_name, []).append(row.slug) - duplicates = {name: slugs for name, slugs in slugs_by_provider_name.items() if len(slugs) > 1} - if duplicates: - details = "; ".join(f"{name!r} maps to slugs {slugs}" for name, slugs in sorted(duplicates.items())) - msg = ( - "sso_config contains duplicate provider_name values that prevent unambiguous " - f"profile re-binding to slugs: {details}. Resolve the duplicates before " - "rerunning this migration." - ) - raise RuntimeError(msg) - for provider_name, slugs in slugs_by_provider_name.items(): - conn.execute(profile.update().where(profile.c.sso_provider == provider_name).values(sso_provider=slugs[0])) - - -def _restore_profile_connection_names(conn: sa.Connection) -> None: - """Reverse of :func:`_backfill_profile_connection_slugs`. - - No EXTERNAL_AUTH guard is needed here: that flow never writes a slug, so a - config skipped on upgrade simply matches no rows on the way back down. - """ - if not migration.table_exists(_PROFILE_TABLE, conn): - return - config_columns = _column_names(conn, _CONFIG_TABLE) - profile_columns = _column_names(conn, _PROFILE_TABLE) - if not {"id", "slug", "provider_name"} <= config_columns or "sso_provider" not in profile_columns: - return - - config = sa.table( - _CONFIG_TABLE, - sa.column("id"), - sa.column("slug"), - sa.column("provider_name"), - ) - profile = sa.table(_PROFILE_TABLE, sa.column("sso_provider")) - rows = conn.execute(sa.select(config.c.slug, config.c.provider_name).order_by(config.c.id)).all() - for row in rows: - if row.slug and row.provider_name: - conn.execute( - profile.update().where(profile.c.sso_provider == row.slug).values(sso_provider=row.provider_name) - ) - - def _sanitize_legacy_client_secrets(conn: sa.Connection) -> None: """Clear pre-encryption plaintext client secrets and disable those connections. @@ -302,6 +226,15 @@ def _create_and_backfill_sso_settings(conn: sa.Connection) -> None: if conn.scalar(sa.select(sa.func.count()).select_from(settings).where(settings.c.id == 1)) == 0: conn.execute(settings.insert().values(id=1, enforce_sso=enforce_sso)) + # Keep the N-1 per-config field coherent with the new singleton while both + # schemas are live. Head installs bidirectional compatibility triggers for + # writes; this backfill establishes a single value for existing rows. + columns = _column_names(conn, _CONFIG_TABLE) + if "enforce_sso" in columns: + config = sa.table(_CONFIG_TABLE, sa.column("enforce_sso", sa.Boolean())) + stored_value = conn.scalar(sa.select(settings.c.enforce_sso).where(settings.c.id == 1)) + conn.execute(config.update().values(enforce_sso=bool(stored_value))) + def _upgrade_instance_fields(conn: sa.Connection) -> None: _create_and_backfill_sso_settings(conn) @@ -327,7 +260,19 @@ def _upgrade_instance_fields(conn: sa.Connection) -> None: None, ) with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: - batch_op.alter_column("sort_order", existing_type=sa.Integer(), nullable=False) + batch_op.alter_column( + "sort_order", + existing_type=sa.Integer(), + nullable=False, + server_default=sa.text("0"), + ) + if "enforce_sso" in columns: + batch_op.alter_column( + "enforce_sso", + existing_type=sa.Boolean(), + nullable=False, + server_default=sa.false(), + ) if updated_by_foreign_key is None: batch_op.create_foreign_key( _UPDATED_BY_FK, @@ -336,8 +281,6 @@ def _upgrade_instance_fields(conn: sa.Connection) -> None: ["id"], ondelete="SET NULL", ) - if "enforce_sso" in columns: - batch_op.drop_column("enforce_sso") def _downgrade_instance_fields(conn: sa.Connection) -> None: @@ -357,7 +300,7 @@ def _downgrade_instance_fields(conn: sa.Connection) -> None: enforce_sso = bool(stored_value) config = sa.table(_CONFIG_TABLE, sa.column("enforce_sso", sa.Boolean())) - conn.execute(config.update().where(config.c.enforce_sso.is_(None)).values(enforce_sso=enforce_sso)) + conn.execute(config.update().values(enforce_sso=enforce_sso)) columns = _column_names(conn, _CONFIG_TABLE) updated_by_foreign_key = next( @@ -365,7 +308,12 @@ def _downgrade_instance_fields(conn: sa.Connection) -> None: None, ) with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: - batch_op.alter_column("enforce_sso", existing_type=sa.Boolean(), nullable=False) + batch_op.alter_column( + "enforce_sso", + existing_type=sa.Boolean(), + nullable=False, + server_default=None, + ) if updated_by_foreign_key is not None and updated_by_foreign_key["name"]: batch_op.drop_constraint(updated_by_foreign_key["name"], type_="foreignkey") if "updated_by" in columns: @@ -398,37 +346,25 @@ def _upgrade_sso_config(conn: sa.Connection) -> None: op.add_column(_CONFIG_TABLE, sa.Column("provider_settings", sa.JSON(), nullable=True)) _backfill_connection_identity(conn) - _backfill_profile_connection_slugs(conn) _backfill_provider_settings(conn) _sanitize_legacy_client_secrets(conn) columns = _column_names(conn, _CONFIG_TABLE) indexes = _indexes(conn, _CONFIG_TABLE) with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: - batch_op.alter_column( - "slug", - existing_type=sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - ) - batch_op.alter_column( - "display_name", - existing_type=sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - ) - batch_op.alter_column( - "protocol", - existing_type=sqlmodel.sql.sqltypes.AutoString(), - nullable=False, - ) - batch_op.alter_column( - "provider_settings", - existing_type=sa.JSON(), - nullable=False, - ) + # Both generations of columns remain nullable during EXPAND so N-1 + # services can insert rows without knowing the new fields and N can + # insert rows without knowing the legacy fields. A head migration adds + # bidirectional compatibility triggers; a later CONTRACT revision may + # enforce NOT NULL and remove the legacy columns after N-1 retirement. + for name in ("provider", "provider_name"): + if name in columns: + batch_op.alter_column( + name, + existing_type=sqlmodel.sql.sqltypes.AutoString(), + nullable=True, + ) if _CONFIG_SLUG_INDEX not in indexes: batch_op.create_index(_CONFIG_SLUG_INDEX, ["slug"], unique=True) - for name in ("provider", "provider_name", *_PROVIDER_SETTING_COLUMNS): - if name in columns: - batch_op.drop_column(name) def _downgrade_sso_config(conn: sa.Connection) -> None: @@ -442,7 +378,6 @@ def _downgrade_sso_config(conn: sa.Connection) -> None: _backfill_legacy_provider_columns(conn) _backfill_provider_name(conn) - _restore_profile_connection_names(conn) columns = _column_names(conn, _CONFIG_TABLE) indexes = _indexes(conn, _CONFIG_TABLE) with op.batch_alter_table(_CONFIG_TABLE, schema=None) as batch_op: @@ -486,6 +421,32 @@ def _raise_for_multiple_identities_per_user(conn: sa.Connection) -> None: raise RuntimeError(msg) +def _raise_for_slug_profile_keys_on_downgrade(conn: sa.Connection) -> None: + """Abort before CONTRACT data would be stranded by dropping config slugs.""" + if not migration.table_exists(_CONFIG_TABLE, conn) or not migration.table_exists(_PROFILE_TABLE, conn): + return + if "slug" not in _column_names(conn, _CONFIG_TABLE) or "sso_provider" not in _column_names(conn, _PROFILE_TABLE): + return + + config = sa.table(_CONFIG_TABLE, sa.column("slug")) + profile = sa.table(_PROFILE_TABLE, sa.column("id"), sa.column("sso_provider")) + slugs = {slug for slug in conn.execute(sa.select(config.c.slug)).scalars() if slug} + if not slugs: + return + affected_ids = [ + str(row.id) + for row in conn.execute(sa.select(profile.c.id, profile.c.sso_provider)) + if row.sso_provider in slugs + ] + if affected_ids: + msg = ( + "Cannot downgrade SSO EXPAND while sso_user_profile rows use connection slugs " + f"({', '.join(affected_ids)}). Verify each identity and explicitly restore its legacy " + "provider_name key before retrying; the migration will not guess or rewrite identity data." + ) + raise RuntimeError(msg) + + def upgrade() -> None: conn = op.get_bind() if migration.table_exists(_PROFILE_TABLE, conn): @@ -508,6 +469,10 @@ def upgrade() -> None: def downgrade() -> None: conn = op.get_bind() + # Run every data-loss preflight before any SQLite DDL: SQLite migrations are + # non-transactional, so discovering this after dropping instance columns + # would leave a partially downgraded schema. + _raise_for_slug_profile_keys_on_downgrade(conn) if migration.table_exists(_CONFIG_TABLE, conn): _downgrade_instance_fields(conn) _downgrade_sso_config(conn) diff --git a/src/backend/base/langflow/services/database/models/auth/sso.py b/src/backend/base/langflow/services/database/models/auth/sso.py index ab827b068427..92deb4751b9c 100644 --- a/src/backend/base/langflow/services/database/models/auth/sso.py +++ b/src/backend/base/langflow/services/database/models/auth/sso.py @@ -15,7 +15,7 @@ from uuid import uuid4 import sqlalchemy as sa -from pydantic import BaseModel, ConfigDict, SecretStr, TypeAdapter, field_validator, model_validator +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, SecretStr, TypeAdapter, field_validator, model_validator from pydantic import Field as PydanticField from sqlalchemy import CheckConstraint, Column, DateTime, ForeignKey, Index from sqlalchemy.orm import validates @@ -27,6 +27,12 @@ from langflow.services.database.models.auth.sso_secret import is_sso_client_secret_envelope _SSO_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_INVALID_PERCENT_ESCAPE_PATTERN = re.compile(r"%(?![0-9A-Fa-f]{2})") +_HTTP_URL_ADAPTER = TypeAdapter(AnyHttpUrl) +_SSO_SECRET_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" # noqa: S105 # pragma: allowlist secret +_SSO_SECRET_NONCE_LENGTH = 16 +_SSO_SECRET_MIN_CIPHERTEXT_LENGTH = 22 +_VALIDATED_UPDATE_FLAG = "_sso_validated_update_in_progress" _OIDC_REMOTE_URL_FIELDS = ( "discovery_url", "token_endpoint", @@ -81,9 +87,24 @@ def validate_remote_url(cls, value: object, info: Any) -> object: if not value: msg = f"OIDC {info.field_name} must not be blank" raise ValueError(msg) + is_http_url = False try: parsed = urlsplit(value) - is_http_url = parsed.scheme.lower() in {"http", "https"} and parsed.hostname is not None + # urlsplit alone accepts malformed hosts and ports. Pydantic's URL + # parser closes those gaps, while the pre-checks preserve the + # original URL structure and reject invalid percent escapes rather + # than normalizing them into a different URL. + is_http_url = ( + parsed.scheme.lower() in {"http", "https"} + and bool(parsed.netloc) + and parsed.hostname is not None + and not any(character.isspace() for character in value) + and _INVALID_PERCENT_ESCAPE_PATTERN.search(value) is None + and "%" not in parsed.hostname + ) + if is_http_url: + _ = parsed.port + _HTTP_URL_ADAPTER.validate_python(value) except ValueError: is_http_url = False if not is_http_url: @@ -92,9 +113,33 @@ def validate_remote_url(cls, value: object, info: Any) -> object: return value +class LegacyProviderSettings(BaseModel): + """Read-compatible settings for disabled legacy SAML and LDAP rows. + + Langflow does not currently execute these protocols through this typed + contract. Keeping their migrated values loadable avoids making an upgrade + destructive; enabled legacy rows still fail closed below. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + protocol: Literal["saml", "ldap"] + discovery_url: str | None = None + redirect_uri: str | None = None + scopes: str | None = None + token_endpoint: str | None = None + authorization_endpoint: str | None = None + jwks_uri: str | None = None + issuer: str | None = None + client_id: str | None = None + + # Add future protocol variants to this discriminated union. The database schema # remains unchanged because every variant is stored in the same JSON column. -SSOProviderSettings: TypeAlias = Annotated[OIDCProviderSettings, PydanticField(discriminator="protocol")] +SSOProviderSettings: TypeAlias = Annotated[ + OIDCProviderSettings | LegacyProviderSettings, + PydanticField(discriminator="protocol"), +] _PROVIDER_SETTINGS_ADAPTER = TypeAdapter(SSOProviderSettings) @@ -115,6 +160,10 @@ def _validate_enabled_config( if not enabled: return + if provider_settings.protocol != "oidc": + msg = "Only OIDC configurations can be enabled" + raise ValueError(msg) + if not provider_settings.client_id: msg = "Enabled OIDC configurations require a client_id" raise ValueError(msg) @@ -179,8 +228,10 @@ def process_bind_param(self, value: str | None, _dialect: sa.engine.Dialect) -> class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg] """SSO profile per user. - ``sso_provider`` stores the immutable ``SSOConfig.slug`` as a documented-soft - reference; no database foreign key is intentionally enforced. + During the expand phase, ``sso_provider`` can contain a legacy provider name, + an OSS ``EXTERNAL_AUTH_PROVIDER`` key, or the immutable ``SSOConfig.slug``. + SSO plugins must dual-read names and slugs until a later contract migration; + no database foreign key is intentionally enforced. """ __tablename__ = "sso_user_profile" @@ -199,7 +250,7 @@ class SSOUserProfile(SQLModel, table=True): # type: ignore[call-arg] index=True, ) ) - sso_provider: str = Field(description="Immutable SSOConfig.slug connection identifier") + sso_provider: str = Field(description="SSO connection slug or expand-phase legacy provider key") sso_user_id: str = Field() email: str | None = Field(default=None, index=True) sso_last_login_at: datetime | None = Field(default=None) @@ -325,7 +376,7 @@ def validate_slug_assignment(self, _key: str, value: str) -> str: def validate_protocol(self, _key: str, value: str) -> str: """Keep protocol aligned with provider_settings on attribute assignment.""" # Use __dict__ so we do not assume the counterpart is loaded yet (e.g. DB hydrate). - if "provider_settings" in self.__dict__: + if not self.__dict__.get(_VALIDATED_UPDATE_FLAG) and "provider_settings" in self.__dict__: _validate_provider_settings(value, self.__dict__["provider_settings"]) return value @@ -336,7 +387,7 @@ def validate_provider_settings( value: SSOProviderSettings | dict[str, Any], ) -> SSOProviderSettings: """Keep provider_settings aligned with protocol on attribute assignment.""" - if "protocol" in self.__dict__: + if not self.__dict__.get(_VALIDATED_UPDATE_FLAG) and "protocol" in self.__dict__: return _validate_provider_settings(self.__dict__["protocol"], value) return _PROVIDER_SETTINGS_ADAPTER.validate_python(value) @@ -348,6 +399,18 @@ def validate_client_secret_envelope(self, _key: str, value: str | None) -> str | raise ValueError(msg) return value + @validates("enabled") + def validate_enabled_assignment(self, _key: str, value: bool) -> bool: # noqa: FBT001 + """Fail closed when an existing configuration is enabled by assignment.""" + provider_settings = self.__dict__.get("provider_settings") + if value and provider_settings is not None and not self.__dict__.get(_VALIDATED_UPDATE_FLAG): + _validate_enabled_config( + provider_settings, + enabled=True, + has_client_secret=self.__dict__.get("client_secret_encrypted") is not None, + ) + return value + def _nonblank_json_string(json_column: sa.Column[Any], key: str) -> sa.ColumnElement[bool]: value = json_column[key].as_string() @@ -357,7 +420,36 @@ def _nonblank_json_string(json_column: sa.Column[Any], key: str) -> sa.ColumnEle def _http_json_url_or_null(json_column: sa.Column[Any], key: str) -> sa.ColumnElement[bool]: value = json_column[key].as_string() normalized = sa.func.lower(value) - return sa.or_(value.is_(None), normalized.like("http://%"), normalized.like("https://%")) + no_whitespace = sa.and_( + *(sa.func.length(value) == sa.func.length(sa.func.replace(value, character, "")) for character in " \t\r\n") + ) + http_url = sa.and_( + normalized.like("http://%"), + sa.func.length(value) > len("http://"), + sa.func.substr(value, len("http://") + 1, 1).not_in(("/", "\\", "?", "#", ":")), + no_whitespace, + ) + https_url = sa.and_( + normalized.like("https://%"), + sa.func.length(value) > len("https://"), + sa.func.substr(value, len("https://") + 1, 1).not_in(("/", "\\", "?", "#", ":")), + no_whitespace, + ) + return sa.or_(value.is_(None), http_url, https_url) + + +def _client_secret_envelope_or_null(value: sa.Column[Any]) -> sa.ColumnElement[bool]: + string_value = sa.type_coerce(value, sa.String()) + separator_position = len(_SSO_SECRET_ENVELOPE_HEADER) + _SSO_SECRET_NONCE_LENGTH + 1 + minimum_length = separator_position + _SSO_SECRET_MIN_CIPHERTEXT_LENGTH + return sa.or_( + value.is_(None), + sa.and_( + sa.func.substr(string_value, 1, len(_SSO_SECRET_ENVELOPE_HEADER)) == _SSO_SECRET_ENVELOPE_HEADER, + sa.func.substr(string_value, separator_position, 1) == ":", + sa.func.length(string_value) >= minimum_length, + ), + ) def _install_sso_config_database_invariants() -> None: @@ -372,7 +464,7 @@ def _install_sso_config_database_invariants() -> None: table.append_constraint( CheckConstraint( sa.and_( - protocol == "oidc", + protocol.in_(("oidc", "saml", "ldap")), provider_settings["protocol"].as_string().is_not(None), provider_settings["protocol"].as_string() == protocol, ), @@ -383,7 +475,9 @@ def _install_sso_config_database_invariants() -> None: CheckConstraint( sa.or_( enabled.is_(False), + protocol.in_(("saml", "ldap")), sa.and_( + protocol == "oidc", table.c.client_secret_encrypted.is_not(None), _nonblank_json_string(provider_settings, "client_id"), sa.or_( @@ -400,13 +494,19 @@ def _install_sso_config_database_invariants() -> None: name=conv("ck_sso_config_enabled_complete"), ) ) + table.append_constraint( + CheckConstraint( + _client_secret_envelope_or_null(table.c.client_secret_encrypted), + name=conv("ck_sso_config_client_secret_envelope"), + ) + ) sqlite_trigger = sa.DDL( """ CREATE TRIGGER trg_sso_config_slug_immutable BEFORE UPDATE OF slug ON sso_config FOR EACH ROW - WHEN NEW.slug IS NOT OLD.slug + WHEN OLD.slug IS NOT NULL AND NEW.slug IS NOT OLD.slug BEGIN SELECT RAISE(ABORT, 'SSOConfig.slug is immutable after insert'); END @@ -417,7 +517,7 @@ def _install_sso_config_database_invariants() -> None: CREATE OR REPLACE FUNCTION prevent_sso_config_slug_update() RETURNS trigger AS $$ BEGIN - IF NEW.slug IS DISTINCT FROM OLD.slug THEN + IF OLD.slug IS NOT NULL AND NEW.slug IS DISTINCT FROM OLD.slug THEN RAISE EXCEPTION 'SSOConfig.slug is immutable after insert'; END IF; RETURN NEW; @@ -589,10 +689,29 @@ def apply_to( has_client_secret=encrypted_secret is not None, ) - for field_name, value in values.items(): - setattr(config, field_name, value) - if "client_secret" in self.model_fields_set: - config.client_secret_encrypted = encrypted_secret + # Apply dependencies before ``enabled`` so assignment-time validation + # observes the already-validated merged state instead of the old, + # potentially incomplete configuration. + enabled_was_set = "enabled" in values + enabled_value = values.pop("enabled") if enabled_was_set else config.enabled + config.__dict__[_VALIDATED_UPDATE_FLAG] = True + try: + for field_name, value in values.items(): + setattr(config, field_name, value) + if "client_secret" in self.model_fields_set: + config.client_secret_encrypted = encrypted_secret + if enabled_was_set: + config.enabled = enabled_value + finally: + config.__dict__.pop(_VALIDATED_UPDATE_FLAG, None) + + # Assert the persisted object matches the state validated above; the + # before-update hook repeats this check at flush time as defense in depth. + _validate_enabled_config( + _validate_provider_settings(config.protocol, config.provider_settings), + enabled=config.enabled, + has_client_secret=config.client_secret_encrypted is not None, + ) config.updated_by = actor_id return config diff --git a/src/backend/tests/unit/alembic/test_migration_execution.py b/src/backend/tests/unit/alembic/test_migration_execution.py index 4d18d9f3fbd7..37f993d59b58 100644 --- a/src/backend/tests/unit/alembic/test_migration_execution.py +++ b/src/backend/tests/unit/alembic/test_migration_execution.py @@ -17,6 +17,29 @@ _WORKSPACE_ROOT = Path(__file__).resolve().parents[5] _SCRIPT_LOCATION = _WORKSPACE_ROOT / "src/backend/base/langflow/alembic" +# ``sso_config`` is deliberately in an EXPAND window: released N-1 services +# still need the scalar columns, while N reads the typed JSON representation. +# Alembic therefore sees the retained DB-only columns and nullable typed columns +# as a future CONTRACT migration. Keep this list exact and remove it with that +# contract revision; the rolling-compatibility migration tests assert that the +# temporary physical schema remains present and synchronized. +_SSO_EXPAND_LEGACY_COLUMNS = frozenset( + { + "provider", + "provider_name", + "enforce_sso", + "client_id", + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + } +) +_SSO_EXPAND_NULLABLE_COLUMNS = frozenset({"slug", "display_name", "protocol", "provider_settings"}) + def _make_alembic_cfg(db_url: str) -> Config: """Create an Alembic Config pointing at the project's migration scripts.""" @@ -264,6 +287,41 @@ def _filter_sqlite_noise(diffs: list) -> list: return significant_diffs +def _filter_sso_expand_contract_diffs(diffs: list) -> list: + """Suppress only the schema diffs intentionally deferred to SSO CONTRACT.""" + significant_diffs = [] + for diff in diffs: + # Alembic can group multiple alter-column operations in a nested list. + if isinstance(diff, list): + filtered_group = _filter_sso_expand_contract_diffs(diff) + if filtered_group: + significant_diffs.append(filtered_group) + continue + if not isinstance(diff, tuple): + significant_diffs.append(diff) + continue + + if ( + len(diff) >= 4 + and diff[0] == "remove_column" + and diff[2] == "sso_config" + and getattr(diff[3], "name", None) in _SSO_EXPAND_LEGACY_COLUMNS + ): + continue + if ( + len(diff) >= 7 + and diff[0] == "modify_nullable" + and diff[2] == "sso_config" + and diff[3] in _SSO_EXPAND_NULLABLE_COLUMNS + and diff[5] is True + and diff[6] is False + ): + continue + significant_diffs.append(diff) + + return significant_diffs + + class _FakeColumn: """Minimal stand-in for sqlalchemy Column used by FK constraint diffs.""" @@ -334,6 +392,46 @@ def test_non_fk_diffs_preserved(self): assert result == diffs +class TestFilterSsoExpandContractDiffs: + """Keep the temporary SSO autogenerate exception narrow and directional.""" + + def test_exact_legacy_remove_and_nullable_diffs_are_suppressed(self): + legacy_column = _FakeColumn("provider", "sso_config") + nullable_diff = ( + "modify_nullable", + None, + "sso_config", + "provider_settings", + {"existing_type": "JSON"}, + True, + False, + ) + + assert ( + _filter_sso_expand_contract_diffs([("remove_column", None, "sso_config", legacy_column), [nullable_diff]]) + == [] + ) + + def test_other_tables_columns_and_nullable_directions_are_preserved(self): + unrelated_column = _FakeColumn("provider", "another_table") + inverse_nullable_diff = ( + "modify_nullable", + None, + "sso_config", + "provider_settings", + {"existing_type": "JSON"}, + False, + True, + ) + diffs = [ + ("remove_column", None, "another_table", unrelated_column), + inverse_nullable_diff, + ("modify_type", None, "sso_config", "provider_settings"), + ] + + assert _filter_sso_expand_contract_diffs(diffs) == diffs + + def _engine_url(db_url: str) -> str: """Convert an async DB URL to a sync one for SQLAlchemy create_engine.""" if db_url.startswith("sqlite+aiosqlite"): @@ -342,8 +440,8 @@ def _engine_url(db_url: str) -> str: def _filter_diffs(diffs: list, db_url: str) -> list: - """Apply only SQLite-specific diff filtering.""" - filtered_diffs = list(diffs) + """Apply documented compatibility and SQLite-specific diff filtering.""" + filtered_diffs = _filter_sso_expand_contract_diffs(diffs) if "sqlite" in db_url: filtered_diffs = _filter_sqlite_noise(filtered_diffs) return filtered_diffs diff --git a/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py b/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py index 9a98f1ff9495..56452051c429 100644 --- a/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py +++ b/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py @@ -124,8 +124,7 @@ def test_sso_instance_fields_upgrade_and_downgrade_preserve_seeded_rows(db_url): sso_settings = sa.Table("sso_settings", metadata, autoload_with=connection) sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) - assert {"sort_order", "updated_by"} <= config_columns - assert "enforce_sso" not in config_columns + assert {"sort_order", "updated_by", "enforce_sso"} <= config_columns assert any( foreign_key["constrained_columns"] == ["updated_by"] and foreign_key["referred_table"] == "user" diff --git a/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py index 966d91104b00..2aacc3c3443f 100644 --- a/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py +++ b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py @@ -114,9 +114,13 @@ def test_sso_provider_settings_upgrade_and_downgrade_preserve_seeded_rows(db_url config_row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() assert {"protocol", "provider_settings", "client_secret_encrypted"} <= columns - assert {"provider", *_PROVIDER_SETTING_COLUMNS}.isdisjoint(columns) + assert {"provider", *_PROVIDER_SETTING_COLUMNS} <= columns assert config_row["protocol"] == "oidc" + assert config_row["provider"] == "oidc" assert config_row["provider_settings"] == expected_settings + assert {name: config_row[name] for name in _PROVIDER_SETTING_COLUMNS} == { + name: expected_settings[name] for name in _PROVIDER_SETTING_COLUMNS + } assert config_row["client_secret_encrypted"] == _TEST_ENCRYPTED_SECRET assert ( connection.scalar( diff --git a/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py new file mode 100644 index 000000000000..6c9e3bd7c035 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py @@ -0,0 +1,437 @@ +"""Rolling compatibility contract for the SSO expand migrations.""" + +from __future__ import annotations + +import importlib +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import UUID, uuid4 + +import pytest +import sqlalchemy as sa +from alembic import command +from sqlalchemy.exc import IntegrityError + +from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 + +_PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_HEAD_REVISION = "8d9e0f1a2b3c" # pragma: allowlist secret +_TEST_ENCRYPTED_SECRET = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:AAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBB" # noqa: S105 # pragma: allowlist secret +_TEST_PLAINTEXT_SECRET = "plaintext-secret" # noqa: S105 # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret +_HEAD_MIGRATION = importlib.import_module("langflow.alembic.versions.8d9e0f1a2b3c_complete_sso_expand_compatibility") + + +def _legacy_config_values( + *, + config_id: str, + provider: str, + provider_name: str, + timestamp: datetime, + enabled: bool = True, +) -> dict: + return { + "id": config_id, + "provider": provider, + "provider_name": provider_name, + "enabled": enabled, + "enforce_sso": False, + "client_secret_encrypted": None, + "client_id": None, + "discovery_url": None, + "redirect_uri": None, + "scopes": None, + "token_endpoint": None, + "authorization_endpoint": None, + "jwks_uri": None, + "issuer": None, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + } + + +def _oidc_settings(*, discovery_url: str = "https://idp.example.com/.well-known/openid-configuration") -> dict: + return { + "protocol": "oidc", + "discovery_url": discovery_url, + "redirect_uri": "/api/v1/login/callback", + "scopes": "openid email profile", + "token_endpoint": None, + "authorization_endpoint": None, + "jwks_uri": None, + "issuer": "https://idp.example.com", + "client_id": "client-id", + } + + +def test_sso_expand_keeps_n_and_n_minus_one_writes_coherent(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + user_id = str(uuid4()) + profile_id = str(uuid4()) + saml_config_id = str(uuid4()) + ldap_config_id = str(uuid4()) + malformed_oidc_id = str(uuid4()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + user = sa.Table("user", metadata, autoload_with=connection) + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + user.insert(), + { + "id": user_id, + "username": "sso-rolling-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ) + connection.execute( + sso_config.insert(), + [ + { + **_legacy_config_values( + config_id=saml_config_id, + provider="saml", + provider_name="Legacy SAML", + timestamp=timestamp, + ), + "issuer": "https://saml.example.com", + }, + { + **_legacy_config_values( + config_id=ldap_config_id, + provider="ldap", + provider_name="Legacy LDAP", + timestamp=timestamp, + ), + "discovery_url": "https://ldap.example.com", + }, + { + **_legacy_config_values( + config_id=malformed_oidc_id, + provider="oidc", + provider_name="Malformed OIDC", + timestamp=timestamp, + ), + "client_secret_encrypted": _TEST_ENCRYPTED_SECRET, + "client_id": "client-id", + "discovery_url": "http://example.com:bad", + }, + ], + ) + connection.execute( + sso_user_profile.insert(), + { + "id": profile_id, + "user_id": user_id, + "sso_provider": "Legacy SAML", + "sso_user_id": "subject-1", + "email": "user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _HEAD_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + inspector = sa.inspect(connection) + columns = {column["name"]: column for column in inspector.get_columns("sso_config")} + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + sso_settings = sa.Table("sso_settings", metadata, autoload_with=connection) + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + + assert { + "provider", + "provider_name", + "enforce_sso", + "slug", + "display_name", + "protocol", + "provider_settings", + } <= columns.keys() + assert all( + columns[name]["nullable"] + for name in ("provider", "provider_name", "slug", "display_name", "protocol", "provider_settings") + ) + if connection.dialect.name == "postgresql": + assert columns["created_at"]["type"].timezone + assert columns["updated_at"]["type"].timezone + + historical_rows = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id.in_((saml_config_id, ldap_config_id)))) + .mappings() + .all() + ) + assert {row["protocol"] for row in historical_rows} == {"saml", "ldap"} + assert all(row["enabled"] for row in historical_rows) + assert {row["provider_settings"]["protocol"] for row in historical_rows} == {"saml", "ldap"} + assert not connection.scalar(sa.select(sso_config.c.enabled).where(sso_config.c.id == malformed_oidc_id)) + assert ( + connection.scalar(sa.select(sso_user_profile.c.sso_provider).where(sso_user_profile.c.id == profile_id)) + == "Legacy SAML" + ) + + # display_name is mutable presentation data in N, while the + # released provider_name remains the EXPAND identity key used by + # N-1 profiles. + connection.execute( + sso_config.update().where(sso_config.c.id == saml_config_id).values(display_name="Renamed SAML") + ) + renamed_saml = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == saml_config_id)).mappings().one() + ) + assert renamed_saml["provider_name"] == "Legacy SAML" + assert ( + connection.scalar(sa.select(sso_user_profile.c.sso_provider).where(sso_user_profile.c.id == profile_id)) + == "Legacy SAML" + ) + + # N-1 INSERT: released scalar fields populate the N representation. + legacy_oidc_id = str(uuid4()) + legacy_oidc_settings = _oidc_settings() + connection.execute( + sso_config.insert(), + { + **_legacy_config_values( + config_id=legacy_oidc_id, + provider="oidc", + provider_name="Rolling Legacy OIDC", + timestamp=timestamp, + ), + "client_secret_encrypted": _TEST_ENCRYPTED_SECRET, + **{key: value for key, value in legacy_oidc_settings.items() if key != "protocol"}, + }, + ) + legacy_oidc = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == legacy_oidc_id)).mappings().one() + ) + assert legacy_oidc["slug"] == f"sso-{UUID(legacy_oidc_id).hex}" + assert legacy_oidc["display_name"] == "Rolling Legacy OIDC" + assert legacy_oidc["protocol"] == "oidc" + assert legacy_oidc["provider_settings"] == legacy_oidc_settings + + # N INSERT: typed fields populate the released scalar representation + # and DB defaults satisfy columns the new model no longer maps. + typed_oidc_id = str(uuid4()) + typed_settings = _oidc_settings(discovery_url="https://typed.example.com/.well-known/openid-configuration") + connection.execute( + sso_config.insert(), + { + "id": typed_oidc_id, + "slug": "sso-typed-writer", + "display_name": "Typed OIDC", + "protocol": "oidc", + "enabled": True, + "sort_order": 5, + "client_secret_encrypted": _TEST_ENCRYPTED_SECRET, + "provider_settings": typed_settings, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ) + typed_oidc = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == typed_oidc_id)).mappings().one() + ) + assert typed_oidc["provider"] == "oidc" + assert typed_oidc["provider_name"] == "Typed OIDC" + assert typed_oidc["discovery_url"] == typed_settings["discovery_url"] + assert not typed_oidc["enforce_sso"] + + # Regression: a single N-1 update can change the protocol and a + # scalar setting without the trigger overwriting that setting from + # stale JSON. + changed_discovery_url = "https://saml-rolling.example.com/metadata" + connection.execute( + sso_config.update() + .where(sso_config.c.id == legacy_oidc_id) + .values(provider="saml", discovery_url=changed_discovery_url) + ) + updated_legacy = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == legacy_oidc_id)).mappings().one() + ) + assert updated_legacy["protocol"] == "saml" + assert updated_legacy["provider_settings"]["protocol"] == "saml" + assert updated_legacy["provider_settings"]["discovery_url"] == changed_discovery_url + + # N UPDATE keeps the released fields usable by an old process. + changed_typed_settings = {**typed_settings, "issuer": "https://issuer.example.com"} + connection.execute( + sso_config.update() + .where(sso_config.c.id == typed_oidc_id) + .values(display_name="Renamed Typed OIDC", provider_settings=changed_typed_settings) + ) + updated_typed = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == typed_oidc_id)).mappings().one() + ) + assert updated_typed["provider_name"] == "Typed OIDC" + assert updated_typed["issuer"] == "https://issuer.example.com" + + # SQLite evaluates CHECK constraints before its AFTER UPDATE + # compatibility trigger. Supported temporary mismatches must reach + # the trigger, which then applies the same deterministic precedence + # as PostgreSQL's BEFORE trigger. + connection.execute( + sso_config.update().where(sso_config.c.id == typed_oidc_id).values(enabled=False, protocol="saml") + ) + protocol_only_update = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == typed_oidc_id)).mappings().one() + ) + assert protocol_only_update["provider"] == "saml" + assert protocol_only_update["provider_settings"]["protocol"] == "saml" + + ldap_settings = {**changed_typed_settings, "protocol": "ldap"} + connection.execute( + sso_config.update().where(sso_config.c.id == typed_oidc_id).values(provider_settings=ldap_settings) + ) + settings_only_update = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == typed_oidc_id)).mappings().one() + ) + assert settings_only_update["protocol"] == "ldap" + assert settings_only_update["provider"] == "ldap" + + conflicting_settings = { + **changed_typed_settings, + "protocol": "ldap", + "issuer": "https://conflict.example.com", + } + connection.execute( + sso_config.update() + .where(sso_config.c.id == typed_oidc_id) + .values(protocol="saml", provider_settings=conflicting_settings) + ) + conflicting_update = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == typed_oidc_id)).mappings().one() + ) + assert conflicting_update["protocol"] == "saml" + assert conflicting_update["provider"] == "saml" + assert conflicting_update["provider_settings"]["protocol"] == "saml" + assert conflicting_update["provider_settings"]["issuer"] == "https://conflict.example.com" + + # An explicit N-1 key rename still mirrors to N presentation data; + # released plugin code owns any corresponding profile maintenance. + connection.execute( + sso_config.update().where(sso_config.c.id == legacy_oidc_id).values(provider_name="Legacy Renamed") + ) + assert ( + connection.scalar(sa.select(sso_config.c.display_name).where(sso_config.c.id == legacy_oidc_id)) + == "Legacy Renamed" + ) + + # Old and new enforcement writers converge on one instance value. + connection.execute(sso_config.update().where(sso_config.c.id == typed_oidc_id).values(enforce_sso=True)) + assert connection.scalar(sa.select(sso_settings.c.enforce_sso).where(sso_settings.c.id == 1)) + assert all(connection.execute(sa.select(sso_config.c.enforce_sso)).scalars()) + + inserted_while_enforced_id = str(uuid4()) + connection.execute( + sso_config.insert(), + _legacy_config_values( + config_id=inserted_while_enforced_id, + provider="saml", + provider_name="Inserted While Enforced", + timestamp=timestamp, + enabled=True, + ), + ) + assert connection.scalar( + sa.select(sso_config.c.enforce_sso).where(sso_config.c.id == inserted_while_enforced_id) + ) + inserted_saml = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == inserted_while_enforced_id)) + .mappings() + .one() + ) + assert inserted_saml["protocol"] == "saml" + assert inserted_saml["provider_settings"]["protocol"] == "saml" + + connection.execute(sso_settings.update().where(sso_settings.c.id == 1).values(enforce_sso=False)) + assert not any(connection.execute(sa.select(sso_config.c.enforce_sso)).scalars()) + + inserted_ldap_id = str(uuid4()) + connection.execute( + sso_config.insert(), + _legacy_config_values( + config_id=inserted_ldap_id, + provider="ldap", + provider_name="Rolling LDAP", + timestamp=timestamp, + enabled=True, + ), + ) + inserted_ldap = ( + connection.execute(sa.select(sso_config).where(sso_config.c.id == inserted_ldap_id)).mappings().one() + ) + assert inserted_ldap["protocol"] == "ldap" + assert inserted_ldap["provider_settings"]["protocol"] == "ldap" + + # Security boundary: N-1 plaintext secret writes are intentionally not + # compatible. They fail atomically instead of reintroducing plaintext. + plaintext_update = ( + sso_config.update() + .where(sso_config.c.id == typed_oidc_id) + .values(client_secret_encrypted=_TEST_PLAINTEXT_SECRET) + ) + with engine.begin() as connection, pytest.raises(IntegrityError): + connection.execute(plaintext_update) + with engine.connect() as connection: + sso_config = sa.Table("sso_config", sa.MetaData(), autoload_with=connection) + assert ( + connection.scalar( + sa.select(sso_config.c.client_secret_encrypted).where(sso_config.c.id == typed_oidc_id) + ) + == _TEST_ENCRYPTED_SECRET + ) + finally: + engine.dispose() + + command.downgrade(alembic_cfg, "7c8e9f0a1b2d") # pragma: allowlist secret + if db_url.startswith("postgresql"): + engine = sa.create_engine(_engine_url(db_url)) + try: + with engine.connect() as connection: + columns = {column["name"]: column for column in sa.inspect(connection).get_columns("sso_config")} + assert not columns["created_at"]["type"].timezone + assert not columns["updated_at"]["type"].timezone + finally: + engine.dispose() + + +def test_sso_timestamp_conversion_uses_utc_in_both_directions(monkeypatch): + calls: list[str] = [] + fake_conn = SimpleNamespace(dialect=SimpleNamespace(name="postgresql")) + monkeypatch.setattr(_HEAD_MIGRATION.migration, "table_exists", lambda *_args: True) + monkeypatch.setattr(_HEAD_MIGRATION, "_column_names", lambda *_args: {"created_at", "updated_at"}) + monkeypatch.setattr(_HEAD_MIGRATION.op, "execute", lambda statement: calls.append(str(statement))) + + _HEAD_MIGRATION._convert_timestamps(fake_conn, timezone_aware=True) + assert len(calls) == 2 + assert all("TYPE TIMESTAMP WITH TIME ZONE" in statement for statement in calls) + assert all("AT TIME ZONE 'UTC'" in statement for statement in calls) + + calls.clear() + _HEAD_MIGRATION._convert_timestamps(fake_conn, timezone_aware=False) + assert len(calls) == 2 + assert all("TYPE TIMESTAMP WITHOUT TIME ZONE" in statement for statement in calls) + assert all("AT TIME ZONE 'UTC'" in statement for statement in calls) diff --git a/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py b/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py index 2a909669798e..a25f7473aa3e 100644 --- a/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py +++ b/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py @@ -23,8 +23,10 @@ def test_sso_connection_identity_upgrade_and_downgrade_preserve_seeded_rows(db_u timestamp = datetime.now(timezone.utc) user_id = str(uuid4()) + slug_profile_user_id = str(uuid4()) config_id = str(uuid4()) profile_id = str(uuid4()) + slug_profile_id = str(uuid4()) expected_slug = f"sso-{UUID(config_id).hex}" engine = sa.create_engine(_engine_url(db_url)) @@ -36,15 +38,26 @@ def test_sso_connection_identity_upgrade_and_downgrade_preserve_seeded_rows(db_u sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) connection.execute( user.insert(), - { - "id": user_id, - "username": "sso-stable-identity-user", - "password": _TEST_PASSWORD, - "is_active": True, - "is_superuser": False, - "create_at": timestamp, - "updated_at": timestamp, - }, + [ + { + "id": user_id, + "username": "sso-stable-identity-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + { + "id": slug_profile_user_id, + "username": "sso-slug-identity-user", + "password": _TEST_PASSWORD, + "is_active": True, + "is_superuser": False, + "create_at": timestamp, + "updated_at": timestamp, + }, + ], ) connection.execute( sso_config.insert(), @@ -97,23 +110,59 @@ def test_sso_connection_identity_upgrade_and_downgrade_preserve_seeded_rows(db_u ) assert {"slug", "display_name"} <= columns.keys() - assert "provider_name" not in columns - assert not columns["slug"]["nullable"] - assert not columns["display_name"]["nullable"] + assert "provider_name" in columns + assert columns["slug"]["nullable"] + assert columns["display_name"]["nullable"] + assert columns["provider_name"]["nullable"] assert indexes[_SLUG_INDEX]["unique"] assert config_row["slug"] == expected_slug assert config_row["display_name"] == "Primary OIDC" - assert profile_row["sso_provider"] == config_row["slug"] + assert config_row["provider_name"] == "Primary OIDC" + # EXPAND must not re-key this shared field while N-1 services still + # resolve profiles by the legacy provider identifier. + assert profile_row["sso_provider"] == "Primary OIDC" + # N is allowed to create slug-backed identities during EXPAND. connection.execute( - sso_config.update().where(sso_config.c.id == config_id).values(display_name="Renamed OIDC") + sso_user_profile.insert(), + { + "id": slug_profile_id, + "user_id": slug_profile_user_id, + "sso_provider": expected_slug, + "sso_user_id": "subject-slug", + "email": "slug-user@example.com", + "sso_last_login_at": None, + "created_at": timestamp, + "updated_at": timestamp, + }, ) - resolved_config = ( - connection.execute(sa.select(sso_config).where(sso_config.c.slug == profile_row["sso_provider"])) - .mappings() - .one() + finally: + engine.dispose() + + # Downgrade must fail before any non-transactional SQLite DDL instead of + # guessing how to rewrite identity keys and stranding or misbinding users. + with pytest.raises(RuntimeError, match="Cannot downgrade SSO EXPAND"): + command.downgrade(alembic_cfg, _PRIOR_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + columns = {column["name"] for column in sa.inspect(connection).get_columns("sso_config")} + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + assert {"slug", "sort_order", "updated_by"} <= columns + assert ( + connection.scalar( + sa.select(sso_user_profile.c.sso_provider).where(sso_user_profile.c.id == slug_profile_id) + ) + == expected_slug + ) + # Explicitly verified remediation lets the downgrade proceed. + connection.execute( + sso_user_profile.update() + .where(sso_user_profile.c.id == slug_profile_id) + .values(sso_provider="Primary OIDC") ) - assert resolved_config["display_name"] == "Renamed OIDC" finally: engine.dispose() @@ -135,17 +184,23 @@ def test_sso_connection_identity_upgrade_and_downgrade_preserve_seeded_rows(db_u .mappings() .one() ) + slug_profile_row = ( + connection.execute(sa.select(sso_user_profile).where(sso_user_profile.c.id == slug_profile_id)) + .mappings() + .one() + ) assert "provider_name" in columns assert {"slug", "display_name"}.isdisjoint(columns) assert _SLUG_INDEX not in indexes - assert config_row["provider_name"] == "Renamed OIDC" - assert profile_row["sso_provider"] == "Renamed OIDC" + assert config_row["provider_name"] == "Primary OIDC" + assert profile_row["sso_provider"] == "Primary OIDC" + assert slug_profile_row["sso_provider"] == "Primary OIDC" finally: engine.dispose() -def test_sso_connection_identity_upgrade_rejects_duplicate_provider_names(db_url): # noqa: F811 +def test_sso_connection_identity_upgrade_preserves_duplicate_provider_names(db_url): # noqa: F811 alembic_cfg = _make_alembic_cfg(db_url) command.upgrade(alembic_cfg, _PRIOR_REVISION) @@ -189,5 +244,21 @@ def test_sso_connection_identity_upgrade_rejects_duplicate_provider_names(db_url finally: engine.dispose() - with pytest.raises(RuntimeError, match="duplicate provider_name"): - command.upgrade(alembic_cfg, _REVISION) + command.upgrade(alembic_cfg, _REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + with engine.connect() as connection: + sso_config = sa.Table("sso_config", sa.MetaData(), autoload_with=connection) + rows = ( + connection.execute( + sa.select(sso_config).where(sso_config.c.id.in_(config_ids)).order_by(sso_config.c.id) + ) + .mappings() + .all() + ) + assert len(rows) == 2 + assert {row["provider_name"] for row in rows} == {"Shared OIDC"} + assert len({row["slug"] for row in rows}) == 2 + finally: + engine.dispose() diff --git a/src/backend/tests/unit/test_auth_settings.py b/src/backend/tests/unit/test_auth_settings.py index e15135520f12..19170a30a5e6 100644 --- a/src/backend/tests/unit/test_auth_settings.py +++ b/src/backend/tests/unit/test_auth_settings.py @@ -161,6 +161,25 @@ def test_sso_redirect_url_loads_relative_path_from_environment(self, tmp_path: P assert settings.SSO_REDIRECT_URL == "/api/v1/sso/callback" + @pytest.mark.parametrize("blank_url", ["", " ", "\t\r\n"]) + def test_sso_redirect_url_normalizes_blank_values_to_none(self, blank_url: str, tmp_path: Path): + settings = AuthSettings(CONFIG_DIR=tmp_path.as_posix(), SSO_REDIRECT_URL=blank_url) + + assert settings.SSO_REDIRECT_URL is None + + @pytest.mark.parametrize( + "control_character_url", + [ + "/api/v1/sso/\x00callback", + "/api/v1/sso/callback\nnext", + "/api/v1/sso/\x7fcallback", + "\t/api/v1/sso/callback", + ], + ) + def test_sso_redirect_url_rejects_control_characters(self, control_character_url: str, tmp_path: Path): + with pytest.raises(ValidationError, match="control characters"): + AuthSettings(CONFIG_DIR=tmp_path.as_posix(), SSO_REDIRECT_URL=control_character_url) + @pytest.mark.parametrize( "off_origin_url", [ diff --git a/src/backend/tests/unit/test_sso_models.py b/src/backend/tests/unit/test_sso_models.py index b4c3be2b8bfe..3d47b03c0d30 100644 --- a/src/backend/tests/unit/test_sso_models.py +++ b/src/backend/tests/unit/test_sso_models.py @@ -16,10 +16,16 @@ decrypt_sso_client_secret, encrypt_sso_client_secret, ) -from langflow.services.database.models.auth.sso import OIDCProviderSettings, SSOConfig, SSOSettings, SSOUserProfile +from langflow.services.database.models.auth.sso import ( + LegacyProviderSettings, + OIDCProviderSettings, + SSOConfig, + SSOSettings, + SSOUserProfile, +) from langflow.services.database.models.user.model import User from pydantic import SecretStr, ValidationError -from sqlalchemy import event, update +from sqlalchemy import event, func, literal, update from sqlalchemy.exc import IntegrityError, StatementError from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import StaticPool @@ -28,7 +34,7 @@ # Placeholder for User.password in tests (not a real secret) _TEST_PASSWORD = "hashed" # noqa: S105 -_TEST_PLAINTEXT_SECRET = "oidc-client-secret" # noqa: S105 +_TEST_PLAINTEXT_SECRET = "oidc-client-secret" # noqa: S105 # pragma: allowlist secret @pytest.fixture(name="sso_secret_settings") @@ -344,6 +350,18 @@ async def test_client_secret_rejects_plaintext_core_updates(self, sso_async_sess .values(client_secret_encrypted=_TEST_PLAINTEXT_SECRET) ) + async def test_client_secret_rejects_plaintext_core_expression_updates(self, sso_async_session): + config = SSOConfig(display_name="Core expression update connection") + sso_async_session.add(config) + await sso_async_session.commit() + + with pytest.raises(IntegrityError, match=r"client_secret_envelope|CHECK constraint failed"): + await sso_async_session.execute( + update(SSOConfig) + .where(SSOConfig.id == config.id) + .values(client_secret_encrypted=literal(_TEST_PLAINTEXT_SECRET)) + ) + async def test_client_secret_is_excluded_from_serialization_and_repr(self, sso_secret_settings): encrypted = encrypt_sso_client_secret(_TEST_PLAINTEXT_SECRET, sso_secret_settings) config = SSOConfig(display_name="Safe output", client_secret_encrypted=encrypted) @@ -385,6 +403,29 @@ async def test_update_schema_encrypts_secret_and_validates_merged_enabled_state( assert config.client_secret_encrypted is not None assert decrypt_sso_client_secret(config.client_secret_encrypted, sso_secret_settings) == _TEST_PLAINTEXT_SECRET + async def test_update_schema_atomically_converts_legacy_config_to_oidc(self, sso_secret_settings): + config = SSOConfig( + display_name="Legacy connection", + protocol="saml", + provider_settings=LegacyProviderSettings(protocol="saml"), + ) + oidc_settings = OIDCProviderSettings( + client_id="client-id", + discovery_url="https://idp.example.com/.well-known/openid-configuration", + ) + + SSOConfigUpdate( + protocol="oidc", + enabled=True, + client_secret=SecretStr(_TEST_PLAINTEXT_SECRET), + provider_settings=oidc_settings, + ).apply_to(config, sso_secret_settings) + + assert config.protocol == "oidc" + assert config.provider_settings == oidc_settings + assert config.enabled is True + assert decrypt_sso_client_secret(config.client_secret_encrypted, sso_secret_settings) == _TEST_PLAINTEXT_SECRET + async def test_incomplete_config_cannot_be_enabled(self, sso_async_session): with pytest.raises(ValueError, match="require a client_id"): SSOConfig(display_name="Incomplete", enabled=True) @@ -392,10 +433,10 @@ async def test_incomplete_config_cannot_be_enabled(self, sso_async_session): config = SSOConfig(display_name="Initially disabled") sso_async_session.add(config) await sso_async_session.commit() - config.enabled = True with pytest.raises(ValueError, match="require a client_id"): - await sso_async_session.commit() + config.enabled = True + assert config.enabled is False async def test_create_schema_rejects_incomplete_enabled_config(self): with pytest.raises(ValidationError, match="require a client_id"): @@ -414,6 +455,21 @@ async def test_provider_credentials_reject_blank_values_and_non_http_urls(self): with pytest.raises(ValidationError, match="client secret must not be blank"): SSOConfigUpdate(client_secret=SecretStr("")) + @pytest.mark.parametrize( + "malformed_url", + [ + "http:// example.com", + "http://example.com:bad", + "https://exa mple.com", + "https://%zz", + "https://example.com/%zz", + "http:///etc/passwd", + ], + ) + async def test_provider_credentials_reject_malformed_http_urls(self, malformed_url): + with pytest.raises(ValidationError, match="absolute HTTP"): + OIDCProviderSettings(discovery_url=malformed_url) + async def test_external_schemas_do_not_accept_audit_actor_fields(self, sso_secret_settings): actor_id = uuid4() with pytest.raises(ValidationError, match="created_by"): @@ -502,6 +558,35 @@ async def test_core_update_cannot_enable_an_incomplete_config(self, sso_async_se with pytest.raises(IntegrityError, match=r"enabled_complete|CHECK constraint failed"): await sso_async_session.execute(update(SSOConfig).where(SSOConfig.id == config.id).values(enabled=True)) + async def test_core_expression_cannot_install_invalid_url_on_enabled_config( + self, sso_async_session, sso_secret_settings + ): + encrypted_secret = encrypt_sso_client_secret(_TEST_PLAINTEXT_SECRET, sso_secret_settings) + config = SSOConfig( + display_name="Core URL expression", + enabled=True, + client_secret_encrypted=encrypted_secret, + provider_settings=OIDCProviderSettings( + client_id="client-id", + discovery_url="https://idp.example.com/.well-known/openid-configuration", + ), + ) + sso_async_session.add(config) + await sso_async_session.commit() + + with pytest.raises(IntegrityError, match=r"enabled_complete|CHECK constraint failed"): + await sso_async_session.execute( + update(SSOConfig) + .where(SSOConfig.id == config.id) + .values( + provider_settings=func.json_set( + SSOConfig.provider_settings, + "$.discovery_url", + "http:///etc/passwd", + ) + ) + ) + async def test_core_update_cannot_change_protocol(self, sso_async_session): config = SSOConfig(display_name="Core protocol") sso_async_session.add(config) @@ -546,6 +631,75 @@ async def test_provider_settings_reject_protocol_mismatch(self): provider_settings={"protocol": "oidc"}, ) + @pytest.mark.parametrize("protocol", ["saml", "ldap"]) + async def test_disabled_legacy_provider_settings_can_load(self, protocol, sso_async_session): + config = SSOConfig( + protocol=protocol, + display_name=f"Legacy {protocol}", + enabled=False, + provider_settings={"protocol": protocol, "client_id": "legacy-client"}, + ) + sso_async_session.add(config) + await sso_async_session.commit() + await sso_async_session.refresh(config) + + assert config.protocol == protocol + assert config.provider_settings.protocol == protocol + assert config.provider_settings.client_id == "legacy-client" + + @pytest.mark.parametrize("protocol", ["saml", "ldap"]) + async def test_legacy_provider_settings_cannot_be_enabled(self, protocol): + with pytest.raises(ValueError, match="Only OIDC configurations can be enabled"): + SSOConfig( + protocol=protocol, + display_name=f"Legacy {protocol}", + enabled=True, + provider_settings={"protocol": protocol}, + ) + + @pytest.mark.parametrize("protocol", ["saml", "ldap"]) + async def test_disabled_legacy_provider_settings_cannot_be_enabled_by_assignment(self, protocol): + config = SSOConfig( + protocol=protocol, + display_name=f"Legacy {protocol}", + enabled=False, + provider_settings={"protocol": protocol}, + ) + + with pytest.raises(ValueError, match="Only OIDC configurations can be enabled"): + config.enabled = True + + assert config.enabled is False + + @pytest.mark.parametrize("protocol", ["saml", "ldap"]) + async def test_database_preserves_enabled_legacy_provider_settings(self, protocol, sso_async_session): + config_id = uuid4() + timestamp = datetime.now(timezone.utc) + + await sso_async_session.execute( + SSOConfig.__table__.insert().values( + id=config_id, + slug=f"sso-legacy-{protocol}-{uuid4().hex}", + display_name=f"Legacy enabled {protocol}", + protocol=protocol, + enabled=True, + sort_order=0, + client_secret_encrypted=None, + provider_settings={"protocol": protocol}, + email_claim="email", + username_claim="preferred_username", + user_id_claim="sub", + created_at=timestamp, + updated_at=timestamp, + ) + ) + await sso_async_session.commit() + + config = await sso_async_session.get(SSOConfig, config_id) + assert config is not None + assert config.enabled is True + assert config.provider_settings.protocol == protocol + async def test_protocol_assignment_rejects_mismatch_with_provider_settings(self): config = SSOConfig(protocol="oidc", display_name="Valid") with pytest.raises(ValueError, match="does not match"): diff --git a/src/frontend/src/locales/de.json b/src/frontend/src/locales/de.json index 7a4045183da0..d1770d60679f 100644 --- a/src/frontend/src/locales/de.json +++ b/src/frontend/src/locales/de.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "Mit meinen API-Schlüsseln sparen", "auth.signInButton": "Anmelden", "auth.signInLink": "Anmelden", + "auth.signInPrompt": "Haben Sie bereits ein Konto? Anmelden", "auth.signUpLink": "Anmelden", "auth.signUpSuccess": "Konto erstellt! Bitte warten Sie auf die Freischaltung durch den Administrator.", "auth.signupButton": "Registrieren", diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json index b12c8949ee0d..88e203317236 100644 --- a/src/frontend/src/locales/en.json +++ b/src/frontend/src/locales/en.json @@ -268,6 +268,7 @@ "auth.signUpLink": "Sign Up", "auth.haveAccount": "Already have an account?", "auth.signInLink": "Sign in", + "auth.signInPrompt": "Already have an account? Sign in", "auth.confirmPasswordLabel": "Confirm your password", "auth.confirmPasswordPlaceholder": "Confirm your password", "auth.confirmPasswordRequired": "Please confirm your password", diff --git a/src/frontend/src/locales/es.json b/src/frontend/src/locales/es.json index b2d303cfeef5..ec92e384c411 100644 --- a/src/frontend/src/locales/es.json +++ b/src/frontend/src/locales/es.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "Ahorra con mis claves API", "auth.signInButton": "Iniciar sesión", "auth.signInLink": "Iniciar sesión", + "auth.signInPrompt": "¿Ya tienes una cuenta? Iniciar sesión", "auth.signUpLink": "Registrarse", "auth.signUpSuccess": "¡Cuenta creada! A la espera de que el administrador lo active.", "auth.signupButton": "Registrarse", diff --git a/src/frontend/src/locales/fr.json b/src/frontend/src/locales/fr.json index e32ea3a0390c..fa5bc245e9d0 100644 --- a/src/frontend/src/locales/fr.json +++ b/src/frontend/src/locales/fr.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "Enregistrez avec mes clés API", "auth.signInButton": "Connexion", "auth.signInLink": "Connexion", + "auth.signInPrompt": "Vous avez déjà un compte? Connexion", "auth.signUpLink": "S'inscrire", "auth.signUpSuccess": "Compte créé! En attente d'activation par l'administrateur.", "auth.signupButton": "S'inscrire", diff --git a/src/frontend/src/locales/ja.json b/src/frontend/src/locales/ja.json index 14b8b66c0fa9..ee1892752eeb 100644 --- a/src/frontend/src/locales/ja.json +++ b/src/frontend/src/locales/ja.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "私のAPIキーを使って節約しましょう", "auth.signInButton": "サインイン", "auth.signInLink": "サインイン", + "auth.signInPrompt": "すでにアカウントをお持ちですか?サインイン", "auth.signUpLink": "登録", "auth.signUpSuccess": "アカウントが作成されました! 管理者の承認をお待ちください。", "auth.signupButton": "登録", diff --git a/src/frontend/src/locales/pt.json b/src/frontend/src/locales/pt.json index 180b0ebf4a35..b87c11cccc57 100644 --- a/src/frontend/src/locales/pt.json +++ b/src/frontend/src/locales/pt.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "Economize com minhas chaves de API", "auth.signInButton": "Conectar", "auth.signInLink": "Conectar", + "auth.signInPrompt": "Já tem uma conta? Conectar", "auth.signUpLink": "Inscreva-se", "auth.signUpSuccess": "Conta criada! Aguarde a ativação do administrador.", "auth.signupButton": "Inscreva-se", diff --git a/src/frontend/src/locales/zh-Hans.json b/src/frontend/src/locales/zh-Hans.json index 6eae64a0468f..27cbc9f19aa4 100644 --- a/src/frontend/src/locales/zh-Hans.json +++ b/src/frontend/src/locales/zh-Hans.json @@ -226,6 +226,7 @@ "auth.saveWithApiCheckbox": "使用我的 API 密钥节省开支", "auth.signInButton": "登录", "auth.signInLink": "登录", + "auth.signInPrompt": "已有账户?登录", "auth.signUpLink": "注册", "auth.signUpSuccess": "账户已创建! 请等待管理员激活。", "auth.signupButton": "注册", diff --git a/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx b/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx index 7815b7e1a97f..7928450e0804 100644 --- a/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx +++ b/src/frontend/src/pages/LoginPage/__tests__/LoginPage.a11y.test.tsx @@ -129,12 +129,13 @@ describe("LoginPage accessibility", () => { it("uses_valid_external_labels_for_username_and_password", () => { renderLoginPage(); - expect( - screen.getByRole("textbox", { name: /username/i }), - ).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /username/i })).toHaveAttribute( + "autocomplete", + "username", + ); expect( screen.getByLabelText(/^Password/i, { selector: "input" }), - ).toBeInTheDocument(); + ).toHaveAttribute("autocomplete", "current-password"); }); it("names_the_login_form_region", () => { diff --git a/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx b/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx new file mode 100644 index 000000000000..721032031f19 --- /dev/null +++ b/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx @@ -0,0 +1,91 @@ +import { act, render } from "@testing-library/react"; +import DotGridBackground from "../dot-grid-background"; + +describe("DotGridBackground", () => { + const context = { + arc: jest.fn(), + beginPath: jest.fn(), + clearRect: jest.fn(), + fill: jest.fn(), + fillStyle: "", + setTransform: jest.fn(), + }; + const addMediaListener = jest.fn(); + const removeMediaListener = jest.fn(); + let prefersReducedMotion = true; + + beforeEach(() => { + jest.clearAllMocks(); + prefersReducedMotion = true; + jest + .spyOn(HTMLCanvasElement.prototype, "getContext") + .mockReturnValue(context as unknown as CanvasRenderingContext2D); + jest.spyOn(window, "requestAnimationFrame").mockReturnValue(1); + jest.spyOn(window, "cancelAnimationFrame"); + jest.spyOn(window, "matchMedia").mockImplementation( + (query: string) => + ({ + addEventListener: (type, listener) => + addMediaListener(query, type, listener), + matches: + query === "(prefers-reduced-motion: reduce)" && + prefersReducedMotion, + media: query, + onchange: null, + removeEventListener: (type, listener) => + removeMediaListener(query, type, listener), + }) as MediaQueryList, + ); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("reads the theme once per static draw", () => { + const getElementById = jest.spyOn(document, "getElementById"); + + render(); + + expect(context.arc).toHaveBeenCalled(); + expect(getElementById).toHaveBeenCalledTimes(1); + }); + + it("redraws after resize when reduced motion is enabled", () => { + render(); + expect(context.clearRect).toHaveBeenCalledTimes(1); + + act(() => window.dispatchEvent(new Event("resize"))); + + expect(context.clearRect).toHaveBeenCalledTimes(2); + expect(window.requestAnimationFrame).not.toHaveBeenCalled(); + }); + + it("stops and restarts animation when reduced motion changes", () => { + prefersReducedMotion = false; + const { unmount } = render(); + const reducedMotionRegistration = addMediaListener.mock.calls.find( + ([query]) => query === "(prefers-reduced-motion: reduce)", + ); + const listener = reducedMotionRegistration?.[2] as ( + event: MediaQueryListEvent, + ) => void; + + expect(listener).toBeDefined(); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + + act(() => listener({ matches: true } as MediaQueryListEvent)); + expect(window.cancelAnimationFrame).toHaveBeenCalledWith(1); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(1); + + act(() => listener({ matches: false } as MediaQueryListEvent)); + expect(window.requestAnimationFrame).toHaveBeenCalledTimes(2); + + unmount(); + expect(removeMediaListener).toHaveBeenCalledWith( + "(prefers-reduced-motion: reduce)", + "change", + listener, + ); + }); +}); diff --git a/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx b/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx index cb94edadf7f1..af517f1930a3 100644 --- a/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx +++ b/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx @@ -21,36 +21,20 @@ export default function DotGridBackground() { const context = canvas.getContext("2d"); if (!context) return; - const reducedMotion = window.matchMedia( + const reducedMotionMedia = window.matchMedia( "(prefers-reduced-motion: reduce)", - ).matches; + ); + const colorSchemeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + let reducedMotion = reducedMotionMedia.matches; + const themeElement = document.getElementById("body"); let animationFrame = 0; let dots: Dot[] = []; const pointer = { x: -1000, y: -1000 }; - const resize = () => { - const pixelRatio = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * pixelRatio; - canvas.height = window.innerHeight * pixelRatio; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; - context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); - - dots = []; - const columns = Math.ceil(window.innerWidth / DOT_SPACING) + 1; - const rows = Math.ceil(window.innerHeight / DOT_SPACING) + 1; - - for (let column = 0; column < columns; column += 1) { - for (let row = 0; row < rows; row += 1) { - const x = column * DOT_SPACING; - const y = row * DOT_SPACING; - dots.push({ x, y, baseX: x, baseY: y, radius: 1 }); - } - } - }; - const draw = () => { context.clearRect(0, 0, window.innerWidth, window.innerHeight); + const isDark = + themeElement?.classList.contains("dark") ?? colorSchemeMedia.matches; dots.forEach((dot) => { const deltaX = pointer.x - dot.baseX; @@ -73,9 +57,6 @@ export default function DotGridBackground() { dot.radius += (targetRadius - dot.radius) * 0.15; const intensity = Math.min(1, (dot.radius - 1) / 2); - const isDark = - document.getElementById("body")?.classList.contains("dark") ?? - window.matchMedia("(prefers-color-scheme: dark)").matches; // Light dots on dark canvas; darker dots on light canvas. const shade = isDark ? Math.round(51 + (200 - 51) * intensity) @@ -91,6 +72,54 @@ export default function DotGridBackground() { } }; + const resize = () => { + const pixelRatio = window.devicePixelRatio || 1; + canvas.width = window.innerWidth * pixelRatio; + canvas.height = window.innerHeight * pixelRatio; + canvas.style.width = `${window.innerWidth}px`; + canvas.style.height = `${window.innerHeight}px`; + context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); + + dots = []; + const columns = Math.ceil(window.innerWidth / DOT_SPACING) + 1; + const rows = Math.ceil(window.innerHeight / DOT_SPACING) + 1; + + for (let column = 0; column < columns; column += 1) { + for (let row = 0; row < rows; row += 1) { + const x = column * DOT_SPACING; + const y = row * DOT_SPACING; + dots.push({ x, y, baseX: x, baseY: y, radius: 1 }); + } + } + + if (reducedMotion) { + draw(); + } + }; + + const redrawStaticCanvas = () => { + if (reducedMotion) { + draw(); + } + }; + + const handleReducedMotionChange = (event: MediaQueryListEvent) => { + reducedMotion = event.matches; + window.cancelAnimationFrame(animationFrame); + animationFrame = 0; + + if (reducedMotion) { + pointer.x = -1000; + pointer.y = -1000; + dots.forEach((dot) => { + dot.x = dot.baseX; + dot.y = dot.baseY; + dot.radius = 1; + }); + } + draw(); + }; + const handlePointerMove = (event: PointerEvent) => { pointer.x = event.clientX; pointer.y = event.clientY; @@ -102,13 +131,32 @@ export default function DotGridBackground() { }; resize(); - draw(); + if (!reducedMotion) { + draw(); + } + const themeObserver = themeElement + ? new MutationObserver(redrawStaticCanvas) + : null; + if (themeElement) { + themeObserver?.observe(themeElement, { + attributeFilter: ["class"], + attributes: true, + }); + } + colorSchemeMedia.addEventListener?.("change", redrawStaticCanvas); + reducedMotionMedia.addEventListener?.("change", handleReducedMotionChange); window.addEventListener("resize", resize); window.addEventListener("pointermove", handlePointerMove); document.documentElement.addEventListener("pointerleave", resetPointer); return () => { window.cancelAnimationFrame(animationFrame); + themeObserver?.disconnect(); + colorSchemeMedia.removeEventListener?.("change", redrawStaticCanvas); + reducedMotionMedia.removeEventListener?.( + "change", + handleReducedMotionChange, + ); window.removeEventListener("resize", resize); window.removeEventListener("pointermove", handlePointerMove); document.documentElement.removeEventListener( diff --git a/src/frontend/src/pages/LoginPage/index.tsx b/src/frontend/src/pages/LoginPage/index.tsx index 38198d66621d..af0c4cabdfc9 100644 --- a/src/frontend/src/pages/LoginPage/index.tsx +++ b/src/frontend/src/pages/LoginPage/index.tsx @@ -198,6 +198,7 @@ export default function LoginPage(): JSX.Element { required id="login-password" inputProps={{ + autoComplete: "current-password", "aria-describedby": passwordError ? "login-password-error" : undefined, diff --git a/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx b/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx index 43598f12aaf0..3594f88cf7b2 100644 --- a/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx +++ b/src/frontend/src/pages/SignUpPage/__tests__/SignUpPage.a11y.test.tsx @@ -93,13 +93,18 @@ describe("SignUpPage accessibility", () => { it("uses_valid_external_labels_for_all_fields", () => { renderSignUpPage(); - expect( - screen.getByRole("textbox", { name: /username/i }), - ).toBeInTheDocument(); - expect(screen.getByLabelText(/^Password/i)).toBeInTheDocument(); - expect( - screen.getByLabelText(/^Confirm your password/i), - ).toBeInTheDocument(); + expect(screen.getByRole("textbox", { name: /username/i })).toHaveAttribute( + "autocomplete", + "username", + ); + expect(screen.getByLabelText(/^Password/i)).toHaveAttribute( + "autocomplete", + "new-password", + ); + expect(screen.getByLabelText(/^Confirm your password/i)).toHaveAttribute( + "autocomplete", + "new-password", + ); }); it("renders_sign_in_navigation_as_one_link", () => { @@ -109,7 +114,7 @@ describe("SignUpPage accessibility", () => { name: /already have an account.*sign in/i, }); expect(signInLink).toHaveAttribute("href", "/login"); - expect(signInLink.querySelector("button")).not.toBeInTheDocument(); + expect(signInLink.querySelector("button")).toBeNull(); }); it("announces_actionable_password_mismatch_suggestion_after_confirm_blur", () => { diff --git a/src/frontend/src/pages/SignUpPage/index.tsx b/src/frontend/src/pages/SignUpPage/index.tsx index 3b374e4a9428..a1f8060f878d 100644 --- a/src/frontend/src/pages/SignUpPage/index.tsx +++ b/src/frontend/src/pages/SignUpPage/index.tsx @@ -14,7 +14,7 @@ import { appendErrorSuggestion, getRequiredFieldError, } from "@/utils/authErrorMessages"; -import { Button } from "../../components/ui/button"; +import { Button, buttonVariants } from "../../components/ui/button"; import { Input } from "../../components/ui/input"; import { CONTROL_INPUT_STATE } from "../../constants/constants"; import useAlertStore from "../../stores/alertStore"; @@ -23,6 +23,7 @@ import type { signUpInputStateType, UserInputType, } from "../../types/components"; +import { cn } from "../../utils/utils"; import DotGridBackground from "../LoginPage/components/dot-grid-background"; export default function SignUp(): JSX.Element { @@ -227,6 +228,7 @@ export default function SignUp(): JSX.Element { required id="signup-password" inputProps={{ + autoComplete: "new-password", "aria-describedby": passwordError ? "signup-password-error" : undefined, @@ -283,6 +285,7 @@ export default function SignUp(): JSX.Element { required id="signup-confirm-password" inputProps={{ + autoComplete: "new-password", "aria-describedby": confirmPasswordError ? "signup-confirm-password-error" : undefined, @@ -309,22 +312,16 @@ export default function SignUp(): JSX.Element { - - + {t("auth.signInPrompt")} +
diff --git a/src/lfx/src/lfx/services/settings/auth.py b/src/lfx/src/lfx/services/settings/auth.py index 8961790b85f0..bce071fbd733 100644 --- a/src/lfx/src/lfx/services/settings/auth.py +++ b/src/lfx/src/lfx/services/settings/auth.py @@ -24,6 +24,7 @@ ) ASCII_CONTROL_CHARACTER_LIMIT = 0x20 +ASCII_DELETE_CHARACTER = 0x7F def _warn_if_secret_key_is_short(value: str | SecretStr) -> None: @@ -372,10 +373,14 @@ def validate_sso_redirect_url(cls, value): if value is None: return None - url = str(value).strip() + raw_url = str(value) + url = raw_url.strip() if not url: return None - if any(ord(character) < ASCII_CONTROL_CHARACTER_LIMIT for character in url): + if any( + ord(character) < ASCII_CONTROL_CHARACTER_LIMIT or ord(character) == ASCII_DELETE_CHARACTER + for character in raw_url + ): msg = "SSO_REDIRECT_URL must not contain control characters." raise ValueError(msg) From 01af799a49f82c18b161eaf4001f08a2a7166e2b Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 5 Aug 2026 09:44:10 -0700 Subject: [PATCH 22/23] fix: allow SSO expand schema drift --- src/backend/base/langflow/alembic/env.py | 3 + .../base/langflow/alembic/expand_compat.py | 109 +++++++++++++ .../unit/alembic/test_migration_execution.py | 153 ++++++++++-------- 3 files changed, 200 insertions(+), 65 deletions(-) create mode 100644 src/backend/base/langflow/alembic/expand_compat.py diff --git a/src/backend/base/langflow/alembic/env.py b/src/backend/base/langflow/alembic/env.py index 638032c43b84..949ce961ff5a 100644 --- a/src/backend/base/langflow/alembic/env.py +++ b/src/backend/base/langflow/alembic/env.py @@ -12,6 +12,7 @@ from sqlalchemy.exc import SAWarning from sqlalchemy.ext.asyncio import async_engine_from_config +from langflow.alembic.expand_compat import filter_expand_revision_directives from langflow.services.database.service import SQLModel # this is the Alembic Config object, which provides @@ -61,6 +62,7 @@ def run_migrations_offline() -> None: "literal_binds": True, "dialect_opts": {"paramstyle": "named"}, "render_as_batch": True, + "process_revision_directives": filter_expand_revision_directives, } # Only add prepare_threshold for PostgreSQL @@ -93,6 +95,7 @@ def _do_run_migrations(connection): "connection": connection, "target_metadata": target_metadata, "render_as_batch": True, + "process_revision_directives": filter_expand_revision_directives, } # Only add prepare_threshold for PostgreSQL diff --git a/src/backend/base/langflow/alembic/expand_compat.py b/src/backend/base/langflow/alembic/expand_compat.py new file mode 100644 index 000000000000..4ad0e0a95a6e --- /dev/null +++ b/src/backend/base/langflow/alembic/expand_compat.py @@ -0,0 +1,109 @@ +"""Autogenerate compatibility rules for active EXPAND migration windows.""" + +from alembic.operations import ops + +# ``sso_config`` is deliberately in an EXPAND window: released N-1 services +# still need the scalar columns, while N reads the typed JSON representation. +# Alembic therefore sees the retained DB-only columns and nullable typed columns +# as a future CONTRACT migration. Keep this list exact and remove it with that +# contract revision; rolling-compatibility migration tests assert that the +# temporary physical schema remains present and synchronized. +SSO_EXPAND_LEGACY_COLUMNS = frozenset( + { + "provider", + "provider_name", + "enforce_sso", + "client_id", + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + } +) +SSO_EXPAND_NULLABLE_COLUMNS = frozenset({"slug", "display_name", "protocol", "provider_settings"}) +_REMOVE_COLUMN_DIFF_LENGTH = 4 +_MODIFY_NULLABLE_DIFF_LENGTH = 7 + + +def filter_sso_expand_diffs(diffs: list) -> list: + """Suppress only schema diffs intentionally deferred to SSO CONTRACT.""" + significant_diffs = [] + for diff in diffs: + # Alembic can group multiple alter-column operations in a nested list. + if isinstance(diff, list): + filtered_group = filter_sso_expand_diffs(diff) + if filtered_group: + significant_diffs.append(filtered_group) + continue + if not isinstance(diff, tuple): + significant_diffs.append(diff) + continue + + if ( + len(diff) >= _REMOVE_COLUMN_DIFF_LENGTH + and diff[0] == "remove_column" + and diff[2] == "sso_config" + and getattr(diff[3], "name", None) in SSO_EXPAND_LEGACY_COLUMNS + ): + continue + if ( + len(diff) >= _MODIFY_NULLABLE_DIFF_LENGTH + and diff[0] == "modify_nullable" + and diff[2] == "sso_config" + and diff[3] in SSO_EXPAND_NULLABLE_COLUMNS + and diff[5] is True + and diff[6] is False + ): + continue + significant_diffs.append(diff) + + return significant_diffs + + +def _filter_sso_expand_operations(container: ops.OpContainer) -> None: + filtered_operations = [] + for operation in container.ops: + if isinstance(operation, ops.OpContainer): + _filter_sso_expand_operations(operation) + if operation.ops: + filtered_operations.append(operation) + continue + + if ( + isinstance(operation, ops.DropColumnOp) + and operation.table_name == "sso_config" + and operation.column_name in SSO_EXPAND_LEGACY_COLUMNS + ): + continue + + if ( + isinstance(operation, ops.AlterColumnOp) + and operation.table_name == "sso_config" + and operation.column_name in SSO_EXPAND_NULLABLE_COLUMNS + and operation.existing_nullable is True + and operation.modify_nullable is False + ): + # Preserve any type/default/comment change Alembic grouped with the + # expected nullable diff so real schema drift remains visible. + operation.modify_nullable = None + if not operation.has_changes(): + continue + + filtered_operations.append(operation) + + container.ops[:] = filtered_operations + + +def filter_expand_revision_directives(_context, _revision, directives: list[ops.MigrationScript]) -> None: + """Apply active EXPAND allowlists to Alembic autogenerate/check output.""" + for directive in directives: + for upgrade_ops, downgrade_ops in zip( + directive.upgrade_ops_list, + directive.downgrade_ops_list, + strict=True, + ): + _filter_sso_expand_operations(upgrade_ops) + upgrade_ops.reverse_into(downgrade_ops) diff --git a/src/backend/tests/unit/alembic/test_migration_execution.py b/src/backend/tests/unit/alembic/test_migration_execution.py index 37f993d59b58..0d3be64150ca 100644 --- a/src/backend/tests/unit/alembic/test_migration_execution.py +++ b/src/backend/tests/unit/alembic/test_migration_execution.py @@ -11,35 +11,14 @@ from alembic.autogenerate import compare_metadata from alembic.config import Config from alembic.migration import MigrationContext +from alembic.operations import ops +from langflow.alembic.expand_compat import filter_expand_revision_directives, filter_sso_expand_diffs from langflow.services.database.service import SQLModel -from sqlalchemy import create_engine, inspect, text +from sqlalchemy import Column, String, Text, create_engine, inspect, text _WORKSPACE_ROOT = Path(__file__).resolve().parents[5] _SCRIPT_LOCATION = _WORKSPACE_ROOT / "src/backend/base/langflow/alembic" -# ``sso_config`` is deliberately in an EXPAND window: released N-1 services -# still need the scalar columns, while N reads the typed JSON representation. -# Alembic therefore sees the retained DB-only columns and nullable typed columns -# as a future CONTRACT migration. Keep this list exact and remove it with that -# contract revision; the rolling-compatibility migration tests assert that the -# temporary physical schema remains present and synchronized. -_SSO_EXPAND_LEGACY_COLUMNS = frozenset( - { - "provider", - "provider_name", - "enforce_sso", - "client_id", - "discovery_url", - "redirect_uri", - "scopes", - "token_endpoint", - "authorization_endpoint", - "jwks_uri", - "issuer", - } -) -_SSO_EXPAND_NULLABLE_COLUMNS = frozenset({"slug", "display_name", "protocol", "provider_settings"}) - def _make_alembic_cfg(db_url: str) -> Config: """Create an Alembic Config pointing at the project's migration scripts.""" @@ -287,41 +266,6 @@ def _filter_sqlite_noise(diffs: list) -> list: return significant_diffs -def _filter_sso_expand_contract_diffs(diffs: list) -> list: - """Suppress only the schema diffs intentionally deferred to SSO CONTRACT.""" - significant_diffs = [] - for diff in diffs: - # Alembic can group multiple alter-column operations in a nested list. - if isinstance(diff, list): - filtered_group = _filter_sso_expand_contract_diffs(diff) - if filtered_group: - significant_diffs.append(filtered_group) - continue - if not isinstance(diff, tuple): - significant_diffs.append(diff) - continue - - if ( - len(diff) >= 4 - and diff[0] == "remove_column" - and diff[2] == "sso_config" - and getattr(diff[3], "name", None) in _SSO_EXPAND_LEGACY_COLUMNS - ): - continue - if ( - len(diff) >= 7 - and diff[0] == "modify_nullable" - and diff[2] == "sso_config" - and diff[3] in _SSO_EXPAND_NULLABLE_COLUMNS - and diff[5] is True - and diff[6] is False - ): - continue - significant_diffs.append(diff) - - return significant_diffs - - class _FakeColumn: """Minimal stand-in for sqlalchemy Column used by FK constraint diffs.""" @@ -407,10 +351,7 @@ def test_exact_legacy_remove_and_nullable_diffs_are_suppressed(self): False, ) - assert ( - _filter_sso_expand_contract_diffs([("remove_column", None, "sso_config", legacy_column), [nullable_diff]]) - == [] - ) + assert filter_sso_expand_diffs([("remove_column", None, "sso_config", legacy_column), [nullable_diff]]) == [] def test_other_tables_columns_and_nullable_directions_are_preserved(self): unrelated_column = _FakeColumn("provider", "another_table") @@ -429,7 +370,86 @@ def test_other_tables_columns_and_nullable_directions_are_preserved(self): ("modify_type", None, "sso_config", "provider_settings"), ] - assert _filter_sso_expand_contract_diffs(diffs) == diffs + assert filter_sso_expand_diffs(diffs) == diffs + + def test_revision_hook_preserves_unrelated_and_grouped_changes(self): + nullable_only = ops.AlterColumnOp( + "sso_config", + "slug", + existing_type=String(), + existing_nullable=True, + modify_nullable=False, + ) + nullable_and_type = ops.AlterColumnOp( + "sso_config", + "provider_settings", + existing_type=String(), + existing_nullable=True, + modify_nullable=False, + modify_type=Text(), + ) + unrelated_nullable = ops.AlterColumnOp( + "another_table", + "slug", + existing_type=String(), + existing_nullable=True, + modify_nullable=False, + ) + unlisted_nullable = ops.AlterColumnOp( + "sso_config", + "enabled", + existing_type=String(), + existing_nullable=True, + modify_nullable=False, + ) + inverse_nullable = ops.AlterColumnOp( + "sso_config", + "slug", + existing_type=String(), + existing_nullable=False, + modify_nullable=True, + ) + unlisted_drop = ops.DropColumnOp.from_column_and_tablename( + None, + "sso_config", + Column("unexpected", String()), + ) + upgrade_ops = ops.UpgradeOps( + [ + ops.ModifyTableOps( + "sso_config", + [ + ops.DropColumnOp("sso_config", "provider"), + nullable_only, + nullable_and_type, + unlisted_drop, + unlisted_nullable, + inverse_nullable, + ], + ), + ops.ModifyTableOps("another_table", [unrelated_nullable]), + ] + ) + migration_script = ops.MigrationScript("test", upgrade_ops, ops.DowngradeOps([])) + + filter_expand_revision_directives(None, None, [migration_script]) + + diffs = migration_script.upgrade_ops.as_diffs() + flat_diffs = [diff for group in diffs for diff in (group if isinstance(group, list) else [group])] + assert [diff[0] for diff in flat_diffs] == [ + "modify_type", + "remove_column", + "modify_nullable", + "modify_nullable", + "modify_nullable", + ] + assert flat_diffs[0][2:4] == ("sso_config", "provider_settings") + assert flat_diffs[1][2] == "sso_config" + assert flat_diffs[1][3].name == "unexpected" + assert flat_diffs[2][2:4] == ("sso_config", "enabled") + assert flat_diffs[3][2:4] == ("sso_config", "slug") + assert flat_diffs[4][2:4] == ("another_table", "slug") + assert migration_script.downgrade_ops.as_diffs() == migration_script.upgrade_ops.reverse().as_diffs() def _engine_url(db_url: str) -> str: @@ -441,7 +461,7 @@ def _engine_url(db_url: str) -> str: def _filter_diffs(diffs: list, db_url: str) -> list: """Apply documented compatibility and SQLite-specific diff filtering.""" - filtered_diffs = _filter_sso_expand_contract_diffs(diffs) + filtered_diffs = filter_sso_expand_diffs(diffs) if "sqlite" in db_url: filtered_diffs = _filter_sqlite_noise(filtered_diffs) return filtered_diffs @@ -457,6 +477,9 @@ def test_no_phantom_migrations(db_url): """ alembic_cfg = _make_alembic_cfg(db_url) command.upgrade(alembic_cfg, "head") + # Exercise the same Alembic autogenerate path used by DatabaseService at + # application startup, including the active EXPAND compatibility hook. + command.check(alembic_cfg) engine = create_engine(_engine_url(db_url)) try: From 52f00228e3b434ea288291faf6f1f3d709d4039d Mon Sep 17 00:00:00 2001 From: Eric Hare Date: Wed, 5 Aug 2026 10:35:15 -0700 Subject: [PATCH 23/23] fix: address SSO review follow-ups --- scripts/migrate_secret_key.py | 15 +- ...f0a1b2d_add_sso_config_invariant_checks.py | 190 +++++++++++++++--- ...a2b3c_complete_sso_expand_compatibility.py | 80 ++++++++ .../services/database/models/auth/sso.py | 3 +- .../database/models/auth/sso_secret.py | 4 + .../test_sso_multi_identity_migration.py | 11 +- ...est_sso_rolling_compatibility_migration.py | 122 +++++++++++ .../unit/scripts/test_migrate_secret_key.py | 19 ++ src/backend/tests/unit/test_sso_models.py | 11 + src/backend/tests/unit/test_sso_secrets.py | 28 ++- .../__tests__/dot-grid-background.test.tsx | 17 ++ .../components/dot-grid-background.tsx | 18 +- 12 files changed, 479 insertions(+), 39 deletions(-) diff --git a/scripts/migrate_secret_key.py b/scripts/migrate_secret_key.py index 4389ae006e95..318fbac94007 100644 --- a/scripts/migrate_secret_key.py +++ b/scripts/migrate_secret_key.py @@ -21,6 +21,7 @@ import argparse import base64 +import binascii import json import os import platform @@ -50,6 +51,7 @@ SSO_TAG_BYTES = 16 SSO_ENVELOPE_PARTS = 6 SSO_HEADER_PARTS = 4 +SSO_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") def get_default_config_dir() -> Path: @@ -167,8 +169,17 @@ def _decode_sso_envelope(envelope: str) -> tuple[bytes, bytes]: if len(parts) != SSO_ENVELOPE_PARTS or ":".join(parts[:SSO_HEADER_PARTS]) != SSO_ENVELOPE_HEADER: msg = "Unsupported SSO client-secret envelope" raise ValueError(msg) - nonce = base64.urlsafe_b64decode(parts[4] + "=" * (-len(parts[4]) % 4)) - ciphertext = base64.urlsafe_b64decode(parts[5] + "=" * (-len(parts[5]) % 4)) + decoded_payloads: list[bytes] = [] + for value in parts[4:]: + if not value or any(character not in SSO_BASE64URL_ALPHABET for character in value): + msg = "Invalid base64url data in SSO client-secret envelope" + raise ValueError(msg) + try: + decoded_payloads.append(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True)) + except (binascii.Error, ValueError) as exc: + msg = "Invalid base64url data in SSO client-secret envelope" + raise ValueError(msg) from exc + nonce, ciphertext = decoded_payloads if len(nonce) != SSO_NONCE_BYTES or len(ciphertext) < SSO_TAG_BYTES: msg = "Invalid SSO client-secret envelope payload" raise ValueError(msg) diff --git a/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py b/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py index 9552363fcfe6..50404f930f1d 100644 --- a/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py +++ b/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py @@ -7,6 +7,8 @@ Phase: EXPAND """ +import base64 +import binascii import re from collections.abc import Sequence from urllib.parse import urlsplit @@ -39,8 +41,22 @@ _POSTGRES_TRIGGER_FUNCTION = "prevent_sso_config_slug_update" _SUPPORTED_PROTOCOLS = ("oidc", "saml", "ldap") _ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" +_ENVELOPE_PART_COUNT = 6 _ENVELOPE_NONCE_LENGTH = 16 _ENVELOPE_MIN_CIPHERTEXT_LENGTH = 22 +_ENVELOPE_NONCE_BYTES = 12 +_ENVELOPE_MIN_CIPHERTEXT_BYTES = 16 +_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") +_PROVIDER_SETTING_COLUMNS = ( + "discovery_url", + "redirect_uri", + "scopes", + "token_endpoint", + "authorization_endpoint", + "jwks_uri", + "issuer", + "client_id", +) _REMOTE_URL_FIELDS = ( "discovery_url", "token_endpoint", @@ -70,6 +86,10 @@ def _config_table() -> sa.Table: ) +def _column_names(conn: sa.Connection) -> set[str]: + return {column["name"] for column in sa.inspect(conn).get_columns(_CONFIG_TABLE)} + + def _nonblank_json_string(json_column: sa.Column, key: str) -> sa.ColumnElement[bool]: value = json_column[key].as_string() return sa.and_(value.is_not(None), sa.func.length(sa.func.trim(value)) > 0) @@ -101,6 +121,7 @@ def _protocol_check( table: sa.Table, *, allow_supported_mismatch: bool = False, + allow_pending_n_minus_one: bool = False, ) -> sa.ColumnElement[bool]: settings_protocol = table.c.provider_settings["protocol"].as_string() synchronized = sa.and_( @@ -118,6 +139,8 @@ def _protocol_check( table.c.protocol.in_(_SUPPORTED_PROTOCOLS), settings_protocol.in_(_SUPPORTED_PROTOCOLS), ) + if not allow_pending_n_minus_one: + return synchronized return sa.or_( # Temporary N-1 INSERT state. SQLite evaluates CHECK constraints before # the head revision's AFTER INSERT compatibility trigger can populate @@ -125,22 +148,15 @@ def _protocol_check( sa.and_( table.c.protocol.is_(None), table.c.provider_settings.is_(None), - table.c.provider.is_not(None), + table.c.provider.in_(_SUPPORTED_PROTOCOLS), ), synchronized, ) -def _enabled_check(table: sa.Table) -> sa.ColumnElement[bool]: +def _enabled_check(table: sa.Table, *, allow_pending_n_minus_one: bool = False) -> sa.ColumnElement[bool]: settings = table.c.provider_settings - return sa.or_( - # See _protocol_check: the compatibility trigger immediately fills the - # typed fields. Final constraints then validate the synchronized row. - sa.and_( - table.c.protocol.is_(None), - table.c.provider_settings.is_(None), - table.c.provider.is_not(None), - ), + allowed_states = [ table.c.enabled.is_(False), # Historical Enterprise plugins can continue executing their released # SAML/LDAP rows during the rolling window. OIDC-only completeness is @@ -160,7 +176,19 @@ def _enabled_check(table: sa.Table) -> sa.ColumnElement[bool]: ), *(_http_json_url_or_null(settings, key) for key in _REMOTE_URL_FIELDS), ), - ) + ] + if allow_pending_n_minus_one: + # See _protocol_check: the compatibility trigger immediately fills the + # typed fields. Final constraints then validate the synchronized row. + allowed_states.insert( + 0, + sa.and_( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.in_(_SUPPORTED_PROTOCOLS), + ), + ) + return sa.or_(*allowed_states) def _client_secret_check(table: sa.Table) -> sa.ColumnElement[bool]: @@ -177,6 +205,27 @@ def _client_secret_check(table: sa.Table) -> sa.ColumnElement[bool]: ) +def _is_secret_envelope(value: object) -> bool: + if not isinstance(value, str): + return False + parts = value.split(":") + if len(parts) != _ENVELOPE_PART_COUNT or f"{':'.join(parts[:4])}:" != _ENVELOPE_HEADER: + return False + + decoded_payloads = [] + for encoded in parts[4:]: + if not encoded or any(character not in _BASE64URL_ALPHABET for character in encoded): + return False + try: + decoded_payloads.append( + base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True) + ) + except (binascii.Error, ValueError): + return False + nonce, ciphertext = decoded_payloads + return len(nonce) == _ENVELOPE_NONCE_BYTES and len(ciphertext) >= _ENVELOPE_MIN_CIPHERTEXT_BYTES + + def _is_http_url(value: object) -> bool: if not isinstance(value, str) or not value.strip(): return False @@ -198,6 +247,68 @@ def _is_http_url(value: object) -> bool: return valid +def _sanitize_pending_n_minus_one_configs(conn: sa.Connection) -> None: + """Fail closed for legacy rows written after the typed-column backfill.""" + columns = _column_names(conn) + required = {"id", "protocol", "provider", "enabled", "client_secret_encrypted", "provider_settings"} + if not required <= columns: + return + + selected_names = [*required] + selected_names.extend(name for name in _PROVIDER_SETTING_COLUMNS if name in columns) + table = sa.table( + _CONFIG_TABLE, + *( + sa.column( + name, + sa.JSON() + if name == "provider_settings" + else sa.Boolean() + if name == "enabled" + else sa.String() + if name != "id" + else None, + ) + for name in selected_names + ), + ) + rows = ( + conn.execute( + sa.select(*(table.c[name] for name in selected_names)).where( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.in_(_SUPPORTED_PROTOCOLS), + ) + ) + .mappings() + .all() + ) + for row in rows: + secret_is_valid = _is_secret_envelope(row["client_secret_encrypted"]) + values: dict[str, object] = {} + if row["client_secret_encrypted"] is not None and not secret_is_valid: + values["client_secret_encrypted"] = None + + if row["provider"] == "oidc" and row["enabled"]: + has_client_id = isinstance(row.get("client_id"), str) and bool(row["client_id"].strip()) + has_discovery = _is_http_url(row.get("discovery_url")) + endpoint_values = [row.get(key) for key in ("authorization_endpoint", "token_endpoint", "jwks_uri")] + has_explicit_endpoints = all(_is_http_url(value) for value in endpoint_values) + supplied_urls_are_valid = all( + value is None or _is_http_url(value) for value in (row.get(key) for key in _REMOTE_URL_FIELDS) + ) + if not ( + secret_is_valid + and has_client_id + and (has_discovery or has_explicit_endpoints) + and supplied_urls_are_valid + ): + values["enabled"] = False + + if values: + conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) + + def _disable_invalid_enabled_configs(conn: sa.Connection, table: sa.Table) -> None: """Fail closed for legacy enabled rows that cannot satisfy the new invariant.""" rows = conn.execute( @@ -222,7 +333,7 @@ def _disable_invalid_enabled_configs(conn: sa.Connection, table: sa.Table) -> No value is None or _is_http_url(value) for value in (settings.get(key) for key in _REMOTE_URL_FIELDS) ) if not ( - row["client_secret_encrypted"] + _is_secret_envelope(row["client_secret_encrypted"]) and has_client_id and (has_discovery or has_explicit_endpoints) and supplied_urls_are_valid @@ -231,13 +342,29 @@ def _disable_invalid_enabled_configs(conn: sa.Connection, table: sa.Table) -> No def _raise_for_protocol_mismatches(conn: sa.Connection, table: sa.Table) -> None: - invalid_ids = [ - str(row.id) - for row in conn.execute(sa.select(table.c.id, table.c.protocol, table.c.provider_settings)) - if row.protocol not in _SUPPORTED_PROTOCOLS - or not isinstance(row.provider_settings, dict) - or row.provider_settings.get("protocol") != row.protocol - ] + has_legacy_provider = "provider" in _column_names(conn) + selected_columns = [table.c.id, table.c.protocol, table.c.provider_settings] + if has_legacy_provider: + selected_columns.append(table.c.provider) + + invalid_ids = [] + for row in conn.execute(sa.select(*selected_columns)).mappings(): + # Mirror _protocol_check: a pending N-1 insert is a legal temporary + # state only when the released representation physically exists and + # names a supported protocol. + if ( + has_legacy_provider + and row["protocol"] is None + and row["provider_settings"] is None + and row["provider"] in _SUPPORTED_PROTOCOLS + ): + continue + if ( + row["protocol"] not in _SUPPORTED_PROTOCOLS + or not isinstance(row["provider_settings"], dict) + or row["provider_settings"].get("protocol") != row["protocol"] + ): + invalid_ids.append(str(row["id"])) if invalid_ids: msg = ( "sso_config contains unsupported or inconsistent protocol settings for row(s): " @@ -260,22 +387,38 @@ def _create_checks(conn: sa.Connection, table: sa.Table) -> None: need_client_secret = not existing.intersection(_CLIENT_SECRET_CHECK_ALIASES) if not need_protocol and not need_enabled and not need_client_secret: return + allow_pending_n_minus_one = "provider" in _column_names(conn) if conn.dialect.name == "sqlite": with op.batch_alter_table(_CONFIG_TABLE, recreate="always") as batch_op: if need_protocol: batch_op.create_check_constraint( op.f(_PROTOCOL_CHECK), - _protocol_check(table, allow_supported_mismatch=True), + _protocol_check( + table, + allow_supported_mismatch=True, + allow_pending_n_minus_one=allow_pending_n_minus_one, + ), ) if need_enabled: - batch_op.create_check_constraint(op.f(_ENABLED_CHECK), _enabled_check(table)) + batch_op.create_check_constraint( + op.f(_ENABLED_CHECK), + _enabled_check(table, allow_pending_n_minus_one=allow_pending_n_minus_one), + ) if need_client_secret: batch_op.create_check_constraint(op.f(_CLIENT_SECRET_CHECK), _client_secret_check(table)) return if need_protocol: - op.create_check_constraint(op.f(_PROTOCOL_CHECK), _CONFIG_TABLE, _protocol_check(table)) + op.create_check_constraint( + op.f(_PROTOCOL_CHECK), + _CONFIG_TABLE, + _protocol_check(table, allow_pending_n_minus_one=allow_pending_n_minus_one), + ) if need_enabled: - op.create_check_constraint(op.f(_ENABLED_CHECK), _CONFIG_TABLE, _enabled_check(table)) + op.create_check_constraint( + op.f(_ENABLED_CHECK), + _CONFIG_TABLE, + _enabled_check(table, allow_pending_n_minus_one=allow_pending_n_minus_one), + ) if need_client_secret: op.create_check_constraint(op.f(_CLIENT_SECRET_CHECK), _CONFIG_TABLE, _client_secret_check(table)) @@ -357,6 +500,7 @@ def upgrade() -> None: if not migration.table_exists(_CONFIG_TABLE, conn): return table = _config_table() + _sanitize_pending_n_minus_one_configs(conn) _disable_invalid_enabled_configs(conn, table) _raise_for_protocol_mismatches(conn, table) _create_checks(conn, table) diff --git a/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py b/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py index f3d3503ded1e..25ae8a2cbe00 100644 --- a/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py +++ b/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py @@ -29,7 +29,10 @@ # ruff: noqa: S608 +import base64 +import binascii from collections.abc import Sequence +from uuid import UUID import sqlalchemy as sa from alembic import op @@ -54,6 +57,12 @@ "issuer", "client_id", ) +_SUPPORTED_PROTOCOLS = ("oidc", "saml", "ldap") +_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" +_ENVELOPE_PART_COUNT = 6 +_ENVELOPE_NONCE_BYTES = 12 +_ENVELOPE_MIN_CIPHERTEXT_BYTES = 16 +_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") _COMPAT_COLUMNS = { "id", "slug", @@ -61,6 +70,8 @@ "provider_name", "protocol", "provider", + "enabled", + "client_secret_encrypted", "provider_settings", "enforce_sso", *_PROVIDER_SETTING_COLUMNS, @@ -90,6 +101,74 @@ def _has_compatibility_schema(conn: sa.Connection) -> bool: ) +def _is_secret_envelope(value: object) -> bool: + if not isinstance(value, str): + return False + parts = value.split(":") + if len(parts) != _ENVELOPE_PART_COUNT or f"{':'.join(parts[:4])}:" != _ENVELOPE_HEADER: + return False + + decoded_payloads = [] + for encoded in parts[4:]: + if not encoded or any(character not in _BASE64URL_ALPHABET for character in encoded): + return False + try: + decoded_payloads.append( + base64.b64decode(encoded + "=" * (-len(encoded) % 4), altchars=b"-_", validate=True) + ) + except (binascii.Error, ValueError): + return False + nonce, ciphertext = decoded_payloads + return len(nonce) == _ENVELOPE_NONCE_BYTES and len(ciphertext) >= _ENVELOPE_MIN_CIPHERTEXT_BYTES + + +def _normalize_pending_n_minus_one_rows(conn: sa.Connection) -> None: + """Complete rows written through the legacy representation before this revision.""" + if not _has_compatibility_schema(conn): + return + + table = sa.table( + _CONFIG_TABLE, + sa.column("id"), + sa.column("slug", sa.String()), + sa.column("display_name", sa.String()), + sa.column("provider_name", sa.String()), + sa.column("protocol", sa.String()), + sa.column("provider", sa.String()), + sa.column("enabled", sa.Boolean()), + sa.column("client_secret_encrypted", sa.String()), + sa.column("provider_settings", sa.JSON()), + *(sa.column(name, sa.String()) for name in _PROVIDER_SETTING_COLUMNS), + ) + rows = ( + conn.execute( + sa.select(table).where( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.in_(_SUPPORTED_PROTOCOLS), + ) + ) + .mappings() + .all() + ) + for row in rows: + provider_settings = {"protocol": row["provider"]} + provider_settings.update({name: row[name] for name in _PROVIDER_SETTING_COLUMNS}) + values = { + "slug": row["slug"] or f"sso-{UUID(str(row['id'])).hex}", + "display_name": row["display_name"] or row["provider_name"], + "protocol": row["provider"], + "provider_settings": provider_settings, + } + if row["provider"] == "oidc" and not _is_secret_envelope(row["client_secret_encrypted"]): + # A pending row bypassed the typed OIDC completeness branch in 7c. + # Complete it fail-closed, and discard any legacy plaintext or + # malformed value instead of reintroducing an unusable credential. + values["enabled"] = False + values["client_secret_encrypted"] = None + conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) + + def _sqlite_provider_settings_json(prefix: str = "NEW") -> str: pairs = [f"'protocol', COALESCE({prefix}.provider, {prefix}.protocol, 'oidc')"] pairs.extend(f"'{name}', {prefix}.{name}" for name in _PROVIDER_SETTING_COLUMNS) @@ -496,6 +575,7 @@ def _convert_timestamps(conn: sa.Connection, *, timezone_aware: bool) -> None: def upgrade() -> None: conn = op.get_bind() + _normalize_pending_n_minus_one_rows(conn) _convert_timestamps(conn, timezone_aware=True) _create_compatibility_triggers(conn) diff --git a/src/backend/base/langflow/services/database/models/auth/sso.py b/src/backend/base/langflow/services/database/models/auth/sso.py index 92deb4751b9c..83cf36cc61a3 100644 --- a/src/backend/base/langflow/services/database/models/auth/sso.py +++ b/src/backend/base/langflow/services/database/models/auth/sso.py @@ -712,7 +712,8 @@ def apply_to( enabled=config.enabled, has_client_secret=config.client_secret_encrypted is not None, ) - config.updated_by = actor_id + if actor_id is not None: + config.updated_by = actor_id return config diff --git a/src/backend/base/langflow/services/database/models/auth/sso_secret.py b/src/backend/base/langflow/services/database/models/auth/sso_secret.py index 65e85cf50f9f..7d1fc411b6f8 100644 --- a/src/backend/base/langflow/services/database/models/auth/sso_secret.py +++ b/src/backend/base/langflow/services/database/models/auth/sso_secret.py @@ -52,6 +52,7 @@ _TAG_BYTES = 16 _ENVELOPE_PARTS = 6 _HEADER_PARTS = 4 +_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") class SSOSecretError(ValueError): @@ -85,6 +86,9 @@ def _encode(value: bytes) -> str: def _decode(value: str) -> bytes: + if not value or any(character not in _BASE64URL_ALPHABET for character in value): + msg = "Invalid base64url data in SSO secret envelope" + raise SSOSecretError(msg) padding = "=" * (-len(value) % 4) try: return base64.b64decode(value + padding, altchars=b"-_", validate=True) diff --git a/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py b/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py index 42b0e914c07f..763a166acc23 100644 --- a/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py +++ b/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone -from uuid import uuid4 +from uuid import UUID, uuid4 import pytest import sqlalchemy as sa @@ -217,6 +217,7 @@ def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(db_ timestamp = datetime.now(timezone.utc) user_id = str(uuid4()) + stored_user_id = UUID(user_id).hex if db_url.startswith("sqlite") else user_id config_id = str(uuid4()) original_profile_id = str(uuid4()) second_profile_id = str(uuid4()) @@ -231,7 +232,7 @@ def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(db_ connection.execute( user.insert(), { - "id": user_id, + "id": stored_user_id, "username": "sso-multi-identity-downgrade-user", "password": _TEST_PASSWORD, "is_active": True, @@ -259,7 +260,7 @@ def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(db_ sso_user_profile.insert(), _profile_values( profile_id=original_profile_id, - user_id=user_id, + user_id=stored_user_id, provider="oidc-primary", sso_user_id="subject-1", timestamp=timestamp, @@ -279,7 +280,7 @@ def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(db_ sso_user_profile.insert(), _profile_values( profile_id=second_profile_id, - user_id=user_id, + user_id=stored_user_id, provider="saml", sso_user_id="subject-2", timestamp=timestamp, @@ -288,5 +289,5 @@ def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(db_ finally: engine.dispose() - with pytest.raises(RuntimeError, match=rf"multiple identities for user_id\(s\): {user_id}"): + with pytest.raises(RuntimeError, match=rf"multiple identities for user_id\(s\): {stored_user_id}"): command.downgrade(alembic_cfg, _PRIOR_REVISION) diff --git a/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py index 6c9e3bd7c035..a632aa03e63f 100644 --- a/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py +++ b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py @@ -15,10 +15,15 @@ from .test_migration_execution import _engine_url, _make_alembic_cfg, db_url # noqa: F401 _PRIOR_REVISION = "b7d5f9a3c2e4" # pragma: allowlist secret +_INVARIANT_PRIOR_REVISION = "f0a1b2c3d4e5" # pragma: allowlist secret _HEAD_REVISION = "8d9e0f1a2b3c" # pragma: allowlist secret _TEST_ENCRYPTED_SECRET = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:AAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBB" # noqa: S105 # pragma: allowlist secret _TEST_PLAINTEXT_SECRET = "plaintext-secret" # noqa: S105 # pragma: allowlist secret +_TEST_INVALID_BASE64_ENVELOPE = ( # pragma: allowlist secret + "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:!!!!!!!!!!!!!!!!:!!!!!!!!!!!!!!!!!!!!!!" +) _TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret +_INVARIANT_MIGRATION = importlib.import_module("langflow.alembic.versions.7c8e9f0a1b2d_add_sso_config_invariant_checks") _HEAD_MIGRATION = importlib.import_module("langflow.alembic.versions.8d9e0f1a2b3c_complete_sso_expand_compatibility") @@ -67,6 +72,123 @@ def _oidc_settings(*, discovery_url: str = "https://idp.example.com/.well-known/ } +def test_sso_protocol_preflight_is_schema_aware_and_rejects_unsupported_legacy_state(db_url): # noqa: F811 + engine = sa.create_engine(_engine_url(db_url)) + try: + typed_metadata = sa.MetaData() + typed_config = sa.Table( + "sso_config", + typed_metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("protocol", sa.String()), + sa.Column("provider_settings", sa.JSON()), + ) + typed_metadata.create_all(engine) + with engine.begin() as connection: + connection.execute( + typed_config.insert(), + {"id": "typed-only", "protocol": "oidc", "provider_settings": {"protocol": "oidc"}}, + ) + _INVARIANT_MIGRATION._raise_for_protocol_mismatches( + connection, + _INVARIANT_MIGRATION._config_table(), + ) + typed_metadata.drop_all(engine) + + legacy_metadata = sa.MetaData() + legacy_config = sa.Table( + "sso_config", + legacy_metadata, + sa.Column("id", sa.String(), primary_key=True), + sa.Column("protocol", sa.String()), + sa.Column("provider", sa.String()), + sa.Column("provider_settings", sa.JSON()), + ) + legacy_metadata.create_all(engine) + with engine.begin() as connection: + connection.execute( + legacy_config.insert(), + {"id": "unsupported-legacy", "protocol": None, "provider": "custom", "provider_settings": None}, + ) + with pytest.raises(RuntimeError, match="unsupported-legacy"): + _INVARIANT_MIGRATION._raise_for_protocol_mismatches( + connection, + _INVARIANT_MIGRATION._config_table(), + ) + finally: + engine.dispose() + + +@pytest.mark.parametrize( + ("client_secret", "complete", "expected_enabled", "expected_secret"), + [ + pytest.param(None, True, False, None, id="missing-secret"), + pytest.param(_TEST_PLAINTEXT_SECRET, True, False, None, id="plaintext-secret"), + pytest.param(_TEST_INVALID_BASE64_ENVELOPE, True, False, None, id="invalid-base64-envelope"), + pytest.param( + _TEST_ENCRYPTED_SECRET, + False, + False, + _TEST_ENCRYPTED_SECRET, + id="valid-envelope-incomplete-settings", + ), + pytest.param(_TEST_ENCRYPTED_SECRET, True, True, _TEST_ENCRYPTED_SECRET, id="complete-valid"), + ], +) +def test_sso_head_normalizes_pending_n_minus_one_insert( + db_url, # noqa: F811 + client_secret, + complete, + expected_enabled, + expected_secret, +): + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _INVARIANT_PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + config_id = str(uuid4()) + expected_settings = _oidc_settings() + if not complete: + expected_settings = {**expected_settings, "client_id": None, "discovery_url": None} + + engine = sa.create_engine(_engine_url(db_url)) + try: + with engine.begin() as connection: + sso_config = sa.Table("sso_config", sa.MetaData(), autoload_with=connection) + connection.execute( + sso_config.insert(), + { + **_legacy_config_values( + config_id=config_id, + provider="oidc", + provider_name="Pending N-1 OIDC", + timestamp=timestamp, + ), + "client_secret_encrypted": client_secret, + **{key: value for key, value in expected_settings.items() if key != "protocol"}, + }, + ) + finally: + engine.dispose() + + command.upgrade(alembic_cfg, _HEAD_REVISION) + + engine = sa.create_engine(_engine_url(db_url)) + try: + with engine.connect() as connection: + sso_config = sa.Table("sso_config", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(sso_config).where(sso_config.c.id == config_id)).mappings().one() + assert row["slug"] == f"sso-{UUID(config_id).hex}" + assert row["display_name"] == "Pending N-1 OIDC" + assert row["provider"] == "oidc" + assert row["protocol"] == "oidc" + assert row["provider_settings"] == expected_settings + assert row["enabled"] is expected_enabled + assert row["client_secret_encrypted"] == expected_secret + finally: + engine.dispose() + + def test_sso_expand_keeps_n_and_n_minus_one_writes_coherent(db_url): # noqa: F811 alembic_cfg = _make_alembic_cfg(db_url) command.upgrade(alembic_cfg, _PRIOR_REVISION) diff --git a/src/backend/tests/unit/scripts/test_migrate_secret_key.py b/src/backend/tests/unit/scripts/test_migrate_secret_key.py index fe469f56c531..1e22e7412310 100644 --- a/src/backend/tests/unit/scripts/test_migrate_secret_key.py +++ b/src/backend/tests/unit/scripts/test_migrate_secret_key.py @@ -171,6 +171,25 @@ def test_sso_secret_rewrap_uses_replacement_key(self, migrate_module, old_key, n with pytest.raises(InvalidTag): migrate_module.decrypt_sso_secret_with_key(migrated, old_key) + @pytest.mark.parametrize("payload_index", [4, 5], ids=["nonce", "ciphertext"]) + @pytest.mark.parametrize("invalid_character", ["!", "+", "/"]) + def test_sso_secret_rewrap_rejects_non_base64url_payload_characters( + self, + migrate_module, + old_key, + new_key, + payload_index, + invalid_character, + ): + encrypted = migrate_module.encrypt_sso_secret_with_key("oidc-client-secret", old_key) + parts = encrypted.split(":") + parts[payload_index] = f"{invalid_character}{parts[payload_index][1:]}" + malformed_envelope = ":".join(parts) + + with pytest.raises(ValueError, match="Invalid base64url data"): + migrate_module._decode_sso_envelope(malformed_envelope) + assert migrate_module.migrate_sso_secret(malformed_envelope, old_key, new_key) is None + def test_encrypt_decrypt_with_short_keys(self, migrate_module, short_old_key): """Short keys should work for encryption/decryption.""" plaintext = "secret-value" diff --git a/src/backend/tests/unit/test_sso_models.py b/src/backend/tests/unit/test_sso_models.py index 3d47b03c0d30..3bfafa624799 100644 --- a/src/backend/tests/unit/test_sso_models.py +++ b/src/backend/tests/unit/test_sso_models.py @@ -403,6 +403,17 @@ async def test_update_schema_encrypts_secret_and_validates_merged_enabled_state( assert config.client_secret_encrypted is not None assert decrypt_sso_client_secret(config.client_secret_encrypted, sso_secret_settings) == _TEST_PLAINTEXT_SECRET + async def test_update_schema_preserves_actor_when_omitted_and_updates_explicit_actor(self): + original_actor_id = uuid4() + config = SSOConfig(display_name="Actor attribution", updated_by=original_actor_id) + + SSOConfigUpdate(display_name="No new actor").apply_to(config) + assert config.updated_by == original_actor_id + + replacement_actor_id = uuid4() + SSOConfigUpdate(display_name="Replacement actor").apply_to(config, actor_id=replacement_actor_id) + assert config.updated_by == replacement_actor_id + async def test_update_schema_atomically_converts_legacy_config_to_oidc(self, sso_secret_settings): config = SSOConfig( display_name="Legacy connection", diff --git a/src/backend/tests/unit/test_sso_secrets.py b/src/backend/tests/unit/test_sso_secrets.py index d12ed628a75f..e3193dfe7afe 100644 --- a/src/backend/tests/unit/test_sso_secrets.py +++ b/src/backend/tests/unit/test_sso_secrets.py @@ -7,11 +7,12 @@ SSOSecretError, decrypt_sso_client_secret, encrypt_sso_client_secret, + is_sso_client_secret_envelope, ) from pydantic import SecretStr _PLAINTEXT = "downstream-oidc-client-secret" -_DEFAULT_SECRET_KEY = "unit-test-langflow-secret-key-material" # noqa: S105 +_DEFAULT_SECRET_KEY = "unit-test-langflow-secret-key-material" # noqa: S105 # pragma: allowlist secret def _settings(secret_key: str | None = None): @@ -65,3 +66,28 @@ def test_sso_client_secret_rejects_unknown_envelope_version(): with pytest.raises(SSOSecretError, match="version"): decrypt_sso_client_secret(unknown_version, _settings()) + + +@pytest.mark.parametrize("invalid_character", ["!", "+", "/", "="]) +@pytest.mark.parametrize("payload_index", [4, 5], ids=["nonce", "ciphertext"]) +def test_sso_client_secret_rejects_non_base64url_payload_characters(invalid_character, payload_index): + encrypted = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + parts = encrypted.split(":") + parts[payload_index] = f"{invalid_character}{parts[payload_index][1:]}" + malformed = ":".join(parts) + + assert not is_sso_client_secret_envelope(malformed) + with pytest.raises(SSOSecretError, match="Invalid base64url data"): + decrypt_sso_client_secret(malformed, _settings()) + + +@pytest.mark.parametrize("payload_index", [4, 5], ids=["nonce", "ciphertext"]) +def test_sso_client_secret_rejects_empty_payload(payload_index): + encrypted = encrypt_sso_client_secret(_PLAINTEXT, _settings()) + parts = encrypted.split(":") + parts[payload_index] = "" + malformed = ":".join(parts) + + assert not is_sso_client_secret_envelope(malformed) + with pytest.raises(SSOSecretError, match="Invalid base64url data"): + decrypt_sso_client_secret(malformed, _settings()) diff --git a/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx b/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx index 721032031f19..7df23dcfdef0 100644 --- a/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx +++ b/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx @@ -61,6 +61,23 @@ describe("DotGridBackground", () => { expect(window.requestAnimationFrame).not.toHaveBeenCalled(); }); + it("sizes the canvas to the layout viewport", () => { + jest + .spyOn(document.documentElement, "clientWidth", "get") + .mockReturnValue(960); + jest + .spyOn(document.documentElement, "clientHeight", "get") + .mockReturnValue(540); + + const { container } = render(); + const canvas = container.querySelector("canvas"); + + expect(canvas).toHaveProperty("width", 960); + expect(canvas).toHaveProperty("height", 540); + expect(canvas).toHaveStyle({ width: "960px", height: "540px" }); + expect(context.clearRect).toHaveBeenLastCalledWith(0, 0, 960, 540); + }); + it("stops and restarts animation when reduced motion changes", () => { prefersReducedMotion = false; const { unmount } = render(); diff --git a/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx b/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx index af517f1930a3..16e3d9f7b630 100644 --- a/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx +++ b/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx @@ -29,10 +29,12 @@ export default function DotGridBackground() { const themeElement = document.getElementById("body"); let animationFrame = 0; let dots: Dot[] = []; + let viewportWidth = 0; + let viewportHeight = 0; const pointer = { x: -1000, y: -1000 }; const draw = () => { - context.clearRect(0, 0, window.innerWidth, window.innerHeight); + context.clearRect(0, 0, viewportWidth, viewportHeight); const isDark = themeElement?.classList.contains("dark") ?? colorSchemeMedia.matches; @@ -74,15 +76,17 @@ export default function DotGridBackground() { const resize = () => { const pixelRatio = window.devicePixelRatio || 1; - canvas.width = window.innerWidth * pixelRatio; - canvas.height = window.innerHeight * pixelRatio; - canvas.style.width = `${window.innerWidth}px`; - canvas.style.height = `${window.innerHeight}px`; + viewportWidth = document.documentElement.clientWidth; + viewportHeight = document.documentElement.clientHeight; + canvas.width = viewportWidth * pixelRatio; + canvas.height = viewportHeight * pixelRatio; + canvas.style.width = `${viewportWidth}px`; + canvas.style.height = `${viewportHeight}px`; context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0); dots = []; - const columns = Math.ceil(window.innerWidth / DOT_SPACING) + 1; - const rows = Math.ceil(window.innerHeight / DOT_SPACING) + 1; + const columns = Math.ceil(viewportWidth / DOT_SPACING) + 1; + const rows = Math.ceil(viewportHeight / DOT_SPACING) + 1; for (let column = 0; column < columns; column += 1) { for (let row = 0; row < rows; row += 1) {