Skip to content

Commit 00c9ce0

Browse files
authored
Merge branch 'release-1.12.0' into workflow-api-prod
2 parents 1a5aa62 + 259369e commit 00c9ce0

77 files changed

Lines changed: 7030 additions & 2351 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.secrets.baseline

Lines changed: 330 additions & 338 deletions
Large diffs are not rendered by default.

scripts/a11y/a11y_routes.json

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,11 +124,6 @@
124124
}
125125
],
126126
"gated": [
127-
{
128-
"path": "/admin",
129-
"surface": "Admin page",
130-
"currentBehavior": "Redirects to /flows for current user/session. Scan with an admin user."
131-
},
132127
{
133128
"path": "/login",
134129
"surface": "Login page",

scripts/migrate_secret_key.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
- user.store_api_key: Langflow Store API keys
1212
- variable.value: All encrypted variable values
1313
- folder.auth_settings: MCP oauth_client_secret and api_key fields
14+
- sso_config.client_secret_encrypted: SSO/OIDC client secrets
1415
1516
Usage:
1617
uv run python scripts/migrate_secret_key.py --help
@@ -20,6 +21,7 @@
2021

2122
import argparse
2223
import base64
24+
import binascii
2325
import json
2426
import os
2527
import platform
@@ -29,14 +31,27 @@
2931
from datetime import datetime, timezone
3032
from pathlib import Path
3133

34+
from cryptography.exceptions import InvalidTag
3235
from cryptography.fernet import Fernet, InvalidToken
36+
from cryptography.hazmat.primitives import hashes
37+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
38+
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
3339
from platformdirs import user_cache_dir
34-
from sqlalchemy import create_engine, text
40+
from sqlalchemy import create_engine, inspect, text
3541

3642
MINIMUM_KEY_LENGTH = 32
3743
SENSITIVE_AUTH_FIELDS = ["oauth_client_secret", "api_key"]
3844
# Must match langflow.services.variable.constants.CREDENTIAL_TYPE
3945
CREDENTIAL_TYPE = "Credential"
46+
SSO_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm"
47+
SSO_AAD = SSO_ENVELOPE_HEADER.encode()
48+
SSO_HKDF_SALT = b"langflow/sso/client-secret/hkdf-salt/v1"
49+
SSO_HKDF_INFO = b"langflow/sso/client-secret/encryption"
50+
SSO_NONCE_BYTES = 12
51+
SSO_TAG_BYTES = 16
52+
SSO_ENVELOPE_PARTS = 6
53+
SSO_HEADER_PARTS = 4
54+
SSO_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
4055

4156

4257
def get_default_config_dir() -> Path:
@@ -140,6 +155,62 @@ def migrate_value(encrypted: str, old_key: str, new_key: str) -> str | None:
140155
return None
141156

142157

158+
def _derive_sso_key(master_key: str) -> bytes:
159+
return HKDF(
160+
algorithm=hashes.SHA256(),
161+
length=32,
162+
salt=SSO_HKDF_SALT,
163+
info=SSO_HKDF_INFO,
164+
).derive(master_key.encode())
165+
166+
167+
def _decode_sso_envelope(envelope: str) -> tuple[bytes, bytes]:
168+
parts = envelope.split(":")
169+
if len(parts) != SSO_ENVELOPE_PARTS or ":".join(parts[:SSO_HEADER_PARTS]) != SSO_ENVELOPE_HEADER:
170+
msg = "Unsupported SSO client-secret envelope"
171+
raise ValueError(msg)
172+
decoded_payloads: list[bytes] = []
173+
for value in parts[4:]:
174+
if not value or any(character not in SSO_BASE64URL_ALPHABET for character in value):
175+
msg = "Invalid base64url data in SSO client-secret envelope"
176+
raise ValueError(msg)
177+
try:
178+
decoded_payloads.append(base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True))
179+
except (binascii.Error, ValueError) as exc:
180+
msg = "Invalid base64url data in SSO client-secret envelope"
181+
raise ValueError(msg) from exc
182+
nonce, ciphertext = decoded_payloads
183+
if len(nonce) != SSO_NONCE_BYTES or len(ciphertext) < SSO_TAG_BYTES:
184+
msg = "Invalid SSO client-secret envelope payload"
185+
raise ValueError(msg)
186+
return nonce, ciphertext
187+
188+
189+
def decrypt_sso_secret_with_key(envelope: str, key: str) -> str:
190+
"""Decrypt an SSO client-secret envelope with an explicit master key."""
191+
nonce, ciphertext = _decode_sso_envelope(envelope)
192+
plaintext = AESGCM(_derive_sso_key(key)).decrypt(nonce, ciphertext, SSO_AAD)
193+
return plaintext.decode()
194+
195+
196+
def encrypt_sso_secret_with_key(plaintext: str, key: str) -> str:
197+
"""Encrypt an SSO client secret using the application's current envelope format."""
198+
nonce = os.urandom(SSO_NONCE_BYTES)
199+
ciphertext = AESGCM(_derive_sso_key(key)).encrypt(nonce, plaintext.encode(), SSO_AAD)
200+
encoded_nonce = base64.urlsafe_b64encode(nonce).rstrip(b"=").decode()
201+
encoded_ciphertext = base64.urlsafe_b64encode(ciphertext).rstrip(b"=").decode()
202+
return f"{SSO_ENVELOPE_HEADER}:{encoded_nonce}:{encoded_ciphertext}"
203+
204+
205+
def migrate_sso_secret(envelope: str, old_key: str, new_key: str) -> str | None:
206+
"""Rewrap an SSO client-secret envelope under a replacement master key."""
207+
try:
208+
plaintext = decrypt_sso_secret_with_key(envelope, old_key)
209+
return encrypt_sso_secret_with_key(plaintext, new_key)
210+
except (InvalidTag, UnicodeDecodeError, ValueError):
211+
return None
212+
213+
143214
def migrate_auth_settings(auth_settings: dict, old_key: str, new_key: str) -> tuple[dict, list[str]]:
144215
"""Re-encrypt sensitive fields in auth_settings dict.
145216
@@ -208,6 +279,17 @@ def verify_migration(conn, new_key: str) -> tuple[int, int]:
208279
except (InvalidToken, json.JSONDecodeError):
209280
failed += 1
210281

282+
if inspect(conn).has_table("sso_config"):
283+
configs = conn.execute(
284+
text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL LIMIT 3")
285+
).fetchall()
286+
for _, encrypted_secret in configs:
287+
try:
288+
decrypt_sso_secret_with_key(encrypted_secret, new_key)
289+
verified += 1
290+
except (InvalidTag, UnicodeDecodeError, ValueError):
291+
failed += 1
292+
211293
return verified, failed
212294

213295

@@ -364,9 +446,38 @@ def migrate(
364446
total_migrated += migrated
365447
total_failed += failed
366448

449+
# Migrate sso_config.client_secret_encrypted when the optional SSO schema exists.
450+
print("\n4. Migrating SSO client secrets...")
451+
migrated, failed = 0, 0
452+
if inspect(conn).has_table("sso_config"):
453+
configs = conn.execute(
454+
text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL")
455+
).fetchall()
456+
for config_id, encrypted_secret in configs:
457+
new_encrypted = migrate_sso_secret(encrypted_secret, old_key, new_key)
458+
if new_encrypted:
459+
if not dry_run:
460+
conn.execute(
461+
text("UPDATE sso_config SET client_secret_encrypted = :secret WHERE id = :id"),
462+
{"secret": new_encrypted, "id": config_id},
463+
)
464+
migrated += 1
465+
else:
466+
failed += 1
467+
print(f" Warning: Could not decrypt SSO config {config_id}")
468+
print(f" {'Would migrate' if dry_run else 'Migrated'}: {migrated}, Failed: {failed}")
469+
total_migrated += migrated
470+
total_failed += failed
471+
472+
if total_failed > 0 and not dry_run:
473+
print(f"\nERROR: {total_failed} values could not be migrated.")
474+
print("Rolling back all database changes; the secret key was not changed.")
475+
conn.rollback()
476+
sys.exit(1)
477+
367478
# Verify migrated data can be decrypted with new key
368479
if total_migrated > 0:
369-
print("\n4. Verifying migration...")
480+
print("\n5. Verifying migration...")
370481
verified, verify_failed = verify_migration(conn, new_key)
371482
if verify_failed > 0:
372483
print(f" ERROR: {verify_failed} records failed verification!")
@@ -386,12 +497,12 @@ def migrate(
386497
if not dry_run:
387498
backup_file = config_dir / f"secret_key.backup.{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}"
388499
write_secret_key_to_file(config_dir, old_key, backup_file.name)
389-
print(f"\n5. Backed up old key to: {backup_file}")
500+
print(f"\n6. Backed up old key to: {backup_file}")
390501
write_secret_key_to_file(config_dir, new_key)
391-
print(f"6. Saved new secret key to: {config_dir / 'secret_key'}")
502+
print(f"7. Saved new secret key to: {config_dir / 'secret_key'}")
392503
else:
393-
print("\n5. [DRY RUN] Would backup old key")
394-
print(f"6. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}")
504+
print("\n6. [DRY RUN] Would backup old key")
505+
print(f"7. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}")
395506

396507
# Summary
397508
print("\n" + "=" * 50)

src/backend/base/langflow/alembic/env.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from sqlalchemy.exc import SAWarning
1313
from sqlalchemy.ext.asyncio import async_engine_from_config
1414

15+
from langflow.alembic.expand_compat import filter_expand_revision_directives
1516
from langflow.services.database.service import SQLModel
1617

1718
# this is the Alembic Config object, which provides
@@ -61,6 +62,7 @@ def run_migrations_offline() -> None:
6162
"literal_binds": True,
6263
"dialect_opts": {"paramstyle": "named"},
6364
"render_as_batch": True,
65+
"process_revision_directives": filter_expand_revision_directives,
6466
}
6567

6668
# Only add prepare_threshold for PostgreSQL
@@ -93,6 +95,7 @@ def _do_run_migrations(connection):
9395
"connection": connection,
9496
"target_metadata": target_metadata,
9597
"render_as_batch": True,
98+
"process_revision_directives": filter_expand_revision_directives,
9699
}
97100

98101
# Only add prepare_threshold for PostgreSQL
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""Autogenerate compatibility rules for active EXPAND migration windows."""
2+
3+
from alembic.operations import ops
4+
5+
# ``sso_config`` is deliberately in an EXPAND window: released N-1 services
6+
# still need the scalar columns, while N reads the typed JSON representation.
7+
# Alembic therefore sees the retained DB-only columns and nullable typed columns
8+
# as a future CONTRACT migration. Keep this list exact and remove it with that
9+
# contract revision; rolling-compatibility migration tests assert that the
10+
# temporary physical schema remains present and synchronized.
11+
SSO_EXPAND_LEGACY_COLUMNS = frozenset(
12+
{
13+
"provider",
14+
"provider_name",
15+
"enforce_sso",
16+
"client_id",
17+
"discovery_url",
18+
"redirect_uri",
19+
"scopes",
20+
"token_endpoint",
21+
"authorization_endpoint",
22+
"jwks_uri",
23+
"issuer",
24+
}
25+
)
26+
SSO_EXPAND_NULLABLE_COLUMNS = frozenset({"slug", "display_name", "protocol", "provider_settings"})
27+
_REMOVE_COLUMN_DIFF_LENGTH = 4
28+
_MODIFY_NULLABLE_DIFF_LENGTH = 7
29+
30+
31+
def filter_sso_expand_diffs(diffs: list) -> list:
32+
"""Suppress only schema diffs intentionally deferred to SSO CONTRACT."""
33+
significant_diffs = []
34+
for diff in diffs:
35+
# Alembic can group multiple alter-column operations in a nested list.
36+
if isinstance(diff, list):
37+
filtered_group = filter_sso_expand_diffs(diff)
38+
if filtered_group:
39+
significant_diffs.append(filtered_group)
40+
continue
41+
if not isinstance(diff, tuple):
42+
significant_diffs.append(diff)
43+
continue
44+
45+
if (
46+
len(diff) >= _REMOVE_COLUMN_DIFF_LENGTH
47+
and diff[0] == "remove_column"
48+
and diff[2] == "sso_config"
49+
and getattr(diff[3], "name", None) in SSO_EXPAND_LEGACY_COLUMNS
50+
):
51+
continue
52+
if (
53+
len(diff) >= _MODIFY_NULLABLE_DIFF_LENGTH
54+
and diff[0] == "modify_nullable"
55+
and diff[2] == "sso_config"
56+
and diff[3] in SSO_EXPAND_NULLABLE_COLUMNS
57+
and diff[5] is True
58+
and diff[6] is False
59+
):
60+
continue
61+
significant_diffs.append(diff)
62+
63+
return significant_diffs
64+
65+
66+
def _filter_sso_expand_operations(container: ops.OpContainer) -> None:
67+
filtered_operations = []
68+
for operation in container.ops:
69+
if isinstance(operation, ops.OpContainer):
70+
_filter_sso_expand_operations(operation)
71+
if operation.ops:
72+
filtered_operations.append(operation)
73+
continue
74+
75+
if (
76+
isinstance(operation, ops.DropColumnOp)
77+
and operation.table_name == "sso_config"
78+
and operation.column_name in SSO_EXPAND_LEGACY_COLUMNS
79+
):
80+
continue
81+
82+
if (
83+
isinstance(operation, ops.AlterColumnOp)
84+
and operation.table_name == "sso_config"
85+
and operation.column_name in SSO_EXPAND_NULLABLE_COLUMNS
86+
and operation.existing_nullable is True
87+
and operation.modify_nullable is False
88+
):
89+
# Preserve any type/default/comment change Alembic grouped with the
90+
# expected nullable diff so real schema drift remains visible.
91+
operation.modify_nullable = None
92+
if not operation.has_changes():
93+
continue
94+
95+
filtered_operations.append(operation)
96+
97+
container.ops[:] = filtered_operations
98+
99+
100+
def filter_expand_revision_directives(_context, _revision, directives: list[ops.MigrationScript]) -> None:
101+
"""Apply active EXPAND allowlists to Alembic autogenerate/check output."""
102+
for directive in directives:
103+
for upgrade_ops, downgrade_ops in zip(
104+
directive.upgrade_ops_list,
105+
directive.downgrade_ops_list,
106+
strict=True,
107+
):
108+
_filter_sso_expand_operations(upgrade_ops)
109+
upgrade_ops.reverse_into(downgrade_ops)

0 commit comments

Comments
 (0)