diff --git a/.secrets.baseline b/.secrets.baseline index ae62ee7026f8..1132d9343a10 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -2265,7 +2265,7 @@ "filename": "src/backend/tests/unit/test_auth_settings.py", "hashed_secret": "0352a8acc949c7df21fec16e566ba9a74e797a97", "is_verified": false, - "line_number": 91, + "line_number": 97, "is_secret": false }, { @@ -2273,7 +2273,7 @@ "filename": "src/backend/tests/unit/test_auth_settings.py", "hashed_secret": "920f8f5815b381ea692e9e7c2f7119f2b1aa620a", "is_verified": false, - "line_number": 103, + "line_number": 109, "is_secret": false }, { @@ -2281,7 +2281,7 @@ "filename": "src/backend/tests/unit/test_auth_settings.py", "hashed_secret": "81f344a7686a80b4c5293e8fdc0b0160c82c06a8", "is_verified": false, - "line_number": 109, + "line_number": 115, "is_secret": false } ], @@ -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 } ], @@ -2619,7 +2619,7 @@ "filename": "src/frontend/src/constants/constants.ts", "hashed_secret": "19a2fbd0dd38b4097f419c962342ef5e109eab07", "is_verified": false, - "line_number": 767, + "line_number": 752, "is_secret": false }, { @@ -2627,7 +2627,7 @@ "filename": "src/frontend/src/constants/constants.ts", "hashed_secret": "3806954324550e26ef5de85d007f1746825a073c", "is_verified": false, - "line_number": 768, + "line_number": 753, "is_secret": false }, { @@ -2635,7 +2635,7 @@ "filename": "src/frontend/src/constants/constants.ts", "hashed_secret": "c04f8fbf55c9096907a982750b1c6b0e4c1dd658", "is_verified": false, - "line_number": 962, + "line_number": 947, "is_secret": false } ], @@ -2805,57 +2805,41 @@ { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "92dfbfc0dd3ae5856bd57631e9c0ea7dd6245711", - "is_verified": false, - "line_number": 37, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "4d67e279837e369ee293a323066ef4a05f30ef40", - "is_verified": false, - "line_number": 38, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "834162a9d4e12ab3d512527330293204a0b63d90", + "hashed_secret": "696e6809b4c7a037e3bfeea796bfbf1e89ce5fbd", "is_verified": false, - "line_number": 48, + "line_number": 97, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "97f7ca718a0013593c7a0a914474182107af35d0", + "hashed_secret": "92dfbfc0dd3ae5856bd57631e9c0ea7dd6245711", "is_verified": false, - "line_number": 49, + "line_number": 214, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "a6b7fa3036809eeab8bb104d9c93e8acff5ea081", + "hashed_secret": "071241279b1ee9edc0e265315fba2833f3c6e1d9", "is_verified": false, - "line_number": 50, + "line_number": 215, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "696e6809b4c7a037e3bfeea796bfbf1e89ce5fbd", + "hashed_secret": "4d67e279837e369ee293a323066ef4a05f30ef40", "is_verified": false, - "line_number": 128, + "line_number": 217, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", - "hashed_secret": "071241279b1ee9edc0e265315fba2833f3c6e1d9", + "hashed_secret": "97f7ca718a0013593c7a0a914474182107af35d0", "is_verified": false, - "line_number": 246, + "line_number": 221, "is_secret": false }, { @@ -2863,7 +2847,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "40a6165fa766809c4d5cc31c9d69bf592b2e930e", "is_verified": false, - "line_number": 253, + "line_number": 222, "is_secret": false }, { @@ -2871,7 +2855,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "029a9cd4161ece9fcf5eda09f07874d228daa9e6", "is_verified": false, - "line_number": 255, + "line_number": 224, "is_secret": false }, { @@ -2879,7 +2863,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e71719056a00aeb51887b49912f107d7075e3953", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -2887,7 +2871,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "a24f75c63a48219c05df0f39831e66aa1faa6189", "is_verified": false, - "line_number": 276, + "line_number": 246, "is_secret": false }, { @@ -2895,7 +2879,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d66dc430b7aff7717100701ed8a112a9601384ed", "is_verified": false, - "line_number": 285, + "line_number": 255, "is_secret": false }, { @@ -2903,7 +2887,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "94173eade3ec4ad38c084c42138773fa4220afcf", "is_verified": false, - "line_number": 286, + "line_number": 256, "is_secret": false }, { @@ -2911,7 +2895,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "eea025da01ac30af81911443bd4e7e6bc02dd458", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -2919,7 +2903,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cdd92c9d2578e0cdc2e57591425298e0d2ecaaed", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -2927,7 +2911,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02d9eee31941ef494b8b63c50484720e21e9a92b", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -2935,7 +2919,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e7cc230ba82f53830501c9e07f40554ee59596d3", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -2943,7 +2927,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "33401d90fa17dd65aeb2468f398353281be85596", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -2951,7 +2935,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e686334c3bec530179fc0d6f81b75005b8a3541f", "is_verified": false, - "line_number": 667, + "line_number": 637, "is_secret": false }, { @@ -2959,7 +2943,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7981d45b19cca6f0be5538f9864663e7685bf052", "is_verified": false, - "line_number": 683, + "line_number": 653, "is_secret": false }, { @@ -2967,7 +2951,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cfd32aee63f6617c1aa367ff901f9f16abc25ccc", "is_verified": false, - "line_number": 711, + "line_number": 681, "is_secret": false }, { @@ -2975,7 +2959,15 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7f9b4ce8fafe27fd7f28e30503ff90be2c51f6f1", "is_verified": false, - "line_number": 718, + "line_number": 688, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/frontend/src/locales/de.json", + "hashed_secret": "a6b7fa3036809eeab8bb104d9c93e8acff5ea081", + "is_verified": false, + "line_number": 696, "is_secret": false }, { @@ -2983,14 +2975,14 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "1233410303742c5b32ae1e69c45ea8cbe59433d4", "is_verified": false, - "line_number": 727 + "line_number": 697 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02eed8625057933776c899e6e60175a552231af3", "is_verified": false, - "line_number": 734, + "line_number": 704, "is_secret": false }, { @@ -2998,7 +2990,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "5781fbe4c19d47db72e814fe889cab2ee138b254", "is_verified": false, - "line_number": 1000, + "line_number": 970, "is_secret": false }, { @@ -3006,7 +2998,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "0098f0e36ee02376e287e99d76328b76cbf395b8", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -3014,7 +3006,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "85accf06c43563c063d399baf5e1d4dbc2bd69b0", "is_verified": false, - "line_number": 1382, + "line_number": 1352, "is_secret": false }, { @@ -3022,7 +3014,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "7af90a388f3f51da50bd0ccfc074ac83a5e77abf", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -3030,7 +3022,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e116d3fdaa720e8822922071931a7da18b34ddd3", "is_verified": false, - "line_number": 1582, + "line_number": 1552, "is_secret": false }, { @@ -3038,7 +3030,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "06e10503631d5b1680e4a08e3a5e0ab109812c48", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -3046,7 +3038,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "de70a102ac9fe37a0714c25453e50afb4f078359", "is_verified": false, - "line_number": 1753, + "line_number": 1721, "is_secret": false }, { @@ -3054,21 +3046,21 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "16bce207c70bb100ece74b75c4c4a79e262cfd5c", "is_verified": false, - "line_number": 1755 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "42174448e882bf43f51c70d0ed3f57d377bb74bb", "is_verified": false, - "line_number": 1756 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/de.json", "hashed_secret": "da5b4695b6132090955c121e932d6f9b833c0b1d", "is_verified": false, - "line_number": 1817, + "line_number": 1785, "is_secret": false }, { @@ -3076,7 +3068,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d4f39052ccfaf9ef5b8fd360ed75d23cf12fa2c4", "is_verified": false, - "line_number": 1818, + "line_number": 1786, "is_secret": false }, { @@ -3084,7 +3076,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "bfc0c1d4d70399476ea81e40a48418ed9962832d", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -3092,7 +3084,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "2a24f50d36559883d2bc391e4e99f9a7afd56ef9", "is_verified": false, - "line_number": 1841, + "line_number": 1809, "is_secret": false }, { @@ -3100,7 +3092,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fd0543de99a00bd6e67f3e522a18ed417089b6b4", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -3108,7 +3100,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "fa4b98297401af5e6ab7a402330a12715e891325", "is_verified": false, - "line_number": 1989, + "line_number": 1957, "is_secret": false }, { @@ -3116,7 +3108,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9c46c22e7a8ff423706b990fc02c85e0d324f134", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -3124,7 +3116,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "91cde4236a3754b4b57733682edfc50571d954c6", "is_verified": false, - "line_number": 2004, + "line_number": 1972, "is_secret": false }, { @@ -3132,7 +3124,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "9625c6b66710b648d27ee284940849a6b5abf3f0", "is_verified": false, - "line_number": 2007, + "line_number": 1975, "is_secret": false }, { @@ -3140,7 +3132,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "370c53187d8e550db85edabdcb86547dab96e300", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -3148,7 +3140,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc57a2140542893b4c9396b6a7186b26351c6fe5", "is_verified": false, - "line_number": 2021, + "line_number": 1989, "is_secret": false }, { @@ -3156,7 +3148,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "d922d16c3bbc818b6fab4e8d2662fa361309fa9d", "is_verified": false, - "line_number": 2236, + "line_number": 2204, "is_secret": false }, { @@ -3164,7 +3156,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "8ea601e5cac549eef50d2743f915e4af54135789", "is_verified": false, - "line_number": 2237, + "line_number": 2205, "is_secret": false }, { @@ -3172,7 +3164,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "e498ebe977c948f4d1b7eddd6bb77be398ed4d7c", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -3180,7 +3172,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "02e680473cb1ee7473e2cdb5b0b6c48cd8aae081", "is_verified": false, - "line_number": 2244, + "line_number": 2212, "is_secret": false }, { @@ -3188,7 +3180,7 @@ "filename": "src/frontend/src/locales/de.json", "hashed_secret": "cc50b2aac5d886516a06afb79b4931f4ee66422d", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -3262,7 +3254,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "19a2fbd0dd38b4097f419c962342ef5e109eab07", "is_verified": false, - "line_number": 186, + "line_number": 184, "is_secret": false }, { @@ -3270,7 +3262,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "cb88e78a13f07467c3e44000869553e709ecd44f", "is_verified": false, - "line_number": 187, + "line_number": 185, "is_secret": false }, { @@ -3278,7 +3270,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "c04f8fbf55c9096907a982750b1c6b0e4c1dd658", "is_verified": false, - "line_number": 234, + "line_number": 232, "is_secret": false }, { @@ -3286,7 +3278,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8be3c943b1609fffbfc51aad666d0a04adf83c9d", "is_verified": false, - "line_number": 265, + "line_number": 263, "is_secret": false }, { @@ -3294,7 +3286,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "29333d2356d5dd3aafb45a8614c6d825b6f58424", "is_verified": false, - "line_number": 267, + "line_number": 265, "is_secret": false }, { @@ -3302,7 +3294,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "198b73ffd1a6d9c83101382c6df255edac0d3625", "is_verified": false, - "line_number": 273, + "line_number": 272, "is_secret": false }, { @@ -3310,7 +3302,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "51d69ae6046d2a3be245449d5b38165d5d78def5", "is_verified": false, - "line_number": 275, + "line_number": 274, "is_secret": false }, { @@ -3318,7 +3310,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2e19560960addfd442570c5b2830afc1115cd3f0", "is_verified": false, - "line_number": 277, + "line_number": 276, "is_secret": false }, { @@ -3326,7 +3318,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "ad0cb64bb04fb2dd4e7072fca59f7f23eb025df9", "is_verified": false, - "line_number": 283, + "line_number": 282, "is_secret": false }, { @@ -3334,7 +3326,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "c2d404cb7bad34de0af2136b8efa809ea96170fa", "is_verified": false, - "line_number": 285, + "line_number": 284, "is_secret": false }, { @@ -3342,29 +3334,21 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "4d5967896006c8a626ecef45e6312f6fc7d5b52f", "is_verified": false, - "line_number": 288 + "line_number": 287 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2a2f556b3e73e091f1e21b95ae5bbca523ab1a2a", "is_verified": false, - "line_number": 289 + "line_number": 288 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/en.json", "hashed_secret": "1abaf3f0bb2d459a0fdefe08084901710603a6be", "is_verified": false, - "line_number": 301, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/en.json", - "hashed_secret": "4a7c565d4c4430e3bb8fa6c560125d5eb37e7c3d", - "is_verified": false, - "line_number": 398, + "line_number": 300, "is_secret": false }, { @@ -3372,7 +3356,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "47acd2028cf81b5da88ddeedb2aea4eca4b71fbd", "is_verified": false, - "line_number": 462, + "line_number": 431, "is_secret": false }, { @@ -3380,7 +3364,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "c23d0f85d66cef95852d6a63811ab919de76b02a", "is_verified": false, - "line_number": 494, + "line_number": 463, "is_secret": false }, { @@ -3388,7 +3372,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "76b33d23eee6aaa96f11abb4cbf1e5abd66aa46d", "is_verified": false, - "line_number": 599, + "line_number": 567, "is_secret": false }, { @@ -3396,7 +3380,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e9013532fd4966ed4b3aab1ae711b21f180e4c26", "is_verified": false, - "line_number": 600, + "line_number": 568, "is_secret": false }, { @@ -3404,7 +3388,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f16563f8a1751c141fd1c47148369ca1c380bb1", "is_verified": false, - "line_number": 639, + "line_number": 607, "is_secret": false }, { @@ -3412,7 +3396,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "2c4e1babc2d1448dfcfdd24fb92068644227d6f1", "is_verified": false, - "line_number": 732, + "line_number": 700, "is_secret": false }, { @@ -3420,7 +3404,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "7ce9180d54b399cafbe6515ca2ca4710a6b9554c", "is_verified": false, - "line_number": 738, + "line_number": 706, "is_secret": false }, { @@ -3428,7 +3412,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "924cf2c6cefbba9e88dc676bd2575b7f21f9661e", "is_verified": false, - "line_number": 745, + "line_number": 713, "is_secret": false }, { @@ -3436,7 +3420,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "9d6bd29f94e1e565bd5de8f44c875638b26e85b6", "is_verified": false, - "line_number": 746, + "line_number": 714, "is_secret": false }, { @@ -3444,7 +3428,15 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8e77e1ff96d9be727f25fc393a48f767935660db", "is_verified": false, - "line_number": 1253, + "line_number": 1221, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/frontend/src/locales/en.json", + "hashed_secret": "4a7c565d4c4430e3bb8fa6c560125d5eb37e7c3d", + "is_verified": false, + "line_number": 1448, "is_secret": false }, { @@ -3452,7 +3444,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "29538b53771fff5bad78e3a48a3a8f88c2f141d2", "is_verified": false, - "line_number": 1560, + "line_number": 1528, "is_secret": false }, { @@ -3460,7 +3452,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8d24e5afd2e9b40d21e4300c893a486aab979795", "is_verified": false, - "line_number": 1561, + "line_number": 1529, "is_secret": false }, { @@ -3468,7 +3460,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "ed05045c1666dd556d9a09f9eb6889f42a81aa5a", "is_verified": false, - "line_number": 1657, + "line_number": 1625, "is_secret": false }, { @@ -3476,7 +3468,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8f616a4d4abc959d505a5b83f1b3fa1a6bce6b2e", "is_verified": false, - "line_number": 1659, + "line_number": 1627, "is_secret": false }, { @@ -3484,7 +3476,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "1bbb8fd38e8fef4280c7218b961ae2ceeb83bbc9", "is_verified": false, - "line_number": 1660, + "line_number": 1628, "is_secret": false }, { @@ -3492,7 +3484,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "0b8eacdde75bb24c6f29adf660f50ca4d9846358", "is_verified": false, - "line_number": 1662, + "line_number": 1630, "is_secret": false }, { @@ -3500,7 +3492,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "8cde7462c1ce55676dc45124068d844ad3b86dcf", "is_verified": false, - "line_number": 1683, + "line_number": 1651, "is_secret": false }, { @@ -3508,7 +3500,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "afbf9aed8c22c3faf4d92dccadabe324d49bc5a8", "is_verified": false, - "line_number": 1914, + "line_number": 1882, "is_secret": false }, { @@ -3516,7 +3508,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "651f66f27bbfaed040c836e22515162e20c4fa0d", "is_verified": false, - "line_number": 1928, + "line_number": 1896, "is_secret": false }, { @@ -3524,7 +3516,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "e38ef0e653c39ce1a4dde13da87333c43ced6cab", "is_verified": false, - "line_number": 2013, + "line_number": 1981, "is_secret": false }, { @@ -3532,7 +3524,7 @@ "filename": "src/frontend/src/locales/en.json", "hashed_secret": "a3e4ec7cb1a57129b074bdd0b9403a008a7f0019", "is_verified": false, - "line_number": 2031, + "line_number": 1999, "is_secret": false } ], @@ -3542,7 +3534,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "1cf31e02bf7c19d36f7fd85ca673beb6ab075457", "is_verified": false, - "line_number": 37, + "line_number": 214, "is_secret": false }, { @@ -3550,15 +3542,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "fce2533c38ef22f06740a61818d9f399856b45ad", "is_verified": false, - "line_number": 38, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/es.json", - "hashed_secret": "5a6d1c612954979ea99ee33dbb2d231b00f6ac0a", - "is_verified": false, - "line_number": 48, + "line_number": 215, "is_secret": false }, { @@ -3566,15 +3550,15 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6776e8e8ad87a11f849d602165885c18b7ffd9c2", "is_verified": false, - "line_number": 49, + "line_number": 221, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/es.json", - "hashed_secret": "d4c5cdf17c6b6a46dd939ad3f9304e726e60384f", + "hashed_secret": "5a6d1c612954979ea99ee33dbb2d231b00f6ac0a", "is_verified": false, - "line_number": 50, + "line_number": 222, "is_secret": false }, { @@ -3582,7 +3566,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "e0bb5d3509b1c0a528192ba194fd358e7cf68aa1", "is_verified": false, - "line_number": 255, + "line_number": 224, "is_secret": false }, { @@ -3590,7 +3574,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "641e0eab068bccd95b4b55a4c361e4115c418a5f", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -3598,7 +3582,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "fc4cb17d803c31094c9d646d708e73a8f618f1f3", "is_verified": false, - "line_number": 276, + "line_number": 246, "is_secret": false }, { @@ -3606,7 +3590,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "cd21bce173804809539fac60a9168e184f12b284", "is_verified": false, - "line_number": 285, + "line_number": 255, "is_secret": false }, { @@ -3614,7 +3598,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c37c841b6c7b009dad7db06b8d78c5a60e231dce", "is_verified": false, - "line_number": 286, + "line_number": 256, "is_secret": false }, { @@ -3622,7 +3606,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "13a8cca2ca0db558c6a15f4d856d5137f2498f20", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -3630,7 +3614,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "5457a352b60f70e37ab5066d7cadbbe5e0271ef1", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -3638,7 +3622,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "82842c3c911be2b0d80d3c5f8d94b52ac2413de9", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -3646,7 +3630,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "f5503a4b8c5a1977fffa2f0e95d877eb7c7c4ee6", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -3654,7 +3638,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "192b20f92ccd805d527886bed6301b7b371fe348", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -3662,7 +3646,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "765294f5f2dd1748dbd78e9b59afc3a1787697a2", "is_verified": false, - "line_number": 667, + "line_number": 637, "is_secret": false }, { @@ -3670,7 +3654,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "d469771eafcb03655d11eed0f3e8811f2c721d02", "is_verified": false, - "line_number": 683, + "line_number": 653, "is_secret": false }, { @@ -3678,7 +3662,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "9b230eabf26be3b9d702c89641b984f98d206061", "is_verified": false, - "line_number": 711, + "line_number": 681, "is_secret": false }, { @@ -3686,7 +3670,15 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "dbfcc263a0fdacc0d688abea5294c8c4968f6dcb", "is_verified": false, - "line_number": 718, + "line_number": 688, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/frontend/src/locales/es.json", + "hashed_secret": "d4c5cdf17c6b6a46dd939ad3f9304e726e60384f", + "is_verified": false, + "line_number": 696, "is_secret": false }, { @@ -3694,7 +3686,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "701b1c64da678bb0b94154c2027363e6b7a7909b", "is_verified": false, - "line_number": 727, + "line_number": 697, "is_secret": false }, { @@ -3702,7 +3694,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "8014424f8694a36a30603e437661446e2eb8c299", "is_verified": false, - "line_number": 734, + "line_number": 704, "is_secret": false }, { @@ -3710,7 +3702,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c2051bb5c40f7970a7dfe6cc779623aab444c55e", "is_verified": false, - "line_number": 1000, + "line_number": 970, "is_secret": false }, { @@ -3718,7 +3710,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "8986077949f64e6ae0027b081075699ce824e0dd", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -3726,7 +3718,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "96457c5a9db1de39b24d9043cdbe6360f2b19f86", "is_verified": false, - "line_number": 1382, + "line_number": 1352, "is_secret": false }, { @@ -3734,7 +3726,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "0f89cb19175c596fc2ae829f5c09d4d04512cc0d", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -3742,7 +3734,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bd8021713f01b31ab9cb3aa0a88e9a88c6531598", "is_verified": false, - "line_number": 1582, + "line_number": 1552, "is_secret": false }, { @@ -3750,7 +3742,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "d35e35ce246c917e0d30a073363f12eb6b412d5e", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -3758,21 +3750,21 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6848bf99f0e8cbaa9e3afe15724b0592761c9895", "is_verified": false, - "line_number": 1755 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/es.json", "hashed_secret": "71e1d05cafdbe483092571dc8fa209cb4fb602d2", "is_verified": false, - "line_number": 1756 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bd2d890bd01b9843827f1583db8f9ee99564f270", "is_verified": false, - "line_number": 1817, + "line_number": 1785, "is_secret": false }, { @@ -3780,7 +3772,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "b245ddc4e5dfcb57cba8d08000e59c5dc0809ceb", "is_verified": false, - "line_number": 1818, + "line_number": 1786, "is_secret": false }, { @@ -3788,7 +3780,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "443fd1115c9bd30afaac31a42fc192b86476f288", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -3796,7 +3788,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "5c324bbf8f0a15d640a2ca93b4bb0183d106edd8", "is_verified": false, - "line_number": 1841, + "line_number": 1809, "is_secret": false }, { @@ -3804,7 +3796,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "49d8b438094e36554d1d2ec73dbc4adbdca1a891", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -3812,7 +3804,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "bdd17febdd742a928650952ec63b0ab0d7eeddac", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -3820,7 +3812,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "15e4f8d5f1d2759b1e83f9082a8ce8e194eb5600", "is_verified": false, - "line_number": 2004, + "line_number": 1972, "is_secret": false }, { @@ -3828,7 +3820,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "c8700cb4716f270e7c37191104366e7b1cadc9de", "is_verified": false, - "line_number": 2007, + "line_number": 1975, "is_secret": false }, { @@ -3836,7 +3828,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "60b0610326aa371e079775047f4e66c77aabb0a2", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -3844,7 +3836,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "0147f29ee8d8343d8d70f94b71b0053b268461e3", "is_verified": false, - "line_number": 2021, + "line_number": 1989, "is_secret": false }, { @@ -3852,7 +3844,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "3c89182cf4fc330080cfb2352105827a904d5691", "is_verified": false, - "line_number": 2236, + "line_number": 2204, "is_secret": false }, { @@ -3860,7 +3852,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "998c11b06dfd04593f289507da2b499cf4bca6e8", "is_verified": false, - "line_number": 2237, + "line_number": 2205, "is_secret": false }, { @@ -3868,7 +3860,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "ccaa726ee57e4ccdab0a28886273feee26d647ec", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -3876,7 +3868,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "2e329e7c25daa340c17b719f5371248f691de123", "is_verified": false, - "line_number": 2244, + "line_number": 2212, "is_secret": false }, { @@ -3884,7 +3876,7 @@ "filename": "src/frontend/src/locales/es.json", "hashed_secret": "6e09157ed6a7516c860d26d0747fb229badf190a", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -3892,57 +3884,49 @@ { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "68c76b53ec9073f0ce32372e2ed32f8f77af301e", + "hashed_secret": "8972f0dab3b53b35a68dd4bb860647825d2fd366", "is_verified": false, - "line_number": 37, + "line_number": 97, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "75a674a3626e87c539a2518afb33931a57598116", + "hashed_secret": "68c76b53ec9073f0ce32372e2ed32f8f77af301e", "is_verified": false, - "line_number": 38, + "line_number": 214, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "94e2f3ee14b92e2e675a8f6118d28738dbf5c6ba", + "hashed_secret": "f4cde692d8841bc82500a858c0b039b87d5aac25", "is_verified": false, - "line_number": 48, + "line_number": 215, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "394f48d2520c7e41c4e4913d77d03a84fb6cc356", + "hashed_secret": "75a674a3626e87c539a2518afb33931a57598116", "is_verified": false, - "line_number": 49, + "line_number": 217, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "c1a3d196da57a31bc8f209f520d7b1453bf18313", + "hashed_secret": "394f48d2520c7e41c4e4913d77d03a84fb6cc356", "is_verified": false, - "line_number": 50, + "line_number": 221, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "8972f0dab3b53b35a68dd4bb860647825d2fd366", + "hashed_secret": "94e2f3ee14b92e2e675a8f6118d28738dbf5c6ba", "is_verified": false, - "line_number": 128, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/fr.json", - "hashed_secret": "f4cde692d8841bc82500a858c0b039b87d5aac25", - "is_verified": false, - "line_number": 246, + "line_number": 222, "is_secret": false }, { @@ -3950,7 +3934,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "bd03c54cd14fd738c7cea7add3ae43058b612392", "is_verified": false, - "line_number": 255, + "line_number": 224, "is_secret": false }, { @@ -3958,7 +3942,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "6a4a9f5fd35aba4316eae8fecd22b6520130d3bb", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -3966,7 +3950,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "45c1ab5429738bffb961d2445c838901298f97cc", "is_verified": false, - "line_number": 285, + "line_number": 255, "is_secret": false }, { @@ -3974,7 +3958,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "af0189df8c27e3378d3692a62ce4e75985a787e5", "is_verified": false, - "line_number": 286, + "line_number": 256, "is_secret": false }, { @@ -3982,7 +3966,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "a51009a48a2d62cd6a67ebf1989fa680dc4506be", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -3990,7 +3974,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d40974ec3d7496c834d5cfd3287e73336dcf7bf2", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -3998,7 +3982,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "5146a90f97b0950b25e7ce9fb097fefa5a3d6817", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -4006,7 +3990,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7af1b1a9f5075623b9535583703bef93d789dfaf", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -4014,7 +3998,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "cb3228774ef442aa34e93da66e3c2ebbac09a3b6", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -4022,7 +4006,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d3a999088e8965ab9ce5c092d636171c69665666", "is_verified": false, - "line_number": 667, + "line_number": 637, "is_secret": false }, { @@ -4030,7 +4014,15 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "8e55bc411656a3791cd177161db4595e24e459ee", "is_verified": false, - "line_number": 683, + "line_number": 653, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/frontend/src/locales/fr.json", + "hashed_secret": "c1a3d196da57a31bc8f209f520d7b1453bf18313", + "is_verified": false, + "line_number": 696, "is_secret": false }, { @@ -4038,7 +4030,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d2c76f41b31e908dc84bc33fa1a3f210f70cf49a", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -4046,7 +4038,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "d3f815b74ab709a72cf9cdce437053fa0dce6287", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -4054,7 +4046,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7f49bb36f7d78041a9c8e79fdead35277249f701", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -4062,7 +4054,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "88362d0d044cf4144e78f646e101110e3f44a9e5", "is_verified": false, - "line_number": 1753, + "line_number": 1721, "is_secret": false }, { @@ -4070,21 +4062,21 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "f924297673a9a6a8f9362349954b1e5273549e28", "is_verified": false, - "line_number": 1755 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "649624ecf03baa824fd2f66c653ef47a10159656", "is_verified": false, - "line_number": 1756 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "89400786d11d1ee5d936d6cd1890e99e4e3aa54b", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -4092,7 +4084,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "b1df0ac48f734b370fc58c591ef0772266f88bb4", "is_verified": false, - "line_number": 1841, + "line_number": 1809, "is_secret": false }, { @@ -4100,7 +4092,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "541b6268f8abceae80c498212280ee7136eba255", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -4108,7 +4100,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "ff959bbe348ba1b8176852cd2e3d6ba8dbf4062d", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -4116,7 +4108,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "73f311b6a3b5bb2b45eccad848db62d85ae37b58", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -4124,7 +4116,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "7294ab5b6d4cf3ea2deab49cecb0a7baacd16447", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -4132,7 +4124,7 @@ "filename": "src/frontend/src/locales/fr.json", "hashed_secret": "8dfdd0969287ef24a795d71e17f53f148d37d766", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -4142,7 +4134,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "2177120edbf5e594d72fdcb9b8fe2183adfc1782", "is_verified": false, - "line_number": 128, + "line_number": 97, "is_secret": false }, { @@ -4150,7 +4142,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "ad309c2e61dfa1a5c0dc9896da7fd830af70b8f6", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -4158,7 +4150,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "20cff9da46719805d7fe563353ad67624227b054", "is_verified": false, - "line_number": 276, + "line_number": 246, "is_secret": false }, { @@ -4166,7 +4158,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "72eaf58c3aa4f501f65b26053d3802e3bd7d5620", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -4174,7 +4166,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "3199448fd2effa51a31410243be3e2aa21843521", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -4182,7 +4174,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "712dfa64243423e83279b034519831d3d43f1222", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -4190,7 +4182,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "6d3f6918ae280307281aca3f1dc9489d44565214", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -4198,7 +4190,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "4479d5f53df4c7c7bf5e956086be99f06a1de389", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -4206,7 +4198,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "f94bedb507c27a67d2641721479db8680ce9388e", "is_verified": false, - "line_number": 683, + "line_number": 653, "is_secret": false }, { @@ -4214,7 +4206,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "6dbe389b0fad2a6c97e803c3b6b5a2d8d22522b5", "is_verified": false, - "line_number": 711, + "line_number": 681, "is_secret": false }, { @@ -4222,7 +4214,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "8ce3126ccfb3229f34fe26a2d087ec4e4e159b9d", "is_verified": false, - "line_number": 718, + "line_number": 688, "is_secret": false }, { @@ -4230,7 +4222,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "de6f8e3f2efe3d1041ea12f5151130da05447df8", "is_verified": false, - "line_number": 734, + "line_number": 704, "is_secret": false }, { @@ -4238,7 +4230,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "dcfb876c95540f212731659ce9603d9741a763cc", "is_verified": false, - "line_number": 1000, + "line_number": 970, "is_secret": false }, { @@ -4246,7 +4238,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "cd5eda56c4e5deaeeb92d6154d05c3dc9264553f", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -4254,7 +4246,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "d4d29807d5f0a184bd28b2dad2a034da1970042a", "is_verified": false, - "line_number": 1382, + "line_number": 1352, "is_secret": false }, { @@ -4262,7 +4254,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "3cf78799d5f2ac03456fc62edd2ad65120b64f05", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -4270,7 +4262,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "fcbab870c3fac2547fc10dfe4d134111aabc18b8", "is_verified": false, - "line_number": 1582, + "line_number": 1552, "is_secret": false }, { @@ -4278,7 +4270,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "d46f0990d79bb487d60ff3a855f6916f3e4e6a1a", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -4286,7 +4278,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "c0428438c20615c80570a0bd88158db6a5cc6003", "is_verified": false, - "line_number": 1817, + "line_number": 1785, "is_secret": false }, { @@ -4294,7 +4286,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "c22e6661861b4203426d47e3a1a50363c09c812e", "is_verified": false, - "line_number": 1818, + "line_number": 1786, "is_secret": false }, { @@ -4302,7 +4294,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b360f8c2eb4e5aeab3a63c711ffe95b4fa24afde", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -4310,7 +4302,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b6416594e1de4cfc0351c6562ab936519130f5b2", "is_verified": false, - "line_number": 1828, + "line_number": 1796, "is_secret": false }, { @@ -4318,7 +4310,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b7cd1a2885e8998336c89a87d4df110a6b66e2f2", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -4326,7 +4318,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "5e8bcadfaab440649c70e43a015503e9109a7bfa", "is_verified": false, - "line_number": 1989, + "line_number": 1957, "is_secret": false }, { @@ -4334,7 +4326,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "44ed46c9ee10a3a7308c6d1a5df4cb082c57b9e0", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -4342,7 +4334,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "20e6aa34a3dd984dd602c294ee85714b53a0a872", "is_verified": false, - "line_number": 2004, + "line_number": 1972, "is_secret": false }, { @@ -4350,7 +4342,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b5426a913c9da60eff621ee15e0b195bf6aaa8da", "is_verified": false, - "line_number": 2007, + "line_number": 1975, "is_secret": false }, { @@ -4358,7 +4350,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "415c03a82cd5f390ad3e28dd82c88e36fb540548", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -4366,7 +4358,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "9e7ba6c71dc246eee615e88e31861a563e33b42d", "is_verified": false, - "line_number": 2021, + "line_number": 1989, "is_secret": false }, { @@ -4374,7 +4366,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "18a887dd97555ab726e7f4d4d999dedd236763eb", "is_verified": false, - "line_number": 2236, + "line_number": 2204, "is_secret": false }, { @@ -4382,7 +4374,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "e4777a4b7b9c6fd1911762e35405c0b6b80ae735", "is_verified": false, - "line_number": 2237, + "line_number": 2205, "is_secret": false }, { @@ -4390,7 +4382,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "8847d054d32b57b7a5633fed58444d7237209e24", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -4398,7 +4390,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "369cb27892790b429dacabb4ee12cdfd63d6c250", "is_verified": false, - "line_number": 2244, + "line_number": 2212, "is_secret": false }, { @@ -4406,7 +4398,7 @@ "filename": "src/frontend/src/locales/ja.json", "hashed_secret": "b9743a370a7e12046cbb762dda96137a0327fc28", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -4414,25 +4406,25 @@ { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", - "hashed_secret": "9c469e53fd34bd270037c7e3309345e0383f7331", + "hashed_secret": "88f195fbbbbeb256d8efac5b6b20e24d319725ee", "is_verified": false, - "line_number": 37, + "line_number": 97, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", - "hashed_secret": "6d9129298ef5058bb568a75df1daa3171d2f6233", + "hashed_secret": "9c469e53fd34bd270037c7e3309345e0383f7331", "is_verified": false, - "line_number": 38, + "line_number": 214, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", - "hashed_secret": "deba0172511d5701d964202f4e5de698d5e07c67", + "hashed_secret": "6d9129298ef5058bb568a75df1daa3171d2f6233", "is_verified": false, - "line_number": 48, + "line_number": 215, "is_secret": false }, { @@ -4440,23 +4432,15 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "38a814286845e0bf7c437e2f0238e6aa127916b9", "is_verified": false, - "line_number": 49, - "is_secret": false - }, - { - "type": "Secret Keyword", - "filename": "src/frontend/src/locales/pt.json", - "hashed_secret": "7caaac3befa935700513d4123b881c383af03452", - "is_verified": false, - "line_number": 50, + "line_number": 221, "is_secret": false }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", - "hashed_secret": "88f195fbbbbeb256d8efac5b6b20e24d319725ee", + "hashed_secret": "deba0172511d5701d964202f4e5de698d5e07c67", "is_verified": false, - "line_number": 128, + "line_number": 222, "is_secret": false }, { @@ -4464,7 +4448,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "f5256124567ff538827b07cd38077b4d2f0ff4cd", "is_verified": false, - "line_number": 255, + "line_number": 224, "is_secret": false }, { @@ -4472,7 +4456,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "30ec8da443543f88f3717d8198a3ea4c07734e5f", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -4480,7 +4464,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "8390eaf853b58ba7ac38db1b8a2cc0808e701547", "is_verified": false, - "line_number": 276, + "line_number": 246, "is_secret": false }, { @@ -4488,7 +4472,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6fc474866c39ce50d555b284bb6c530d22db3f7c", "is_verified": false, - "line_number": 285, + "line_number": 255, "is_secret": false }, { @@ -4496,7 +4480,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e2a8853c024be2da10167eb42b19c6cc259f66db", "is_verified": false, - "line_number": 286, + "line_number": 256, "is_secret": false }, { @@ -4504,7 +4488,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6ab7275b94c1be125facd6df7ebdfee8b26931e3", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -4512,7 +4496,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "da4345e5dde7d1d6f387ec90d089cb0a944843cf", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -4520,7 +4504,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "15444730dd1d753e5ab5717d1dc9a76f1540352d", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -4528,7 +4512,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "265a547151e5ac45c15a75c94d14dd585066e379", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -4536,7 +4520,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "42f1178697edfac6f6b2532587187fdd33accaf0", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -4544,7 +4528,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "60743e0ecf39ab99f572bb2f4358ce144499395c", "is_verified": false, - "line_number": 667, + "line_number": 637, "is_secret": false }, { @@ -4552,7 +4536,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e8d5154134abcd60165e058618f2cf069841ee38", "is_verified": false, - "line_number": 683, + "line_number": 653, "is_secret": false }, { @@ -4560,7 +4544,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "1422160d3d897876d721df10805d9fe293add09d", "is_verified": false, - "line_number": 711, + "line_number": 681, "is_secret": false }, { @@ -4568,7 +4552,15 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "cc0bedebf2fadcc1bfbfa9daf41bba27a2d9aad0", "is_verified": false, - "line_number": 718, + "line_number": 688, + "is_secret": false + }, + { + "type": "Secret Keyword", + "filename": "src/frontend/src/locales/pt.json", + "hashed_secret": "7caaac3befa935700513d4123b881c383af03452", + "is_verified": false, + "line_number": 696, "is_secret": false }, { @@ -4576,14 +4568,14 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "36ffc06a145b35e699588e21f91d0847c758b761", "is_verified": false, - "line_number": 727 + "line_number": 697 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "792615431c8645d5df08db0bf84f5db8014f4a17", "is_verified": false, - "line_number": 734, + "line_number": 704, "is_secret": false }, { @@ -4591,7 +4583,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "861c6c413b2f9ca56854fb7602d3c690d8a1dc97", "is_verified": false, - "line_number": 1000, + "line_number": 970, "is_secret": false }, { @@ -4599,7 +4591,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4306c81dbceadb0effaac422f16658897a8034ef", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -4607,7 +4599,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "539811754f4eced6174b2365b581aca032a29904", "is_verified": false, - "line_number": 1382, + "line_number": 1352, "is_secret": false }, { @@ -4615,7 +4607,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "8610717164ef598531238326ede1ac540dd7a74c", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -4623,7 +4615,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "50e3a9595f402d5afdd0ee67c71b68cf57bab78d", "is_verified": false, - "line_number": 1582, + "line_number": 1552, "is_secret": false }, { @@ -4631,7 +4623,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "29022bd1c2181fbd36940c13cc3fa70041b48ab6", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -4639,21 +4631,21 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "9fc16a99501e66da0391bf67c7a16b236b23bed9", "is_verified": false, - "line_number": 1755 + "line_number": 1723 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "6e40094f370649da3568ca1c747fb5a17575c2be", "is_verified": false, - "line_number": 1756 + "line_number": 1724 }, { "type": "Secret Keyword", "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "924dffdc29e36152ac5c5ad72472d4012c453619", "is_verified": false, - "line_number": 1817, + "line_number": 1785, "is_secret": false }, { @@ -4661,7 +4653,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "3cf10d8f342037e71cf0fa4f902939cce046db88", "is_verified": false, - "line_number": 1818, + "line_number": 1786, "is_secret": false }, { @@ -4669,7 +4661,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "83107836c3fbb4f7126e1c62127d867f2115c66e", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -4677,7 +4669,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "e5de519e1707a85ae531c311d440ee66c9d92013", "is_verified": false, - "line_number": 1841, + "line_number": 1809, "is_secret": false }, { @@ -4685,7 +4677,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "5e210e2b1f3abec2110bc9be8e64d7f330fa1077", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -4693,7 +4685,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f0b397ea447a3fa2ad71bf2275fef3c57c19ced", "is_verified": false, - "line_number": 1989, + "line_number": 1957, "is_secret": false }, { @@ -4701,7 +4693,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "9a36a9b7c6bfc059b4f02372fff29d59a7d28956", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -4709,7 +4701,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "d041f1238574c9fda80f329a69df103dd0362201", "is_verified": false, - "line_number": 2004, + "line_number": 1972, "is_secret": false }, { @@ -4717,7 +4709,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "cb9c85cb748ddd0400375630f5ec0206759ddb89", "is_verified": false, - "line_number": 2007, + "line_number": 1975, "is_secret": false }, { @@ -4725,7 +4717,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "0f426df5e9101c108190dccfd037a953d0d402f2", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -4733,7 +4725,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "01817544892d96dedca2ad884fa493fea87ba133", "is_verified": false, - "line_number": 2021, + "line_number": 1989, "is_secret": false }, { @@ -4741,7 +4733,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "187a0dc92fd69a86bd0e675941e7f1898e3b55e1", "is_verified": false, - "line_number": 2236, + "line_number": 2204, "is_secret": false }, { @@ -4749,7 +4741,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "685a15b5a75fafecd74ad06561f50711e97d7275", "is_verified": false, - "line_number": 2237, + "line_number": 2205, "is_secret": false }, { @@ -4757,7 +4749,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "47a5deeb29d9b4f9d88464d050560a7f7b294ddd", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -4765,7 +4757,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "32c3af10cab241e0b425cebc42831cbaa236f1c6", "is_verified": false, - "line_number": 2244, + "line_number": 2212, "is_secret": false }, { @@ -4773,7 +4765,7 @@ "filename": "src/frontend/src/locales/pt.json", "hashed_secret": "4cb21cbeac2b9488b0fde6c6291caf37245c54c2", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -4783,7 +4775,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "c2c9099cb20503082b8973cbe38e7ba4712379d8", "is_verified": false, - "line_number": 128, + "line_number": 97, "is_secret": false }, { @@ -4791,7 +4783,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7d4449172fe7f8214f3b91655c013aa2de3fc8aa", "is_verified": false, - "line_number": 256, + "line_number": 225, "is_secret": false }, { @@ -4799,7 +4791,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "5df1d83e18c6e5903e3f4805f0af1415fd050ef7", "is_verified": false, - "line_number": 276, + "line_number": 246, "is_secret": false }, { @@ -4807,7 +4799,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "3244c8c5d271a1057fc569daacc04432f9c870f7", "is_verified": false, - "line_number": 494, + "line_number": 464, "is_secret": false }, { @@ -4815,7 +4807,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "e15b9952a55ccd2f94bf52984458b57a7b1f8fdd", "is_verified": false, - "line_number": 495, + "line_number": 465, "is_secret": false }, { @@ -4823,7 +4815,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "0ecfe8c3af9e1c44ecb32a27b2e873ea862d972a", "is_verified": false, - "line_number": 518, + "line_number": 488, "is_secret": false }, { @@ -4831,7 +4823,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8ccc7338429262ed661178a7bc14e6d9f0d86661", "is_verified": false, - "line_number": 590, + "line_number": 560, "is_secret": false }, { @@ -4839,7 +4831,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "15017ab3ec0dda97b754524421ed4dcff8bc2252", "is_verified": false, - "line_number": 665, + "line_number": 635, "is_secret": false }, { @@ -4847,7 +4839,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "4d24cbb80b72bf05ad9710c3a801359cf417a91a", "is_verified": false, - "line_number": 683, + "line_number": 653, "is_secret": false }, { @@ -4855,7 +4847,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d9bbe5c31c394c33128697ada5ad6bb2386b2d50", "is_verified": false, - "line_number": 711, + "line_number": 681, "is_secret": false }, { @@ -4863,7 +4855,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "47d79393f1549ed6bc311a334cda7f88bea287cc", "is_verified": false, - "line_number": 718, + "line_number": 688, "is_secret": false }, { @@ -4871,7 +4863,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "596d019fb62944b96319dc89f44d017cd8fb7152", "is_verified": false, - "line_number": 734, + "line_number": 704, "is_secret": false }, { @@ -4879,7 +4871,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "f316aecc6ce46d58af4c90ec4c007a3cb2339949", "is_verified": false, - "line_number": 1000, + "line_number": 970, "is_secret": false }, { @@ -4887,7 +4879,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8559e3a2fe94eb8b5201083eb5e4f50680934376", "is_verified": false, - "line_number": 1381, + "line_number": 1351, "is_secret": false }, { @@ -4895,7 +4887,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "bbfbbb23d7af0d8f4b452ca1cf95af7bbe98b547", "is_verified": false, - "line_number": 1382, + "line_number": 1352, "is_secret": false }, { @@ -4903,7 +4895,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "925ec0cb3d802b1c10bd74612833338e38f123e9", "is_verified": false, - "line_number": 1510, + "line_number": 1480, "is_secret": false }, { @@ -4911,7 +4903,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "6d09a8bbd2e67a11d0f0be76d5c2f1bc239327bc", "is_verified": false, - "line_number": 1582, + "line_number": 1552, "is_secret": false }, { @@ -4919,7 +4911,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "00e66990421091c8a0f9084a1997c317ffe34000", "is_verified": false, - "line_number": 1750, + "line_number": 1718, "is_secret": false }, { @@ -4927,7 +4919,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d370647062f8a9ebf57183122e6aacd46c2a7bda", "is_verified": false, - "line_number": 1817, + "line_number": 1785, "is_secret": false }, { @@ -4935,7 +4927,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7d2d5daebdd522b7de7fab94e153cbea4620c369", "is_verified": false, - "line_number": 1819, + "line_number": 1787, "is_secret": false }, { @@ -4943,7 +4935,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "74a3dd23222f2bc1888171506f585281009f79a4", "is_verified": false, - "line_number": 1852, + "line_number": 1820, "is_secret": false }, { @@ -4951,7 +4943,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "c10e8dbe79654ae8ed496c4c50f5e188b7924a4e", "is_verified": false, - "line_number": 1989, + "line_number": 1957, "is_secret": false }, { @@ -4959,7 +4951,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7223cfc50b72fe9b091b46275f561526aba9e705", "is_verified": false, - "line_number": 2002, + "line_number": 1970, "is_secret": false }, { @@ -4967,7 +4959,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "4aa9a3231780af9a37413cb7034ecdf72d43cfec", "is_verified": false, - "line_number": 2004, + "line_number": 1972, "is_secret": false }, { @@ -4975,7 +4967,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "68af7627d36cc6b923f5928a198165857f1ba5f1", "is_verified": false, - "line_number": 2007, + "line_number": 1975, "is_secret": false }, { @@ -4983,7 +4975,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7acd3659ef48c980ce4aef1d0f2f218ff25efc89", "is_verified": false, - "line_number": 2012, + "line_number": 1980, "is_secret": false }, { @@ -4991,7 +4983,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "7443c6e0ef4c013461140fb1aa2a7a60b40a0b89", "is_verified": false, - "line_number": 2021, + "line_number": 1989, "is_secret": false }, { @@ -4999,7 +4991,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "3c274d81d83ceba5bfaaaf2ac4f6b0aedcf431a0", "is_verified": false, - "line_number": 2236, + "line_number": 2204, "is_secret": false }, { @@ -5007,7 +4999,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "583ddc85a613ceef2e54f9d2251113f3acef73a3", "is_verified": false, - "line_number": 2237, + "line_number": 2205, "is_secret": false }, { @@ -5015,7 +5007,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8494b7db80f8bfedeef8cc5697bed5339cd1a4ee", "is_verified": false, - "line_number": 2243, + "line_number": 2211, "is_secret": false }, { @@ -5023,7 +5015,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "d45af2467cb991008a273eb0c4590fa9a96fc497", "is_verified": false, - "line_number": 2244, + "line_number": 2212, "is_secret": false }, { @@ -5031,7 +5023,7 @@ "filename": "src/frontend/src/locales/zh-Hans.json", "hashed_secret": "8da5daf1ef780fe5e37ae02bee48cbdb023c7101", "is_verified": false, - "line_number": 2254, + "line_number": 2222, "is_secret": false } ], @@ -5262,7 +5254,7 @@ "filename": "src/frontend/tests/core/features/user-flow-state-cleanup.spec.ts", "hashed_secret": "66904568acfa1e59b8b072cbe602e0ba3688019c", "is_verified": false, - "line_number": 46, + "line_number": 47, "is_secret": false } ], @@ -7309,5 +7301,5 @@ } ] }, - "generated_at": "2026-08-04T18:09:09Z" + "generated_at": "2026-08-05T16:25:25Z" } diff --git a/scripts/a11y/a11y_routes.json b/scripts/a11y/a11y_routes.json index 73b174099a49..9a97346b2844 100644 --- a/scripts/a11y/a11y_routes.json +++ b/scripts/a11y/a11y_routes.json @@ -124,11 +124,6 @@ } ], "gated": [ - { - "path": "/admin", - "surface": "Admin page", - "currentBehavior": "Redirects to /flows for current user/session. Scan with an admin user." - }, { "path": "/login", "surface": "Login page", diff --git a/scripts/migrate_secret_key.py b/scripts/migrate_secret_key.py index 6f3cc44ddc09..318fbac94007 100644 --- a/scripts/migrate_secret_key.py +++ b/scripts/migrate_secret_key.py @@ -11,6 +11,7 @@ - user.store_api_key: Langflow Store API keys - variable.value: All encrypted variable values - folder.auth_settings: MCP oauth_client_secret and api_key fields +- sso_config.client_secret_encrypted: SSO/OIDC client secrets Usage: uv run python scripts/migrate_secret_key.py --help @@ -20,6 +21,7 @@ import argparse import base64 +import binascii import json import os import platform @@ -29,14 +31,27 @@ from datetime import datetime, timezone from pathlib import Path +from cryptography.exceptions import InvalidTag from cryptography.fernet import Fernet, InvalidToken +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives.kdf.hkdf import HKDF from platformdirs import user_cache_dir -from sqlalchemy import create_engine, text +from sqlalchemy import create_engine, inspect, text MINIMUM_KEY_LENGTH = 32 SENSITIVE_AUTH_FIELDS = ["oauth_client_secret", "api_key"] # Must match langflow.services.variable.constants.CREDENTIAL_TYPE CREDENTIAL_TYPE = "Credential" +SSO_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm" +SSO_AAD = SSO_ENVELOPE_HEADER.encode() +SSO_HKDF_SALT = b"langflow/sso/client-secret/hkdf-salt/v1" +SSO_HKDF_INFO = b"langflow/sso/client-secret/encryption" +SSO_NONCE_BYTES = 12 +SSO_TAG_BYTES = 16 +SSO_ENVELOPE_PARTS = 6 +SSO_HEADER_PARTS = 4 +SSO_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") def get_default_config_dir() -> Path: @@ -140,6 +155,62 @@ def migrate_value(encrypted: str, old_key: str, new_key: str) -> str | None: return None +def _derive_sso_key(master_key: str) -> bytes: + return HKDF( + algorithm=hashes.SHA256(), + length=32, + salt=SSO_HKDF_SALT, + info=SSO_HKDF_INFO, + ).derive(master_key.encode()) + + +def _decode_sso_envelope(envelope: str) -> tuple[bytes, bytes]: + parts = envelope.split(":") + if len(parts) != SSO_ENVELOPE_PARTS or ":".join(parts[:SSO_HEADER_PARTS]) != SSO_ENVELOPE_HEADER: + msg = "Unsupported SSO client-secret envelope" + raise ValueError(msg) + 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) + return nonce, ciphertext + + +def decrypt_sso_secret_with_key(envelope: str, key: str) -> str: + """Decrypt an SSO client-secret envelope with an explicit master key.""" + nonce, ciphertext = _decode_sso_envelope(envelope) + plaintext = AESGCM(_derive_sso_key(key)).decrypt(nonce, ciphertext, SSO_AAD) + return plaintext.decode() + + +def encrypt_sso_secret_with_key(plaintext: str, key: str) -> str: + """Encrypt an SSO client secret using the application's current envelope format.""" + nonce = os.urandom(SSO_NONCE_BYTES) + ciphertext = AESGCM(_derive_sso_key(key)).encrypt(nonce, plaintext.encode(), SSO_AAD) + encoded_nonce = base64.urlsafe_b64encode(nonce).rstrip(b"=").decode() + encoded_ciphertext = base64.urlsafe_b64encode(ciphertext).rstrip(b"=").decode() + return f"{SSO_ENVELOPE_HEADER}:{encoded_nonce}:{encoded_ciphertext}" + + +def migrate_sso_secret(envelope: str, old_key: str, new_key: str) -> str | None: + """Rewrap an SSO client-secret envelope under a replacement master key.""" + try: + plaintext = decrypt_sso_secret_with_key(envelope, old_key) + return encrypt_sso_secret_with_key(plaintext, new_key) + except (InvalidTag, UnicodeDecodeError, ValueError): + return None + + def migrate_auth_settings(auth_settings: dict, old_key: str, new_key: str) -> tuple[dict, list[str]]: """Re-encrypt sensitive fields in auth_settings dict. @@ -208,6 +279,17 @@ def verify_migration(conn, new_key: str) -> tuple[int, int]: except (InvalidToken, json.JSONDecodeError): failed += 1 + if inspect(conn).has_table("sso_config"): + configs = conn.execute( + text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL LIMIT 3") + ).fetchall() + for _, encrypted_secret in configs: + try: + decrypt_sso_secret_with_key(encrypted_secret, new_key) + verified += 1 + except (InvalidTag, UnicodeDecodeError, ValueError): + failed += 1 + return verified, failed @@ -364,9 +446,38 @@ def migrate( total_migrated += migrated total_failed += failed + # Migrate sso_config.client_secret_encrypted when the optional SSO schema exists. + print("\n4. Migrating SSO client secrets...") + migrated, failed = 0, 0 + if inspect(conn).has_table("sso_config"): + configs = conn.execute( + text("SELECT id, client_secret_encrypted FROM sso_config WHERE client_secret_encrypted IS NOT NULL") + ).fetchall() + for config_id, encrypted_secret in configs: + new_encrypted = migrate_sso_secret(encrypted_secret, old_key, new_key) + if new_encrypted: + if not dry_run: + conn.execute( + text("UPDATE sso_config SET client_secret_encrypted = :secret WHERE id = :id"), + {"secret": new_encrypted, "id": config_id}, + ) + migrated += 1 + else: + failed += 1 + print(f" Warning: Could not decrypt SSO config {config_id}") + print(f" {'Would migrate' if dry_run else 'Migrated'}: {migrated}, Failed: {failed}") + total_migrated += migrated + total_failed += failed + + if total_failed > 0 and not dry_run: + print(f"\nERROR: {total_failed} values could not be migrated.") + print("Rolling back all database changes; the secret key was not changed.") + conn.rollback() + sys.exit(1) + # Verify migrated data can be decrypted with new key if total_migrated > 0: - print("\n4. Verifying migration...") + print("\n5. Verifying migration...") verified, verify_failed = verify_migration(conn, new_key) if verify_failed > 0: print(f" ERROR: {verify_failed} records failed verification!") @@ -386,12 +497,12 @@ def migrate( if not dry_run: backup_file = config_dir / f"secret_key.backup.{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}" write_secret_key_to_file(config_dir, old_key, backup_file.name) - print(f"\n5. Backed up old key to: {backup_file}") + print(f"\n6. Backed up old key to: {backup_file}") write_secret_key_to_file(config_dir, new_key) - print(f"6. Saved new secret key to: {config_dir / 'secret_key'}") + print(f"7. Saved new secret key to: {config_dir / 'secret_key'}") else: - print("\n5. [DRY RUN] Would backup old key") - print(f"6. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}") + print("\n6. [DRY RUN] Would backup old key") + print(f"7. [DRY RUN] Would save new key to: {config_dir / 'secret_key'}") # Summary print("\n" + "=" * 50) 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/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 new file mode 100644 index 000000000000..50404f930f1d --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/7c8e9f0a1b2d_add_sso_config_invariant_checks.py @@ -0,0 +1,515 @@ +"""add database enforcement for SSO configuration invariants + +Revision ID: 7c8e9f0a1b2d +Revises: f0a1b2c3d4e5 +Create Date: 2026-08-04 + +Phase: EXPAND +""" + +import base64 +import binascii +import re +from collections.abc import Sequence +from urllib.parse import urlsplit + +import sqlalchemy as sa +from alembic import op +from langflow.utils import migration + +# revision identifiers, used by Alembic. +revision: str = "7c8e9f0a1b2d" # pragma: allowlist secret +down_revision: str | None = "f0a1b2c3d4e5" # pragma: allowlist secret +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_CONFIG_TABLE = "sso_config" +# Final DB names (op.f / conv). Without the token wrapper, alembic's +# ck_%(table_name)s_%(constraint_name)s convention doubles the prefix to +# ck_sso_config_ck_sso_config_*, which then survives downgrade and breaks +# 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_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", + "authorization_endpoint", + "jwks_uri", + "issuer", +) +_INVALID_PERCENT_ESCAPE_PATTERN = re.compile(r"%(?![0-9A-Fa-f]{2})") + + +def _config_table() -> sa.Table: + metadata = sa.MetaData() + return sa.Table( + _CONFIG_TABLE, + metadata, + # 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()), + ) + + +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) + + +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) + 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, + *, + 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_( + 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), + ) + 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 + # the new representation. + sa.and_( + table.c.protocol.is_(None), + table.c.provider_settings.is_(None), + table.c.provider.in_(_SUPPORTED_PROTOCOLS), + ), + synchronized, + ) + + +def _enabled_check(table: sa.Table, *, allow_pending_n_minus_one: bool = False) -> sa.ColumnElement[bool]: + settings = table.c.provider_settings + 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 + # 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_( + _nonblank_json_string(settings, "discovery_url"), + sa.and_( + _nonblank_json_string(settings, "authorization_endpoint"), + _nonblank_json_string(settings, "token_endpoint"), + _nonblank_json_string(settings, "jwks_uri"), + ), + ), + *(_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]: + 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_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 + try: + 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 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( + 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")) + endpoint_values = [settings.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 (settings.get(key) for key in _REMOTE_URL_FIELDS) + ) + if not ( + _is_secret_envelope(row["client_secret_encrypted"]) + and has_client_id + and (has_discovery or has_explicit_endpoints) + and supplied_urls_are_valid + ): + conn.execute(table.update().where(table.c.id == row["id"]).values(enabled=False)) + + +def _raise_for_protocol_mismatches(conn: sa.Connection, table: sa.Table) -> None: + 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): " + f"{', '.join(invalid_ids)}. Correct these rows before rerunning the migration." + ) + raise RuntimeError(msg) + + +def _existing_check_names(conn: sa.Connection) -> set[str]: + return {check["name"] for check in sa.inspect(conn).get_check_constraints(_CONFIG_TABLE) if check.get("name")} + + +def _create_checks(conn: sa.Connection, table: sa.Table) -> None: + # create_all() may already have installed these from the SQLModel metadata. + # Also treat the legacy doubled-prefix names as present so a partial upgrade + # does not install a second copy under the canonical name. + existing = _existing_check_names(conn) + need_protocol = not existing.intersection(_PROTOCOL_CHECK_ALIASES) + need_enabled = not existing.intersection(_ENABLED_CHECK_ALIASES) + 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, + 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, 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, 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, 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)) + + +def _drop_checks(conn: sa.Connection) -> None: + existing = _existing_check_names(conn) + 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": + with op.batch_alter_table(_CONFIG_TABLE, recreate="always") as batch_op: + for name in to_drop: + batch_op.drop_constraint(op.f(name), type_="check") + return + for name in to_drop: + op.drop_constraint(op.f(name), _CONFIG_TABLE, type_="check") + + +def _create_slug_trigger(conn: sa.Connection) -> None: + # Idempotent for create_all()-then-upgrade: model after_create listeners may + # already have installed the same trigger/function. + if conn.dialect.name == "sqlite": + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_SLUG_TRIGGER}")) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SLUG_TRIGGER} + BEFORE UPDATE OF slug ON {_CONFIG_TABLE} + FOR EACH ROW + WHEN OLD.slug IS NOT NULL AND NEW.slug IS NOT OLD.slug + BEGIN + SELECT RAISE(ABORT, 'SSOConfig.slug is immutable after insert'); + END + """ + ) + ) + elif conn.dialect.name == "postgresql": + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_SLUG_TRIGGER} ON {_CONFIG_TABLE}")) + op.execute( + sa.text( + f""" + CREATE OR REPLACE FUNCTION {_POSTGRES_TRIGGER_FUNCTION}() + RETURNS trigger AS $$ + BEGIN + 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; + END; + $$ LANGUAGE plpgsql + """ + ) + ) + op.execute( + sa.text( + f""" + CREATE TRIGGER {_SLUG_TRIGGER} + BEFORE UPDATE OF slug ON {_CONFIG_TABLE} + FOR EACH ROW EXECUTE FUNCTION {_POSTGRES_TRIGGER_FUNCTION}() + """ + ) + ) + + +def _drop_slug_trigger(conn: sa.Connection) -> None: + if conn.dialect.name == "sqlite": + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_SLUG_TRIGGER}")) + elif conn.dialect.name == "postgresql": + op.execute(sa.text(f"DROP TRIGGER IF EXISTS {_SLUG_TRIGGER} ON {_CONFIG_TABLE}")) + op.execute(sa.text(f"DROP FUNCTION IF EXISTS {_POSTGRES_TRIGGER_FUNCTION}()")) + + +def upgrade() -> None: + conn = op.get_bind() + 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) + _create_slug_trigger(conn) + + +def downgrade() -> None: + conn = op.get_bind() + if not migration.table_exists(_CONFIG_TABLE, conn): + return + _drop_slug_trigger(conn) + _drop_checks(conn) 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..25ae8a2cbe00 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/8d9e0f1a2b3c_complete_sso_expand_compatibility.py @@ -0,0 +1,586 @@ +"""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 + +import base64 +import binascii +from collections.abc import Sequence +from uuid import UUID + +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", +) +_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", + "display_name", + "provider_name", + "protocol", + "provider", + "enabled", + "client_secret_encrypted", + "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 _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) + 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() + _normalize_pending_n_minus_one_rows(conn) + _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 9e3618594634..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 @@ -6,12 +6,12 @@ Phase: EXPAND """ + from collections.abc import Sequence import sqlalchemy as sa import sqlmodel from alembic import op - from langflow.utils import migration # revision identifiers, used by Alembic. 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 new file mode 100644 index 000000000000..08b9f2ee43aa --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/e9f2a3b4c5d6_allow_multiple_sso_identities_per_user.py @@ -0,0 +1,496 @@ +"""update SSO identity, connection, and protocol contracts + +Revision ID: e9f2a3b4c5d6 +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 = "e9f2a3b4c5d6" # pragma: allowlist secret +down_revision: str | None = "b7d5f9a3c2e4" # pragma: allowlist secret +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", +) +# Structural prefix of the current client-secret envelope. Duplicated from +# ``models/auth/sso_secret.py`` on purpose: a migration must not import +# application models, whose shape changes independently of this revision. +_ENVELOPE_HEADER = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:" +_ENVELOPE_PART_COUNT = 6 +_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 ( + len(parts) == _ENVELOPE_PART_COUNT + and len(parts[-2]) == _ENVELOPE_NONCE_LENGTH + and len(parts[-1]) >= _ENVELOPE_MIN_CIPHERTEXT_LENGTH + ) + + +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 _sanitize_legacy_client_secrets(conn: sa.Connection) -> None: + """Clear pre-encryption plaintext client secrets and disable those connections. + + Before this revision ``client_secret_encrypted`` held the raw secret despite + its name. From this revision on the model rejects any value that is not a + versioned envelope, so a legacy row would be unwritable through the ORM and + undecryptable at login — a failure that would surface long after upgrade. + + Re-encrypting in place is not possible here: the key is derived from the + application's ``SECRET_KEY``, which alembic has no reliable access to, and a + migration that fails on a missing key would block the deploy. Removing the + plaintext and disabling the connection fails safe instead, and it also clears + a secret that was stored unencrypted. An administrator re-enters it through + the admin UI and re-enables the connection. + + This is deliberately one-way; ``downgrade`` cannot restore a secret that has + been deleted. + """ + columns = _column_names(conn, _CONFIG_TABLE) + if not {"id", "client_secret_encrypted"} <= columns: + return + + has_enabled = "enabled" in columns + selected = [sa.column("id"), sa.column("client_secret_encrypted")] + if has_enabled: + selected.append(sa.column("enabled")) + table = sa.table(_CONFIG_TABLE, *selected) + + for row in conn.execute(sa.select(table.c.id, table.c.client_secret_encrypted)).mappings(): + secret = row["client_secret_encrypted"] + if secret is None or _is_secret_envelope(secret): + continue + values: dict[str, object] = {"client_secret_encrypted": None} + if has_enabled: + values["enabled"] = False + conn.execute(table.update().where(table.c.id == row["id"]).values(**values)) + + +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): + columns = _column_names(conn, _CONFIG_TABLE) + if "enforce_sso" in columns: + selected = [sa.column("enforce_sso", sa.Boolean())] + if "enabled" in columns: + selected.append(sa.column("enabled", sa.Boolean())) + config = sa.table(_CONFIG_TABLE, *selected) + # Ignore disabled/sanitized connections so a stale enforce_sso flag + # on an inactive row cannot turn on instance-wide enforcement. + stmt = sa.select(config.c.enforce_sso).where(config.c.enabled.is_(True)) + else: + config = sa.table(_CONFIG_TABLE, *selected) + stmt = sa.select(config.c.enforce_sso) + enforce_sso = any(conn.execute(stmt).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)) + + # 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) + 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, + 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, + "user", + ["updated_by"], + ["id"], + ondelete="SET NULL", + ) + + +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().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, + 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: + 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_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: + # 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) + + +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) + 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 _raise_for_multiple_identities_per_user(conn: sa.Connection) -> None: + """Fail clearly when multi-identity rows would block restoring unique(user_id).""" + profile = sa.table(_PROFILE_TABLE, sa.column("user_id")) + duplicates = ( + conn.execute( + sa.select(profile.c.user_id, sa.func.count().label("identity_count")) + .group_by(profile.c.user_id) + .having(sa.func.count() > 1) + .order_by(profile.c.user_id) + ) + .mappings() + .all() + ) + if not duplicates: + return + details = ", ".join(f"{row['user_id']} ({row['identity_count']} identities)" for row in duplicates) + msg = ( + f"{_PROFILE_TABLE} has multiple identities for user_id(s): {details}. " + "Remove the extra identities before restoring the unique user_id index." + ) + 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): + 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() + # 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) + 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) + needs_unique_user_id = user_id_index is None or not user_id_index.get("unique") + if needs_unique_user_id: + _raise_for_multiple_identities_per_user(conn) + 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/alembic/versions/f0a1b2c3d4e5_merge_sso_and_catalog_policy_heads.py b/src/backend/base/langflow/alembic/versions/f0a1b2c3d4e5_merge_sso_and_catalog_policy_heads.py new file mode 100644 index 000000000000..b86202c98b03 --- /dev/null +++ b/src/backend/base/langflow/alembic/versions/f0a1b2c3d4e5_merge_sso_and_catalog_policy_heads.py @@ -0,0 +1,28 @@ +"""merge SSO identity and catalog policy heads + +Phase: EXPAND (no DDL - merge point only) + +Revision ID: f0a1b2c3d4e5 +Revises: d4a7c9e1b2f6, e9f2a3b4c5d6 +Create Date: 2026-08-03 + +Resolves the branch created when release-1.12.0 model/catalog policy +migrations landed alongside the SSO multi-identity migration that had +accidentally reused revision id e8f1a2b3c4d5. +""" + +from collections.abc import Sequence + +# revision identifiers, used by Alembic. +revision: str = "f0a1b2c3d4e5" # pragma: allowlist secret +down_revision: str | Sequence[str] | None = ("d4a7c9e1b2f6", "e9f2a3b4c5d6") # pragma: allowlist secret +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass diff --git a/src/backend/base/langflow/services/database/models/__init__.py b/src/backend/base/langflow/services/database/models/__init__.py index 400a7d60ffc7..c12ea200c23b 100644 --- a/src/backend/base/langflow/services/database/models/__init__.py +++ b/src/backend/base/langflow/services/database/models/__init__.py @@ -10,7 +10,15 @@ AuthzTeamMember, CasbinRule, SSOConfig, + SSOConfigCreate, + SSOConfigRead, + SSOConfigUpdate, + SSOSecretError, + SSOSettings, SSOUserProfile, + decrypt_sso_client_secret, + encrypt_sso_client_secret, + is_sso_client_secret_envelope, ) from .catalog_policy import CatalogPolicyMode, CatalogPolicyRule, CatalogPolicyScope, CatalogResourceKind from .deployment import Deployment @@ -71,6 +79,11 @@ "MessageTable", "ModelProviderPolicy", "SSOConfig", + "SSOConfigCreate", + "SSOConfigRead", + "SSOConfigUpdate", + "SSOSecretError", + "SSOSettings", "SSOUserProfile", "SignalType", "SpanTable", @@ -78,4 +91,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 1ef3e8d1ec9f..e4f2dc1dfff5 100644 --- a/src/backend/base/langflow/services/database/models/auth/__init__.py +++ b/src/backend/base/langflow/services/database/models/auth/__init__.py @@ -11,7 +11,13 @@ SharePermissionLevel, ShareScope, ) -from .sso import SSOConfig, SSOUserProfile +from .sso import SSOConfig, SSOConfigCreate, SSOConfigRead, SSOConfigUpdate, SSOSettings, SSOUserProfile +from .sso_secret import ( + SSOSecretError, + decrypt_sso_client_secret, + encrypt_sso_client_secret, + is_sso_client_secret_envelope, +) __all__ = [ "AuthzAuditLog", @@ -24,7 +30,15 @@ "AuthzTeamMember", "CasbinRule", "SSOConfig", + "SSOConfigCreate", + "SSOConfigRead", + "SSOConfigUpdate", + "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..83cf36cc61a3 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,238 @@ ``langflow.services.database.models`` (e.g. ``SSOUserProfile``, ``SSOConfig``). """ +import re from datetime import datetime, timezone +from typing import Annotated, Any, Literal, TypeAlias +from urllib.parse import urlsplit from uuid import uuid4 import sqlalchemy as sa -from sqlalchemy import Column, ForeignKey, Index +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 +from sqlalchemy.sql.naming import conv 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]+)*$") +_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", + "authorization_endpoint", + "jwks_uri", + "issuer", +) + + +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", frozen=True) + + 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 + + @field_validator("client_id", mode="before") + @classmethod + def validate_client_id(cls, value: object) -> object: + if not isinstance(value, str): + return value + value = value.strip() + if not value: + msg = "OIDC client_id must not be blank" + raise ValueError(msg) + return value + + @field_validator(*_OIDC_REMOTE_URL_FIELDS, mode="before") + @classmethod + def validate_remote_url(cls, value: object, info: Any) -> object: + """Require absolute HTTP(S) URLs for values consumed by the OIDC HTTP client.""" + if value is None or not isinstance(value, str): + return value + value = value.strip() + if not value: + msg = f"OIDC {info.field_name} must not be blank" + raise ValueError(msg) + is_http_url = False + try: + parsed = urlsplit(value) + # 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: + msg = f"OIDC {info.field_name} must be an absolute HTTP(S) URL" + raise ValueError(msg) + 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 | LegacyProviderSettings, + 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 + + +def _validate_enabled_config( + provider_settings: SSOProviderSettings, + *, + enabled: bool, + has_client_secret: bool, +) -> None: + 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) + if not has_client_secret: + msg = "Enabled OIDC configurations require a client secret" + raise ValueError(msg) + + has_discovery = bool(provider_settings.discovery_url) + has_explicit_endpoints = all( + ( + provider_settings.authorization_endpoint, + provider_settings.token_endpoint, + provider_settings.jwks_uri, + ) + ) + if not has_discovery and not has_explicit_endpoints: + msg = ( + "Enabled OIDC configurations require discovery_url or authorization_endpoint, token_endpoint, and jwks_uri" + ) + raise ValueError(msg) + + +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 _SSOClientSecretEnvelope(sa.TypeDecorator): + """Reject non-envelope values on every SQL bind path, including Core updates.""" + + impl = sa.String + cache_ok = True + + def process_bind_param(self, value: str | None, _dialect: sa.engine.Dialect) -> str | None: + 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 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. + + 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" - # 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 +247,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="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) @@ -44,29 +259,49 @@ 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() - 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") + 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=False) + sort_order: int = Field(default=0, description="Login-button display order") + client_secret_encrypted: str | None = Field( + default=None, + exclude=True, + repr=False, + sa_column=Column(_SSOClientSecretEnvelope(), nullable=True), + description="Versioned ciphertext envelope; never serialize in a read response", + ) + provider_settings: SSOProviderSettings = Field( + default_factory=OIDCProviderSettings, + sa_column=Column(_ProviderSettingsJSON(), nullable=False), + description=( + "Validated provider settings persisted as JSON. Assign a new object " + "(or call flag_modified) to persist changes; in-place nested field " + "mutations are not tracked by SQLAlchemy." + ), + ) 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, + sa_column=Column(DateTime(timezone=True), nullable=False), + ) + updated_at: datetime = Field( + default_factory=_utc_now, + sa_column=Column(DateTime(timezone=True), nullable=False, onupdate=_utc_now), + ) created_by: UUIDstr | None = Field( default=None, sa_column=Column( @@ -75,3 +310,449 @@ 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) + _validate_enabled_config( + self.provider_settings, + enabled=self.enabled, + has_client_secret=self.client_secret_encrypted is not None, + ) + + @model_validator(mode="after") + def validate_configuration(self) -> Self: + """Validate without assigning through SQLAlchemy instrumentation.""" + provider_settings = _validate_provider_settings(self.protocol, self.provider_settings) + _validate_enabled_config( + provider_settings, + enabled=self.enabled, + has_client_secret=self.client_secret_encrypted is not None, + ) + return self + + @field_validator("slug") + @classmethod + def validate_slug_field(cls, value: str) -> str: + if not _SSO_SLUG_PATTERN.fullmatch(value): + msg = "SSOConfig.slug must contain only lowercase letters, numbers, and single hyphens" + raise ValueError(msg) + return value + + @field_validator("client_secret_encrypted") + @classmethod + def validate_client_secret_field(cls, value: str | None) -> str | None: + 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 + + @validates("slug") + def validate_slug_assignment(self, _key: str, value: str) -> str: + """Reject invalid slugs assigned before the first insert.""" + if not _SSO_SLUG_PATTERN.fullmatch(value): + msg = "SSOConfig.slug must contain only lowercase letters, numbers, and single hyphens" + raise ValueError(msg) + return value + + @validates("protocol") + 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 not self.__dict__.get(_VALIDATED_UPDATE_FLAG) and "provider_settings" in self.__dict__: + _validate_provider_settings(value, self.__dict__["provider_settings"]) + return value + + @validates("provider_settings") + def validate_provider_settings( + self, + _key: str, + value: SSOProviderSettings | dict[str, Any], + ) -> SSOProviderSettings: + """Keep provider_settings aligned with protocol on attribute assignment.""" + 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) + + @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 + + @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() + return sa.and_(value.is_not(None), sa.func.length(sa.func.trim(value)) > 0) + + +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) + 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: + """Install portable checks and dialect-specific slug immutability triggers.""" + table = SSOConfig.__table__ + provider_settings = table.c.provider_settings + protocol = table.c.protocol + enabled = table.c.enabled + + # conv() marks these as final names so alembic's ck_%(table_name)s_%(constraint_name)s + # convention (applied in alembic/env.py) does not double-prefix them. + table.append_constraint( + CheckConstraint( + sa.and_( + protocol.in_(("oidc", "saml", "ldap")), + provider_settings["protocol"].as_string().is_not(None), + provider_settings["protocol"].as_string() == protocol, + ), + name=conv("ck_sso_config_protocol_consistency"), + ) + ) + table.append_constraint( + 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_( + _nonblank_json_string(provider_settings, "discovery_url"), + sa.and_( + _nonblank_json_string(provider_settings, "authorization_endpoint"), + _nonblank_json_string(provider_settings, "token_endpoint"), + _nonblank_json_string(provider_settings, "jwks_uri"), + ), + ), + *(_http_json_url_or_null(provider_settings, key) for key in _OIDC_REMOTE_URL_FIELDS), + ), + ), + 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 OLD.slug IS NOT NULL AND NEW.slug IS NOT OLD.slug + BEGIN + SELECT RAISE(ABORT, 'SSOConfig.slug is immutable after insert'); + END + """ + ).execute_if(dialect="sqlite") + postgres_trigger_function = sa.DDL( + """ + CREATE OR REPLACE FUNCTION prevent_sso_config_slug_update() + RETURNS trigger AS $$ + BEGIN + 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; + END; + $$ LANGUAGE plpgsql + """ + ).execute_if(dialect="postgresql") + postgres_trigger = sa.DDL( + """ + CREATE TRIGGER trg_sso_config_slug_immutable + BEFORE UPDATE OF slug ON sso_config + FOR EACH ROW EXECUTE FUNCTION prevent_sso_config_slug_update() + """ + ).execute_if(dialect="postgresql") + drop_postgres_function = sa.DDL("DROP FUNCTION IF EXISTS prevent_sso_config_slug_update()").execute_if( + dialect="postgresql" + ) + sa.event.listen(table, "after_create", sqlite_trigger) + sa.event.listen(table, "after_create", postgres_trigger_function) + sa.event.listen(table, "after_create", postgres_trigger) + sa.event.listen(table, "after_drop", drop_postgres_function) + + +_install_sso_config_database_invariants() + + +class SSOConfigCreate(SQLModel): + """Validated external input for creating an SSO configuration.""" + + model_config = ConfigDict(extra="forbid") + + display_name: str + protocol: Literal["oidc"] = "oidc" + enabled: bool = False + sort_order: int = 0 + client_secret: SecretStr | None = Field(default=None, exclude=True, repr=False) + provider_settings: SSOProviderSettings = Field(default_factory=OIDCProviderSettings) + email_claim: str = "email" + username_claim: str = "preferred_username" + user_id_claim: str = "sub" + + @field_validator("client_secret") + @classmethod + def validate_client_secret(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None and not value.get_secret_value().strip(): + msg = "SSO client secret must not be blank" + raise ValueError(msg) + return value + + @model_validator(mode="after") + def validate_configuration(self) -> Self: + provider_settings = _validate_provider_settings(self.protocol, self.provider_settings) + _validate_enabled_config( + provider_settings, + enabled=self.enabled, + has_client_secret=self.client_secret is not None, + ) + return self + + def to_model( + self, + settings_service: Any | None = None, + *, + actor_id: UUIDstr | None = None, + ) -> SSOConfig: + """Build the persistence model, encrypting plaintext before it reaches the table.""" + from langflow.services.database.models.auth.sso_secret import encrypt_sso_client_secret + + values = self.model_dump(exclude={"client_secret"}) + if self.client_secret is not None: + values["client_secret_encrypted"] = encrypt_sso_client_secret( + self.client_secret.get_secret_value(), + settings_service, + ) + values["created_by"] = actor_id + values["updated_by"] = actor_id + return SSOConfig(**values) + + +class SSOConfigRead(SQLModel): + """Secret-free representation of a persisted SSO configuration.""" + + model_config = ConfigDict(from_attributes=True) + + id: UUIDstr + slug: str + display_name: str + protocol: str + enabled: bool + sort_order: int + provider_settings: SSOProviderSettings + email_claim: str + username_claim: str + user_id_claim: str + created_at: datetime + updated_at: datetime + created_by: UUIDstr | None + updated_by: UUIDstr | None + + +class SSOConfigUpdate(SQLModel): + """Validated partial update input for an SSO configuration.""" + + model_config = ConfigDict(extra="forbid") + + display_name: str | None = None + protocol: Literal["oidc"] | None = None + enabled: bool | None = None + sort_order: int | None = None + client_secret: SecretStr | None = Field(default=None, exclude=True, repr=False) + provider_settings: SSOProviderSettings | None = None + email_claim: str | None = None + username_claim: str | None = None + user_id_claim: str | None = None + + @field_validator("client_secret") + @classmethod + def validate_client_secret(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None and not value.get_secret_value().strip(): + msg = "SSO client secret must not be blank" + raise ValueError(msg) + return value + + def apply_to( + self, + config: SSOConfig, + settings_service: Any | None = None, + *, + actor_id: UUIDstr | None = None, + ) -> SSOConfig: + """Validate the merged state and apply this update to a persistence model.""" + from langflow.services.database.models.auth.sso_secret import encrypt_sso_client_secret + + values = self.model_dump(exclude_unset=True, exclude={"client_secret"}) + if "provider_settings" in self.model_fields_set: + values["provider_settings"] = self.provider_settings + required_fields = { + "display_name", + "protocol", + "enabled", + "sort_order", + "provider_settings", + "email_claim", + "username_claim", + "user_id_claim", + } + null_fields = sorted(field for field in required_fields if field in values and values[field] is None) + if null_fields: + msg = f"SSO configuration fields cannot be null: {', '.join(null_fields)}" + raise ValueError(msg) + + protocol = values.get("protocol", config.protocol) + provider_settings = _validate_provider_settings( + protocol, + values.get("provider_settings", config.provider_settings), + ) + enabled = values.get("enabled", config.enabled) + + encrypted_secret = config.client_secret_encrypted + if "client_secret" in self.model_fields_set: + encrypted_secret = ( + encrypt_sso_client_secret(self.client_secret.get_secret_value(), settings_service) + if self.client_secret is not None + else None + ) + _validate_enabled_config( + provider_settings, + enabled=enabled, + has_client_secret=encrypted_secret is not None, + ) + + # 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, + ) + if actor_id is not None: + config.updated_by = actor_id + return config + + +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) + _validate_enabled_config( + _validate_provider_settings(target.protocol, target.provider_settings), + enabled=target.enabled, + has_client_secret=target.client_secret_encrypted is not None, + ) + + +@sa.event.listens_for(SSOConfig, "before_insert") +def _validate_sso_config_before_insert( + _mapper: sa.orm.Mapper[SSOConfig], + _connection: sa.Connection, + target: SSOConfig, +) -> None: + """Validate cross-column invariants before ORM inserts.""" + _validate_enabled_config( + _validate_provider_settings(target.protocol, target.provider_settings), + enabled=target.enabled, + has_client_secret=target.client_secret_encrypted is not None, + ) 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..7d1fc411b6f8 --- /dev/null +++ b/src/backend/base/langflow/services/database/models/auth/sso_secret.py @@ -0,0 +1,163 @@ +"""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 +_BASE64URL_ALPHABET = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") + + +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: + 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) + 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) + if not client_secret.strip(): + msg = "SSO client secret must not be blank" + raise SSOSecretError(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_migration_execution.py b/src/backend/tests/unit/alembic/test_migration_execution.py index 4d18d9f3fbd7..0d3be64150ca 100644 --- a/src/backend/tests/unit/alembic/test_migration_execution.py +++ b/src/backend/tests/unit/alembic/test_migration_execution.py @@ -11,8 +11,10 @@ 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" @@ -334,6 +336,122 @@ 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_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_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: """Convert an async DB URL to a sync one for SQLAlchemy create_engine.""" if db_url.startswith("sqlite+aiosqlite"): @@ -342,8 +460,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_diffs(diffs) if "sqlite" in db_url: filtered_diffs = _filter_sqlite_noise(filtered_diffs) return filtered_diffs @@ -359,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: 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..56452051c429 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_instance_settings_migration.py @@ -0,0 +1,220 @@ +"""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 = "e9f2a3b4c5d6" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret + + +def _config_values( + *, + config_id: str, + provider_name: str, + enforce_sso: bool, + timestamp: datetime, + enabled: bool = True, +) -> dict: + return { + "id": config_id, + "provider": "oidc", + "provider_name": provider_name, + "enabled": enabled, + "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", "enforce_sso"} <= 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() + + +def test_sso_instance_fields_upgrade_ignores_disabled_enforce_sso(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + config_id = str(uuid4()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + connection.execute( + sso_config.insert(), + _config_values( + config_id=config_id, + provider_name="Disabled OIDC", + enforce_sso=True, + timestamp=timestamp, + enabled=False, + ), + ) + 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: + sso_settings = sa.Table("sso_settings", metadata, autoload_with=connection) + settings_rows = connection.execute(sa.select(sso_settings)).mappings().all() + assert settings_rows == [{"id": 1, "enforce_sso": False}] + 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..763a166acc23 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_multi_identity_migration.py @@ -0,0 +1,293 @@ +"""Migration contract for multiple SSO identities per user.""" + +from __future__ import annotations + +from datetime import datetime, timezone +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 +_REVISION = "e9f2a3b4c5d6" # 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 # pragma: allowlist secret + + +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() + + +def test_sso_multi_identity_downgrade_rejects_users_with_multiple_identities(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()) + 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()) + + 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": stored_user_id, + "username": "sso-multi-identity-downgrade-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=stored_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.begin() as connection: + sso_user_profile = sa.Table("sso_user_profile", metadata, autoload_with=connection) + connection.execute( + sso_user_profile.insert(), + _profile_values( + profile_id=second_profile_id, + user_id=stored_user_id, + provider="saml", + sso_user_id="subject-2", + timestamp=timestamp, + ), + ) + finally: + engine.dispose() + + 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_protocol_settings_migration.py b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py new file mode 100644 index 000000000000..2aacc3c3443f --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_protocol_settings_migration.py @@ -0,0 +1,159 @@ +"""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 = "e9f2a3b4c5d6" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret +# Must be a structurally valid client-secret envelope. The revision clears any +# value that is not one: pre-encryption rows held plaintext, and the model now +# rejects non-envelope values. See _sanitize_legacy_client_secrets. +_TEST_ENCRYPTED_SECRET = "lf-sso:v1:hkdf-sha256-v1:aes-256-gcm:AAAAAAAAAAAAAAAA:BBBBBBBBBBBBBBBBBBBBBBBB" # noqa: S105 # pragma: allowlist secret +_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} <= 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( + 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_rolling_compatibility_migration.py b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py new file mode 100644 index 000000000000..a632aa03e63f --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_rolling_compatibility_migration.py @@ -0,0 +1,559 @@ +"""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 +_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") + + +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_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) + + 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_secret_migration.py b/src/backend/tests/unit/alembic/test_sso_secret_migration.py new file mode 100644 index 000000000000..4d2cd8a54054 --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_secret_migration.py @@ -0,0 +1,196 @@ +"""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 = "e9f2a3b4c5d6" # pragma: allowlist secret +_TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret +_PLAINTEXT_SECRET = "migration-oidc-client-secret" # noqa: S105 # pragma: allowlist secret + + +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() + + +def test_sso_upgrade_clears_pre_encryption_plaintext_secret(db_url): # noqa: F811 + """A legacy plaintext secret is removed and its connection disabled. + + Before this revision ``client_secret_encrypted`` held the raw secret. The + model now rejects non-envelope values, so leaving one in place would make the + row unwritable through the ORM and undecryptable at login. The migration + fails safe instead: clear the plaintext, disable the connection, and require + an administrator to re-enter it. + """ + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + config_id = str(uuid4()) + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + connection.execute( + sso_config.insert(), + { + "id": config_id, + "provider": "oidc", + "provider_name": "Legacy Plaintext OIDC", + "enabled": True, + "enforce_sso": False, + "client_secret_encrypted": _PLAINTEXT_SECRET, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "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) + row = ( + connection.execute( + sa.select(sso_config.c.client_secret_encrypted, sso_config.c.enabled).where( + sso_config.c.id == config_id + ) + ) + .mappings() + .one() + ) + + assert row["client_secret_encrypted"] is None + assert not row["enabled"] + 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..a25f7473aa3e --- /dev/null +++ b/src/backend/tests/unit/alembic/test_sso_stable_connection_migration.py @@ -0,0 +1,264 @@ +"""Migration contract for stable SSO connection identity.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +import pytest +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 = "e9f2a3b4c5d6" # pragma: allowlist secret +_SLUG_INDEX = "uq_sso_config_slug" +_TEST_PASSWORD = "hashed" # noqa: S105 # pragma: allowlist secret + + +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()) + 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)) + 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, + }, + { + "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(), + { + "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" 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 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_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, + }, + ) + 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") + ) + 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() + ) + 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"] == "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_preserves_duplicate_provider_names(db_url): # noqa: F811 + alembic_cfg = _make_alembic_cfg(db_url) + command.upgrade(alembic_cfg, _PRIOR_REVISION) + + timestamp = datetime.now(timezone.utc) + config_ids = [str(uuid4()), str(uuid4())] + + engine = sa.create_engine(_engine_url(db_url)) + try: + metadata = sa.MetaData() + with engine.begin() as connection: + sso_config = sa.Table("sso_config", metadata, autoload_with=connection) + connection.execute( + sso_config.insert(), + [ + { + "id": config_ids[0], + "provider": "oidc", + "provider_name": "Shared OIDC", + "enabled": True, + "enforce_sso": False, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + { + "id": config_ids[1], + "provider": "oidc", + "provider_name": "Shared OIDC", + "enabled": True, + "enforce_sso": False, + "email_claim": "email", + "username_claim": "preferred_username", + "user_id_claim": "sub", + "created_at": timestamp, + "updated_at": timestamp, + }, + ], + ) + finally: + engine.dispose() + + 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/scripts/test_migrate_secret_key.py b/src/backend/tests/unit/scripts/test_migrate_secret_key.py index 1f4739724196..1e22e7412310 100644 --- a/src/backend/tests/unit/scripts/test_migrate_secret_key.py +++ b/src/backend/tests/unit/scripts/test_migrate_secret_key.py @@ -9,6 +9,7 @@ from uuid import uuid4 import pytest +from cryptography.exceptions import InvalidTag from cryptography.fernet import Fernet from httpx import AsyncClient from langflow.services.deps import get_settings_service @@ -92,6 +93,14 @@ def sqlite_db(): ) """) ) + conn.execute( + text(""" + CREATE TABLE sso_config ( + id TEXT PRIMARY KEY, + client_secret_encrypted TEXT + ) + """) + ) conn.commit() return engine @@ -150,6 +159,37 @@ def test_decrypt_with_wrong_key_fails(self, migrate_module, old_key, new_key): with pytest.raises(InvalidToken): migrate_module.decrypt_with_key(encrypted, new_key) + def test_sso_secret_rewrap_uses_replacement_key(self, migrate_module, old_key, new_key): + plaintext = "oidc-client-secret" + encrypted = migrate_module.encrypt_sso_secret_with_key(plaintext, old_key) + + migrated = migrate_module.migrate_sso_secret(encrypted, old_key, new_key) + + assert migrated is not None + assert migrated != encrypted + assert migrate_module.decrypt_sso_secret_with_key(migrated, new_key) == plaintext + 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" @@ -850,3 +890,18 @@ def test_verify_migration_empty_tables(self, migrate_module, sqlite_db, new_key) verified, failed = migrate_module.verify_migration(conn, new_key) assert verified == 0 assert failed == 0 + + def test_verify_migration_includes_sso_secrets(self, migrate_module, sqlite_db, new_key): + config_id = str(uuid4()) + encrypted = migrate_module.encrypt_sso_secret_with_key("oidc-secret", new_key) + with sqlite_db.begin() as conn: + conn.execute( + text("INSERT INTO sso_config (id, client_secret_encrypted) VALUES (:id, :secret)"), + {"id": config_id, "secret": encrypted}, + ) + + with sqlite_db.connect() as conn: + verified, failed = migrate_module.verify_migration(conn, new_key) + + assert verified == 1 + assert failed == 0 diff --git a/src/backend/tests/unit/test_auth_settings.py b/src/backend/tests/unit/test_auth_settings.py index 228b64d4c012..19170a30a5e6 100644 --- a/src/backend/tests/unit/test_auth_settings.py +++ b/src/backend/tests/unit/test_auth_settings.py @@ -36,11 +36,17 @@ def test_auto_login_true_preserves_configured_credentials_and_scrubs_password(tm assert settings.SUPERUSER_PASSWORD.get_secret_value() == "" -def test_default_superuser_password_is_empty(tmp_path: Path): +def test_default_superuser_password_is_empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + # conftest load_dotenv() can inject a developer .env (e.g. LANGFLOW_SUPERUSER=admin); + # this test asserts AuthSettings defaults, so clear any process overrides. + monkeypatch.delenv("LANGFLOW_SUPERUSER", raising=False) + monkeypatch.delenv("LANGFLOW_SUPERUSER_PASSWORD", raising=False) + cfg_dir = tmp_path.as_posix() settings = AuthSettings(CONFIG_DIR=cfg_dir) assert settings.SUPERUSER == DEFAULT_SUPERUSER - assert settings.SUPERUSER_PASSWORD.get_secret_value() == DEFAULT_SUPERUSER_PASSWORD.get_secret_value() == "" + assert settings.SUPERUSER_PASSWORD.get_secret_value() == "" + assert DEFAULT_SUPERUSER_PASSWORD.get_secret_value() == "" def test_short_secret_key_logs_upgrade_warning(tmp_path: Path): @@ -139,3 +145,50 @@ 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 TestSsoRedirectUrlSettings: + def test_sso_redirect_url_is_declared_with_description(self, tmp_path: Path): + settings = AuthSettings(CONFIG_DIR=tmp_path.as_posix()) + + assert settings.SSO_REDIRECT_URL is None + assert AuthSettings.model_fields["SSO_REDIRECT_URL"].description + + def test_sso_redirect_url_loads_relative_path_from_environment(self, tmp_path: Path, monkeypatch): + monkeypatch.setenv("LANGFLOW_SSO_REDIRECT_URL", "/api/v1/sso/callback") + + settings = AuthSettings(CONFIG_DIR=tmp_path.as_posix()) + + 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", + [ + "https://attacker.example/sso", + "//attacker.example/sso", + "///attacker.example/sso", + "\\\\attacker.example\\sso", + ], + ) + def test_sso_redirect_url_rejects_absolute_off_origin_url(self, off_origin_url: str, tmp_path: Path): + with pytest.raises(ValidationError, match="SSO_REDIRECT_URL"): + AuthSettings(CONFIG_DIR=tmp_path.as_posix(), SSO_REDIRECT_URL=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..3bfafa624799 100644 --- a/src/backend/tests/unit/test_sso_models.py +++ b/src/backend/tests/unit/test_sso_models.py @@ -4,11 +4,29 @@ CASCADE delete, unique constraints, and default values. """ +from datetime import datetime, timezone +from types import SimpleNamespace +from uuid import uuid4 + import pytest -from langflow.services.database.models.auth.sso import SSOConfig, SSOUserProfile +from langflow.services.database.models.auth import ( + SSOConfigCreate, + SSOConfigRead, + SSOConfigUpdate, + decrypt_sso_client_secret, + encrypt_sso_client_secret, +) +from langflow.services.database.models.auth.sso import ( + LegacyProviderSettings, + OIDCProviderSettings, + SSOConfig, + SSOSettings, + SSOUserProfile, +) from langflow.services.database.models.user.model import User -from sqlalchemy import event -from sqlalchemy.exc import IntegrityError +from pydantic import SecretStr, ValidationError +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 from sqlmodel import SQLModel, select @@ -16,6 +34,14 @@ # Placeholder for User.password in tests (not a real secret) _TEST_PASSWORD = "hashed" # noqa: S105 +_TEST_PLAINTEXT_SECRET = "oidc-client-secret" # noqa: S105 # pragma: allowlist secret + + +@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 +103,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 +195,561 @@ 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.enabled is True - assert config.enforce_sso is False - assert config.scopes == "openid email profile" + 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 False + 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.enabled is True - assert config.enforce_sso is False - assert config.scopes == "openid email profile" + assert config.protocol == "oidc" + assert config.provider_settings == OIDCProviderSettings() + assert config.enabled is False + 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, sso_secret_settings + ): + settings = SSOSettings(enforce_sso=True) + encrypted_secret = encrypt_sso_client_secret(_TEST_PLAINTEXT_SECRET, sso_secret_settings) + provider_settings = OIDCProviderSettings( + client_id="client-id", + discovery_url="https://idp.example.com/.well-known/openid-configuration", + ) + configs = [ + SSOConfig( + display_name="Second", + enabled=True, + sort_order=20, + client_secret_encrypted=encrypted_secret, + provider_settings=provider_settings, + ), + SSOConfig( + display_name="First", + enabled=True, + sort_order=10, + client_secret_encrypted=encrypted_secret, + provider_settings=provider_settings, + ), + ] + 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_client_secret_rejects_plaintext_core_updates(self, sso_async_session): + config = SSOConfig(display_name="Core update connection") + sso_async_session.add(config) + await sso_async_session.commit() + + with pytest.raises(StatementError, match="versioned SSO secret envelope"): + await sso_async_session.execute( + update(SSOConfig) + .where(SSOConfig.id == config.id) + .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) + + assert "client_secret_encrypted" not in config.model_dump() + assert encrypted not in config.model_dump_json() + assert encrypted not in repr(config) + + read_config = SSOConfigRead.model_validate(config) + assert "client_secret_encrypted" not in SSOConfigRead.model_fields + assert encrypted not in read_config.model_dump_json() + + async def test_create_schema_accepts_plaintext_secret_and_encrypts_for_persistence(self, sso_secret_settings): + create = SSOConfigCreate( + display_name="Created safely", + client_secret=SecretStr(_TEST_PLAINTEXT_SECRET), + ) + + assert "client_secret" not in create.model_dump() + config = create.to_model(sso_secret_settings) + assert config.client_secret_encrypted is not None + assert config.client_secret_encrypted != _TEST_PLAINTEXT_SECRET + assert decrypt_sso_client_secret(config.client_secret_encrypted, sso_secret_settings) == _TEST_PLAINTEXT_SECRET + + async def test_update_schema_encrypts_secret_and_validates_merged_enabled_state(self, sso_secret_settings): + config = SSOConfig(display_name="Update safely") + update_schema = SSOConfigUpdate( + enabled=True, + client_secret=SecretStr(_TEST_PLAINTEXT_SECRET), + provider_settings=OIDCProviderSettings( + client_id="client-id", + discovery_url="https://idp.example.com/.well-known/openid-configuration", + ), + ) + + update_schema.apply_to(config, sso_secret_settings) + + assert config.enabled is True + 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", + 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) + + config = SSOConfig(display_name="Initially disabled") + sso_async_session.add(config) + await sso_async_session.commit() + + with pytest.raises(ValueError, match="require a client_id"): + 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"): + SSOConfigCreate(display_name="Incomplete", enabled=True) + + async def test_provider_credentials_reject_blank_values_and_non_http_urls(self): + with pytest.raises(ValidationError, match="client_id must not be blank"): + OIDCProviderSettings(client_id=" \t ") + + for field_name in ("discovery_url", "token_endpoint", "authorization_endpoint", "jwks_uri", "issuer"): + with pytest.raises(ValidationError, match="absolute HTTP"): + OIDCProviderSettings(**{field_name: "file:///etc/passwd"}) + + with pytest.raises(ValidationError, match="client secret must not be blank"): + SSOConfigCreate(display_name="Blank secret", client_secret=SecretStr(" ")) + 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"): + SSOConfigCreate(display_name="Caller attributed", created_by=actor_id) + with pytest.raises(ValidationError, match="updated_by"): + SSOConfigUpdate(updated_by=actor_id) + + config = SSOConfigCreate(display_name="Server attributed").to_model( + sso_secret_settings, + actor_id=actor_id, + ) + assert config.created_by == actor_id + assert config.updated_by == actor_id + + new_actor_id = uuid4() + SSOConfigUpdate(display_name="Updated").apply_to( + config, + sso_secret_settings, + actor_id=new_actor_id, + ) + assert config.updated_by == new_actor_id + + async def test_standard_model_validation_is_safe_and_strict(self): + config = SSOConfig.model_validate({"display_name": "Validated"}) + assert config.display_name == "Validated" + assert config.enabled is False + + with pytest.raises(ValidationError, match="display_name"): + SSOConfig.model_validate({}) + with pytest.raises(ValidationError, match="valid string"): + SSOConfig.model_validate({"display_name": 123}) + with pytest.raises(ValidationError, match="extra_forbidden"): + SSOConfig.model_validate( + { + "display_name": "Malformed", + "provider_settings": {"protocol": "oidc", "unexpected": True}, + } + ) + + 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_core_update_cannot_enable_an_incomplete_config(self, sso_async_session): + config = SSOConfig(display_name="Core incomplete") + 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(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) + await sso_async_session.commit() + + with pytest.raises(IntegrityError, match=r"protocol_consistency|CHECK constraint failed"): + await sso_async_session.execute(update(SSOConfig).where(SSOConfig.id == config.id).values(protocol="saml")) + + async def test_core_update_cannot_change_slug(self, sso_async_session): + config = SSOConfig(display_name="Core slug") + sso_async_session.add(config) + await sso_async_session.commit() + + with pytest.raises(IntegrityError, match="immutable after insert"): + await sso_async_session.execute( + update(SSOConfig).where(SSOConfig.id == config.id).values(slug="sso-replacement") + ) + + 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_invalid_slug_assignment_is_rejected_before_insert(self): + config = SSOConfig(display_name="Pending") + with pytest.raises(ValueError, match="lowercase letters"): + config.slug = "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"}, + ) + + @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"): + config.protocol = "saml" + + async def test_provider_settings_assignment_rejects_protocol_mismatch(self): + config = SSOConfig(protocol="oidc", display_name="Valid") + # Bypass validates so we can exercise the provider_settings assignment check. + config.__dict__["protocol"] = "saml" + with pytest.raises(ValueError, match="does not match"): + config.provider_settings = OIDCProviderSettings() + + 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()) + + async def test_nested_provider_settings_are_immutable(self): + config = SSOConfig(display_name="Immutable settings") + with pytest.raises(ValidationError, match="frozen_instance"): + config.provider_settings.client_id = "new-client-id" 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..e3193dfe7afe --- /dev/null +++ b/src/backend/tests/unit/test_sso_secrets.py @@ -0,0 +1,93 @@ +"""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, + 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 # pragma: allowlist secret + + +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")) + + +@pytest.mark.parametrize("client_secret", ["", " ", "\t\n"]) +def test_sso_client_secret_rejects_blank_plaintext(client_secret): + with pytest.raises(SSOSecretError, match="must not be blank"): + encrypt_sso_client_secret(client_secret, _settings()) + + +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()) + + +@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/components/authorization/authAdminGuard/index.tsx b/src/frontend/src/components/authorization/authAdminGuard/index.tsx deleted file mode 100644 index 8cd9ee78390b..000000000000 --- a/src/frontend/src/components/authorization/authAdminGuard/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { useContext } from "react"; -import { AuthContext } from "@/contexts/authContext"; -import { CustomNavigate } from "@/customization/components/custom-navigate"; -import { LoadingPage } from "@/pages/LoadingPage"; -import useAuthStore from "@/stores/authStore"; - -export const ProtectedAdminRoute = ({ children }) => { - const { userData } = useContext(AuthContext); - const isAuthenticated = useAuthStore((state) => state.isAuthenticated); - const autoLogin = useAuthStore((state) => state.autoLogin); - const isAdmin = useAuthStore((state) => state.isAdmin); - - if (!isAuthenticated) { - return ; - } else if ((userData && !isAdmin) || autoLogin) { - return ; - } else { - return children; - } -}; 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..5f372e312d16 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 @@ -32,9 +32,8 @@ jest.mock("@/customization/feature-flags", () => ({ jest.mock("@/stores/authStore", () => ({ __esModule: true, - default: ( - selector: (state: { isAdmin: boolean; autoLogin: boolean }) => unknown, - ) => selector({ isAdmin: false, autoLogin: false }), + default: (selector: (state: { autoLogin: boolean }) => unknown) => + selector({ autoLogin: false }), })); jest.mock("@/stores/darkStore", () => ({ 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..d90bfd463cf6 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/AccountMenu/index.tsx @@ -9,6 +9,7 @@ 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 { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; @@ -32,11 +33,7 @@ export const AccountMenu = () => { const navigate = useCustomNavigate(); const { mutate: mutationLogout } = useLogout(); const hideLogoutButton = useUtilityStore((state) => state.hideLogoutButton); - - const { isAdmin, autoLogin } = useAuthStore((state) => ({ - isAdmin: state.isAdmin, - autoLogin: state.autoLogin, - })); + const autoLogin = useAuthStore((state) => state.autoLogin); const handleLogout = () => { mutationLogout(); @@ -103,22 +100,7 @@ export const AccountMenu = () => { - {isAdmin && !autoLogin && ( -
- { - navigate("/admin"); - }} - > - - {t("account.adminPage")} - - -
- )} + navigate(path)} /> { + it("does not render admin navigation", () => { + const { container } = render( + , + ); + + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the OSS product name as the login brand", () => { + render(); + + expect(screen.getByText("Langflow")).toBeInTheDocument(); + }); + + it("passes signup prompt children through", () => { + render( + +

Don't have an account? Sign Up

+
, + ); + + expect( + screen.getByText("Don't have an account? Sign Up"), + ).toBeInTheDocument(); + }); + + it("renders no SSO login options", () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it("never skips auth refresh", () => { + const error = { + response: { status: 403, data: { detail: "must_change_password" } }, + } as AxiosError; + + expect(customShouldSkipAuthRefresh(error)).toBe(false); + }); +}); 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..22b1d7423e45 --- /dev/null +++ b/src/frontend/src/customization/components/custom-admin-page-menu-item.tsx @@ -0,0 +1,9 @@ +export interface CustomAdminPageMenuItemProps { + onNavigate: (path: string) => void; +} + +// OSS has no admin page. Enterprise overlays this seam with the /admin-ee entry. +export const CustomAdminPageMenuItem = (_: CustomAdminPageMenuItemProps) => + null; + +export default CustomAdminPageMenuItem; diff --git a/src/frontend/src/customization/components/custom-login-brand-title.tsx b/src/frontend/src/customization/components/custom-login-brand-title.tsx new file mode 100644 index 000000000000..d41268e3dcee --- /dev/null +++ b/src/frontend/src/customization/components/custom-login-brand-title.tsx @@ -0,0 +1,5 @@ +// OSS default product name on the login page. Downstream overlays replace this +// with an edition-specific brand (e.g. "IBM Langflow") without changing layout. +export default function CustomLoginBrandTitle() { + return <>Langflow; +} diff --git a/src/frontend/src/customization/components/custom-login-form-gate.tsx b/src/frontend/src/customization/components/custom-login-form-gate.tsx new file mode 100644 index 000000000000..68b5a0170fc9 --- /dev/null +++ b/src/frontend/src/customization/components/custom-login-form-gate.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export interface CustomLoginFormGateProps { + children: ReactNode; +} + +// OSS no-op: always renders the local username/password form. +// Downstream overlays can return null (or alternate UI) to hide the form on +// the primary login route while still showing it on a recovery route. +export default function CustomLoginFormGate({ + children, +}: CustomLoginFormGateProps) { + return <>{children}; +} diff --git a/src/frontend/src/customization/components/custom-login-signup-prompt.tsx b/src/frontend/src/customization/components/custom-login-signup-prompt.tsx new file mode 100644 index 000000000000..22a76897e6af --- /dev/null +++ b/src/frontend/src/customization/components/custom-login-signup-prompt.tsx @@ -0,0 +1,14 @@ +import type { ReactNode } from "react"; + +export interface CustomLoginSignupPromptProps { + children: ReactNode; +} + +// OSS no-op: always shows the "Don't have an account? Sign Up" block. +// Downstream overlays (e.g. Enterprise) can return null to suppress public +// self-registration on the login page while leaving the OSS route intact. +export default function CustomLoginSignupPrompt({ + children, +}: CustomLoginSignupPromptProps) { + return <>{children}; +} 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/customization/utils/custom-auth-routes-store.tsx b/src/frontend/src/customization/utils/custom-auth-routes-store.tsx new file mode 100644 index 000000000000..15b8a1b42e18 --- /dev/null +++ b/src/frontend/src/customization/utils/custom-auth-routes-store.tsx @@ -0,0 +1,9 @@ +// OSS no-op; downstream overlays register additional pre-auth routes here +// (e.g. an SSO enterprise layer's `/login/break-glass`). Mounted as a sibling +// of `login` / `signup` / `login/admin` in routes.tsx — outside the +// authenticated route tree, so it's reachable before sign-in. +export const CustomAuthRoutesStore = () => { + return null; +}; + +export default CustomAuthRoutesStore; diff --git a/src/frontend/src/customization/utils/custom-should-skip-auth-refresh.ts b/src/frontend/src/customization/utils/custom-should-skip-auth-refresh.ts new file mode 100644 index 000000000000..ad3c58e1af90 --- /dev/null +++ b/src/frontend/src/customization/utils/custom-should-skip-auth-refresh.ts @@ -0,0 +1,11 @@ +import type { AxiosError } from "axios"; + +/** + * OSS no-op: never skip the standard 401/403 refresh/logout path. + * Downstream overlays can return true for edition-specific 403s that mean + * "authenticated but gated" rather than "session invalid" — e.g. a forced + * password change — so the interceptor does not log the user out. + */ +export function customShouldSkipAuthRefresh(_error: AxiosError): boolean { + return false; +} diff --git a/src/frontend/src/locales/de.json b/src/frontend/src/locales/de.json index 7abe1c9c1e24..d1770d60679f 100644 --- a/src/frontend/src/locales/de.json +++ b/src/frontend/src/locales/de.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "Variable hinzufügen", "accordion.fullscreen": "Vollbildschirm", - "account.adminPage": "Verwaltungsseite", "account.discord": "Discord", "account.docs": "Dokumente", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "{{action}} entfernen", "actionPicker.rename": "{{action}} umbenennen", "actionPicker.renamedConnectionRemoved": "In „ \"{{previous}}\" “ umbenannt – die bisherige Verknüpfung wurde entfernt.", - "admin.attentionTitle": "Achtung!", - "admin.backButton": "Zurück", - "admin.clearSearch": "Suche löschen", - "admin.cancelButton": "Abbrechen", - "admin.cannotDeactivateSelf": "Sie können Ihr eigenes Konto nicht deaktivieren", - "admin.columnActive": "Aktiv", - "admin.columnCreatedAt": "Erstellt am", - "admin.columnId": "ID", - "admin.columnSuperuser": "Superuser", - "admin.columnUpdatedAt": "Aktualisiert am", - "admin.columnUsername": "Benutzername", - "admin.confirmButton": "Bestätigen", - "admin.confirmPasswordLabel": "Kennwort bestätigen", - "admin.confirmPasswordRequired": "Bitte bestätigen Sie Ihr Passwort", - "admin.deleteTitle": "Löschen", - "admin.deleteUserConfirmContent": "Möchten Sie diesen Benutzer wirklich löschen? Dieser Vorgang kann nicht rückgängig gemacht werden.", - "admin.deleteUserHeader": "Benutzer löschen", "admin.editTitle": "Bearbeiten", - "admin.editUserConfirmContent": "Sind Sie sich ganz sicher, dass Sie diese Änderungen an diesem Benutzer vornehmen möchten?", - "admin.newUserButton": "Neuer Benutzer", - "admin.newUserHeader": "Neuen Benutzer hinzufügen", - "admin.newUserTitle": "Neuer Benutzer", - "admin.noUsersRegistered": "Es sind keine Benutzer registriert.", - "admin.passwordLabel": "Kennwort", - "admin.passwordRequired": "Bitte geben Sie ein Passwort ein", - "admin.passwordsDoNotMatch": "Kennwörter stimmen nicht überein", - "admin.saveButton": "Speichern", - "admin.searchPlaceholder": "Benutzername suchen", - "admin.usernameLabel": "Benutzername", - "admin.usernamePlaceholder": "Benutzername", - "admin.usernameRequired": "Bitte geben Sie Ihren Benutzernamen ein", "agentTab.addExample": "Beispiel hinzufügen", "agentTab.addTag": "Tag hinzufügen", "agentTab.addressLabel": "Adresse", @@ -257,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", @@ -1738,8 +1708,6 @@ "project.newName": "Neues Projekt", "project.starterName": "Einsteigerprojekt", "settings.addNewKey": "Neu hinzufügen", - "settings.adminDescription": "Nutzen Sie diesen Abschnitt, um alle Anwendungsbenutzer effizient zu verwalten. Von hier aus können Sie Benutzerkonten nahtlos verwalten.", - "settings.adminTitle": "Verwaltungsseite", "settings.apiKeys.columnCreated": "Erstellungsdatum", "settings.apiKeys.columnExpires": "Gültig bis", "settings.apiKeys.columnKey": "Schlüssel", diff --git a/src/frontend/src/locales/en.json b/src/frontend/src/locales/en.json index d782a6d0d6a4..88e203317236 100644 --- a/src/frontend/src/locales/en.json +++ b/src/frontend/src/locales/en.json @@ -174,8 +174,6 @@ "toolsModal.parametersSubtitle": "Manage inputs for this tool", "toolsModal.inputControlledByAgent": "Input controlled by the agent", "toolsModal.close": "Close", - "settings.adminTitle": "Admin Page", - "settings.adminDescription": "Navigate through this section to efficiently oversee all application users. From here, you can seamlessly manage user accounts.", "settings.apiPageParagraph": "Your secret Langflow API keys are listed below. Do not share your API key with others, or expose it in the browser or other client-side code.", "settings.noKeysAssigned": "This user does not have any keys assigned at the moment.", "settings.lastUsedSpan1": "The last time this key was used.", @@ -270,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", @@ -366,37 +365,7 @@ "settings.dbProviders.fields.OPENSEARCH_USE_SSL.helperText": "Connect over HTTPS. Disable for plain-HTTP clusters. Defaults to the URL scheme when unset.", "settings.dbProviders.fields.OPENSEARCH_VERIFY_CERTS.label": "Verify TLS certificate", "settings.dbProviders.fields.OPENSEARCH_VERIFY_CERTS.helperText": "Disable for self-signed certificates (the default OpenSearch container ships one).", - "admin.searchPlaceholder": "Search Username", - "admin.newUserButton": "New User", - "admin.newUserTitle": "New User", - "admin.newUserHeader": "Add a new user", - "admin.cancelButton": "Cancel", - "admin.saveButton": "Save", - "admin.noUsersRegistered": "No users registered.", - "admin.columnId": "Id", - "admin.columnUsername": "Username", - "admin.columnActive": "Active", - "admin.columnSuperuser": "Superuser", - "admin.columnCreatedAt": "Created At", - "admin.columnUpdatedAt": "Updated At", "admin.editTitle": "Edit", - "admin.deleteTitle": "Delete", - "admin.deleteUserHeader": "Delete User", - "admin.attentionTitle": "Attention!", - "admin.backButton": "Back", - "admin.clearSearch": "Clear search", - "admin.confirmButton": "Confirm", - "admin.editUserConfirmContent": "Are you completely confident about the changes you are making to this user?", - "admin.deleteUserConfirmContent": "Are you sure you want to delete this user? This action cannot be undone.", - "admin.cannotDeactivateSelf": "You cannot deactivate your own account", - "admin.usernameLabel": "Username", - "admin.usernamePlaceholder": "Username", - "admin.usernameRequired": "Please enter your username", - "admin.passwordLabel": "Password", - "admin.passwordRequired": "Please enter a password", - "admin.passwordsDoNotMatch": "Passwords do not match", - "admin.confirmPasswordLabel": "Confirm password", - "admin.confirmPasswordRequired": "Please confirm your password", "modal.cancelButton": "Cancel", "modal.saveButton": "Save", "modal.secretKey.doneButton": "Done", @@ -503,7 +472,6 @@ "account.latest": "(latest)", "account.updateAvailable": "(update available)", "account.settings": "Settings", - "account.adminPage": "Admin Page", "account.docs": "Docs", "account.github": "GitHub", "account.discord": "Discord", diff --git a/src/frontend/src/locales/es.json b/src/frontend/src/locales/es.json index 6ac4632dcbac..ec92e384c411 100644 --- a/src/frontend/src/locales/es.json +++ b/src/frontend/src/locales/es.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "Añadir variable", "accordion.fullscreen": "Pantalla completa", - "account.adminPage": "Página de administración", "account.discord": "Discord", "account.docs": "Documentos", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "Eliminar « {{action}} »", "actionPicker.rename": "Cambiar el nombre de « {{action}} »", "actionPicker.renamedConnectionRemoved": "Se ha cambiado el nombre a « \"{{previous}}\" »; se ha eliminado su enlace anterior.", - "admin.attentionTitle": "Atención", - "admin.backButton": "Atrás", - "admin.clearSearch": "Borrar búsqueda", - "admin.cancelButton": "Cancelar", - "admin.cannotDeactivateSelf": "No puedes desactivar tu propia cuenta", - "admin.columnActive": "Activo", - "admin.columnCreatedAt": "Creado el", - "admin.columnId": "ID", - "admin.columnSuperuser": "Superusuario", - "admin.columnUpdatedAt": "Actualizado a las", - "admin.columnUsername": "Nombre de usuario", - "admin.confirmButton": "Confirmar", - "admin.confirmPasswordLabel": "Confirmar contraseña", - "admin.confirmPasswordRequired": "Confirma tu contraseña", - "admin.deleteTitle": "Suprimir", - "admin.deleteUserConfirmContent": "¿Estás seguro de que quieres eliminar a este usuario? Esta acción no se puede deshacer.", - "admin.deleteUserHeader": "Suprimir usuario", "admin.editTitle": "Editar", - "admin.editUserConfirmContent": "¿Estás completamente seguro de los cambios que estás realizando en este usuario?", - "admin.newUserButton": "Nuevo usuario", - "admin.newUserHeader": "Añadir un nuevo usuario", - "admin.newUserTitle": "Nuevo usuario", - "admin.noUsersRegistered": "No hay usuarios registrados.", - "admin.passwordLabel": "Contraseña", - "admin.passwordRequired": "Introduzca una contraseña", - "admin.passwordsDoNotMatch": "Las contraseñas no coinciden", - "admin.saveButton": "Guardar", - "admin.searchPlaceholder": "Buscar nombre de usuario", - "admin.usernameLabel": "Nombre de usuario", - "admin.usernamePlaceholder": "Nombre de usuario", - "admin.usernameRequired": "Introduce tu nombre de usuario", "agentTab.addExample": "Añadir ejemplo", "agentTab.addTag": "Añadir etiqueta", "agentTab.addressLabel": "Dirección", @@ -257,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", @@ -1738,8 +1708,6 @@ "project.newName": "Nuevo proyecto", "project.starterName": "Proyecto de inicio", "settings.addNewKey": "Añadir nuevo", - "settings.adminDescription": "Navega por esta sección para gestionar de forma eficaz a todos los usuarios de la aplicación. Desde aquí, puedes gestionar fácilmente las cuentas de usuario.", - "settings.adminTitle": "Página de administración", "settings.apiKeys.columnCreated": "Creado", "settings.apiKeys.columnExpires": "Caduca", "settings.apiKeys.columnKey": "Clave", diff --git a/src/frontend/src/locales/fr.json b/src/frontend/src/locales/fr.json index 17ffe2de6d46..fa5bc245e9d0 100644 --- a/src/frontend/src/locales/fr.json +++ b/src/frontend/src/locales/fr.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "Ajouter la variable", "accordion.fullscreen": "Plein écran", - "account.adminPage": "Page Admin", "account.discord": "Discord", "account.docs": "Documents", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "Supprimer « {{action}} »", "actionPicker.rename": "Renommer « {{action}} »", "actionPicker.renamedConnectionRemoved": "Renommé « \"{{previous}}\" » — son lien précédent a été supprimé.", - "admin.attentionTitle": "Attention !", - "admin.backButton": "Retour", - "admin.clearSearch": "Effacer la recherche", - "admin.cancelButton": "Annuler", - "admin.cannotDeactivateSelf": "Vous ne pouvez pas désactiver votre propre compte", - "admin.columnActive": "Actif", - "admin.columnCreatedAt": "Créé à", - "admin.columnId": "Id", - "admin.columnSuperuser": "Superutilisateur", - "admin.columnUpdatedAt": "Mis à jour à", - "admin.columnUsername": "Nom d'utilisateur", - "admin.confirmButton": "Confirmer", - "admin.confirmPasswordLabel": "Confirmation du mot de passe", - "admin.confirmPasswordRequired": "Veuillez confirmer votre mot de passe", - "admin.deleteTitle": "Supprimer", - "admin.deleteUserConfirmContent": "Êtes-vous sûr de vouloir supprimer cet utilisateur? Cette action ne peut pas être annulée.", - "admin.deleteUserHeader": "Supprimer l'utilisateur", "admin.editTitle": "Editer", - "admin.editUserConfirmContent": "Êtes-vous tout à fait sûr des modifications que vous apportez à ce compte utilisateur?", - "admin.newUserButton": "Nouvel utilisateur", - "admin.newUserHeader": "Ajouter un nouvel utilisateur", - "admin.newUserTitle": "Nouvel utilisateur", - "admin.noUsersRegistered": "Aucun utilisateur enregistré.", - "admin.passwordLabel": "Mot de passe", - "admin.passwordRequired": "Veuillez saisir un mot de passe", - "admin.passwordsDoNotMatch": "Les mots de passe ne correspondent pas", - "admin.saveButton": "Sauvegarder", - "admin.searchPlaceholder": "Rechercher un nom d'utilisateur", - "admin.usernameLabel": "Nom d'utilisateur", - "admin.usernamePlaceholder": "Nom d'utilisateur", - "admin.usernameRequired": "Veuillez saisir votre nom d'utilisateur", "agentTab.addExample": "Ajouter un exemple", "agentTab.addTag": "Ajout d'une étiquette", "agentTab.addressLabel": "Adresse", @@ -257,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", @@ -1738,8 +1708,6 @@ "project.newName": "Nouveau projet", "project.starterName": "Projet de démarrage", "settings.addNewKey": "Ajouter un nouvel élément", - "settings.adminDescription": "Parcourez cette section pour gérer efficacement tous les utilisateurs de l'application. À partir d'ici, vous pouvez gérer facilement les comptes utilisateurs.", - "settings.adminTitle": "Page Admin", "settings.apiKeys.columnCreated": "Date de création", "settings.apiKeys.columnExpires": "Expire", "settings.apiKeys.columnKey": "Clé", diff --git a/src/frontend/src/locales/ja.json b/src/frontend/src/locales/ja.json index 3c3fa2c2c3d7..ee1892752eeb 100644 --- a/src/frontend/src/locales/ja.json +++ b/src/frontend/src/locales/ja.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "変数の追加", "accordion.fullscreen": "フルスクリーン", - "account.adminPage": "管理ページ", "account.discord": "ディスコード", "account.docs": "文書", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "{{action}} を削除する", "actionPicker.rename": "{{action}} の名前を変更する", "actionPicker.renamedConnectionRemoved": "「 \"{{previous}}\" 」に名称を変更しました。以前のリンクは削除されました。", - "admin.attentionTitle": "注意", - "admin.backButton": "戻る", - "admin.clearSearch": "検索をクリア", - "admin.cancelButton": "キャンセル", - "admin.cannotDeactivateSelf": "ご自身のアカウントを無効にすることはできません", - "admin.columnActive": "アクティブ", - "admin.columnCreatedAt": "作成日時", - "admin.columnId": "ID", - "admin.columnSuperuser": "スーパーユーザー", - "admin.columnUpdatedAt": "更新日時", - "admin.columnUsername": "ユーザー名", - "admin.confirmButton": "確認", - "admin.confirmPasswordLabel": "パスワードの確認", - "admin.confirmPasswordRequired": "パスワードの確認をお願いします", - "admin.deleteTitle": "削除", - "admin.deleteUserConfirmContent": "このユーザーを削除してもよろしいですか? この操作は元に戻せません。", - "admin.deleteUserHeader": "ユーザーの削除", "admin.editTitle": "編集", - "admin.editUserConfirmContent": "このユーザーに対して行う変更について、本当に問題ないと確信していますか?", - "admin.newUserButton": "新規ユーザー", - "admin.newUserHeader": "新しいユーザーを追加する", - "admin.newUserTitle": "新規ユーザー", - "admin.noUsersRegistered": "登録ユーザーはいません。", - "admin.passwordLabel": "パスワード", - "admin.passwordRequired": "パスワードを入力してください", - "admin.passwordsDoNotMatch": "パスワードが一致しません", - "admin.saveButton": "保存", - "admin.searchPlaceholder": "ユーザー名を検索", - "admin.usernameLabel": "ユーザー名", - "admin.usernamePlaceholder": "ユーザー名", - "admin.usernameRequired": "ユーザー名を入力してください", "agentTab.addExample": "例の追加", "agentTab.addTag": "タグの追加", "agentTab.addressLabel": "アドレス", @@ -257,6 +226,7 @@ "auth.saveWithApiCheckbox": "私のAPIキーを使って節約しましょう", "auth.signInButton": "サインイン", "auth.signInLink": "サインイン", + "auth.signInPrompt": "すでにアカウントをお持ちですか?サインイン", "auth.signUpLink": "登録", "auth.signUpSuccess": "アカウントが作成されました! 管理者の承認をお待ちください。", "auth.signupButton": "登録", @@ -1738,8 +1708,6 @@ "project.newName": "新規プロジェクト", "project.starterName": "入門プロジェクト", "settings.addNewKey": "新規の追加", - "settings.adminDescription": "このセクションを参照して、すべてのアプリケーションユーザーを効率的に管理してください。 ここから、ユーザーアカウントをシームレスに管理できます。", - "settings.adminTitle": "管理ページ", "settings.apiKeys.columnCreated": "作成済み", "settings.apiKeys.columnExpires": "有効期限", "settings.apiKeys.columnKey": "キー", diff --git a/src/frontend/src/locales/pt.json b/src/frontend/src/locales/pt.json index 2cab71700fd1..b87c11cccc57 100644 --- a/src/frontend/src/locales/pt.json +++ b/src/frontend/src/locales/pt.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "Incluir variável", "accordion.fullscreen": "Tela cheia", - "account.adminPage": "Página de administração", "account.discord": "Discord", "account.docs": "Documentos", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "Remova o {{action}}", "actionPicker.rename": "Renomear {{action}}", "actionPicker.renamedConnectionRemoved": "Renomeado para “ \"{{previous}}\" ” — sua conexão anterior foi removida.", - "admin.attentionTitle": "Atenção!", - "admin.backButton": "Voltar", - "admin.clearSearch": "Limpar pesquisa", - "admin.cancelButton": "Cancelar", - "admin.cannotDeactivateSelf": "Você não pode desativar sua própria conta", - "admin.columnActive": "Ativo", - "admin.columnCreatedAt": "Criado em", - "admin.columnId": "ID", - "admin.columnSuperuser": "Superusuário", - "admin.columnUpdatedAt": "Atualizado em", - "admin.columnUsername": "Nome do usuário", - "admin.confirmButton": "Confirmar", - "admin.confirmPasswordLabel": "Confirmar senha", - "admin.confirmPasswordRequired": "Confirme sua senha", - "admin.deleteTitle": "Excluir", - "admin.deleteUserConfirmContent": "Tem certeza de que deseja excluir este usuário? Esta ação não pode ser revertida.", - "admin.deleteUserHeader": "Excluir usuário", "admin.editTitle": "Editar", - "admin.editUserConfirmContent": "Você tem certeza absoluta das alterações que está fazendo neste usuário?", - "admin.newUserButton": "Novo usuário", - "admin.newUserHeader": "Adicionar um novo usuário", - "admin.newUserTitle": "Novo usuário", - "admin.noUsersRegistered": "Não há usuários registrados.", - "admin.passwordLabel": "Senha", - "admin.passwordRequired": "Digite uma senha", - "admin.passwordsDoNotMatch": "As senhas não correspondem", - "admin.saveButton": "Salvar", - "admin.searchPlaceholder": "Pesquisar nome de usuário", - "admin.usernameLabel": "Nome do usuário", - "admin.usernamePlaceholder": "Nome do usuário", - "admin.usernameRequired": "Digite seu nome de usuário", "agentTab.addExample": "Incluir exemplo", "agentTab.addTag": "Incluir tag", "agentTab.addressLabel": "Endereço", @@ -257,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", @@ -1738,8 +1708,6 @@ "project.newName": "Novo projeto", "project.starterName": "Projeto Inicial", "settings.addNewKey": "Incluir novo", - "settings.adminDescription": "Navegue por esta seção para gerenciar com eficiência todos os usuários do aplicativo. A partir daqui, você pode gerenciar contas de usuário com facilidade.", - "settings.adminTitle": "Página de administração", "settings.apiKeys.columnCreated": "Criado em", "settings.apiKeys.columnExpires": "Expira", "settings.apiKeys.columnKey": "Chave", diff --git a/src/frontend/src/locales/zh-Hans.json b/src/frontend/src/locales/zh-Hans.json index 4fab9708d3f2..27cbc9f19aa4 100644 --- a/src/frontend/src/locales/zh-Hans.json +++ b/src/frontend/src/locales/zh-Hans.json @@ -1,7 +1,6 @@ { "accordion.addVariable": "添加变量", "accordion.fullscreen": "全屏", - "account.adminPage": "管理页面", "account.discord": "Discord", "account.docs": "文档", "account.github": "GitHub", @@ -22,37 +21,7 @@ "actionPicker.remove": "卸载 {{action}}", "actionPicker.rename": "重命名 {{action}}", "actionPicker.renamedConnectionRemoved": "已将名称更改为 \"{{previous}}\" ——其之前的关联已被移除。", - "admin.attentionTitle": "注意!", - "admin.backButton": "返回", - "admin.clearSearch": "清除搜索", - "admin.cancelButton": "取消", - "admin.cannotDeactivateSelf": "您无法注销自己的账户", - "admin.columnActive": "活动", - "admin.columnCreatedAt": "创建时间", - "admin.columnId": "标识", - "admin.columnSuperuser": "超级用户", - "admin.columnUpdatedAt": "更新于", - "admin.columnUsername": "用户名", - "admin.confirmButton": "确认", - "admin.confirmPasswordLabel": "确认密码", - "admin.confirmPasswordRequired": "请确认您的密码", - "admin.deleteTitle": "删除", - "admin.deleteUserConfirmContent": "您确定要删除此用户吗? 此操作无法撤销。", - "admin.deleteUserHeader": "删除用户", "admin.editTitle": "编辑", - "admin.editUserConfirmContent": "您对对该用户所做的更改完全确定吗?", - "admin.newUserButton": "新用户", - "admin.newUserHeader": "添加新用户", - "admin.newUserTitle": "新用户", - "admin.noUsersRegistered": "尚未有用户注册。", - "admin.passwordLabel": "密码", - "admin.passwordRequired": "请输入密码", - "admin.passwordsDoNotMatch": "密码不匹配", - "admin.saveButton": "保存", - "admin.searchPlaceholder": "搜索用户名", - "admin.usernameLabel": "用户名", - "admin.usernamePlaceholder": "用户名", - "admin.usernameRequired": "请输入您的用户名", "agentTab.addExample": "添加示例", "agentTab.addTag": "添加标记", "agentTab.addressLabel": "地址", @@ -257,6 +226,7 @@ "auth.saveWithApiCheckbox": "使用我的 API 密钥节省开支", "auth.signInButton": "登录", "auth.signInLink": "登录", + "auth.signInPrompt": "已有账户?登录", "auth.signUpLink": "注册", "auth.signUpSuccess": "账户已创建! 请等待管理员激活。", "auth.signupButton": "注册", @@ -1738,8 +1708,6 @@ "project.newName": "新建项目", "project.starterName": "入门项目", "settings.addNewKey": "添加新项", - "settings.adminDescription": "浏览本节,以便高效管理所有应用程序用户。 在此,您可以轻松管理用户账户。", - "settings.adminTitle": "管理页面", "settings.apiKeys.columnCreated": "创建时间", "settings.apiKeys.columnExpires": "到期", "settings.apiKeys.columnKey": "键", diff --git a/src/frontend/src/modals/userManagementModal/index.tsx b/src/frontend/src/modals/userManagementModal/index.tsx deleted file mode 100644 index 09e64167c403..000000000000 --- a/src/frontend/src/modals/userManagementModal/index.tsx +++ /dev/null @@ -1,337 +0,0 @@ -import * as Form from "@radix-ui/react-form"; -import { useContext, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import IconComponent from "@/components/common/genericIconComponent"; -import ShadTooltip from "@/components/common/shadTooltipComponent"; -import InputComponent from "@/components/core/parameterRenderComponent/components/inputComponent"; -import { getRequiredFieldError } from "@/utils/authErrorMessages"; -import { Button } from "../../components/ui/button"; -import { Checkbox } from "../../components/ui/checkbox"; -import { CONTROL_NEW_USER } from "../../constants/constants"; -import { AuthContext } from "../../contexts/authContext"; -import type { - inputHandlerEventType, - UserInputType, - UserManagementType, -} from "../../types/components"; -import BaseModal from "../baseModal"; - -export default function UserManagementModal({ - title, - titleHeader, - cancelText, - confirmationText, - children, - icon, - data, - index, - onConfirm, - asChild, -}: UserManagementType) { - const { t } = useTranslation(); - const [open, setOpen] = useState(false); - const [password, setPassword] = useState(data?.password ?? ""); - const [username, setUserName] = useState(data?.username ?? ""); - const [confirmPassword, setConfirmPassword] = useState(data?.password ?? ""); - const [isActive, setIsActive] = useState(data?.is_active ?? false); - const [isSuperUser, setIsSuperUser] = useState(data?.is_superuser ?? false); - const [inputState, setInputState] = useState(CONTROL_NEW_USER); - const [submitAttempted, setSubmitAttempted] = useState(false); - const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false); - const { userData } = useContext(AuthContext); - - // Password is only required when creating a user; editing leaves it blank to - // keep the existing password. Errors surface after a submit attempt (or once - // the confirm field is blurred for the mismatch case), matching the - // login/signup auth surfaces. - const isCreating = !data; - const passwordMismatch = - password !== "" && confirmPassword !== "" && password !== confirmPassword; - const passwordError = isCreating - ? getRequiredFieldError( - submitAttempted, - password, - t("admin.passwordRequired"), - ) - : undefined; - const shouldShowMismatch = - passwordMismatch && (submitAttempted || confirmPasswordTouched); - const confirmPasswordRequiredError = isCreating - ? getRequiredFieldError( - submitAttempted, - confirmPassword, - t("admin.confirmPasswordRequired"), - ) - : undefined; - const confirmPasswordError = - confirmPasswordRequiredError ?? - (shouldShowMismatch ? t("admin.passwordsDoNotMatch") : undefined); - - function handleInput({ - target: { name, value }, - }: inputHandlerEventType): void { - setInputState((prev) => ({ ...prev, [name]: value })); - } - - useEffect(() => { - if (open) { - if (!data) { - resetForm(); - } else { - setUserName(data.username); - setIsActive(data.is_active); - setIsSuperUser(data.is_superuser); - - handleInput({ target: { name: "username", value: data.username } }); - handleInput({ target: { name: "is_active", value: data.is_active } }); - handleInput({ - target: { name: "is_superuser", value: data.is_superuser }, - }); - } - } - }, [open]); - - function resetForm() { - setPassword(""); - setUserName(""); - setConfirmPassword(""); - setIsActive(false); - setIsSuperUser(false); - setSubmitAttempted(false); - setConfirmPasswordTouched(false); - } - - return ( - - {children} - - {title} - - - { - setSubmitAttempted(true); - if (password !== confirmPassword) { - event.preventDefault(); - return; - } - resetForm(); - onConfirm(1, inputState); - setOpen(false); - event.preventDefault(); - }} - > -
- -
- - {t("admin.usernameLabel")}{" "} - * - -
- - { - handleInput({ target: { name: "username", value } }); - setUserName(value); - }} - value={username} - className="primary-input" - required - placeholder={t("admin.usernamePlaceholder")} - /> - - - {t("admin.usernameRequired")} - -
- -
-
- - - {t("admin.passwordLabel")} - * - - { - handleInput({ target: { name: "password", value } }); - setPassword(value); - }} - required={isCreating} - placeholder={t("admin.passwordLabel")} - className="w-full" - inputProps={{ - "aria-describedby": passwordError - ? "new-user-password-error" - : undefined, - "aria-invalid": Boolean(passwordError) || undefined, - }} - /> - {passwordError && ( - - )} - -
- -
- - - {t("admin.confirmPasswordLabel")} - * - - setConfirmPassword(value)} - onBlur={() => setConfirmPasswordTouched(true)} - required={isCreating} - placeholder={t("admin.confirmPasswordLabel")} - className="w-full" - inputProps={{ - "aria-describedby": confirmPasswordError - ? "new-user-confirm-password-error" - : undefined, - "aria-invalid": - Boolean(confirmPasswordError) || undefined, - }} - /> - {confirmPasswordError && ( - - )} - -
-
-
- -
- - {t("admin.columnActive")} - - {data?.id === userData?.id ? ( - - - - - - ) : ( - - { - handleInput({ target: { name: "is_active", value } }); - setIsActive(value); - }} - /> - - )} -
-
- {userData?.is_superuser && ( - -
- - {t("admin.columnSuperuser")} - - - { - handleInput({ - target: { name: "is_superuser", value }, - }); - setIsSuperUser(value); - }} - /> - -
-
- )} -
-
- -
- - - - - -
-
-
-
- ); -} diff --git a/src/frontend/src/pages/AdminPage/index.tsx b/src/frontend/src/pages/AdminPage/index.tsx deleted file mode 100644 index a277e0fd2855..000000000000 --- a/src/frontend/src/pages/AdminPage/index.tsx +++ /dev/null @@ -1,547 +0,0 @@ -import { cloneDeep, debounce } from "lodash"; -import { useCallback, useContext, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import PaginatorComponent from "@/components/common/paginatorComponent"; -import { - useAddUser, - useDeleteUsers, - useGetUsers, - useUpdateUser, -} from "@/controllers/API/queries/auth"; -import CustomLoader from "@/customization/components/custom-loader"; -import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate"; -import IconComponent from "../../components/common/genericIconComponent"; -import ShadTooltip from "../../components/common/shadTooltipComponent"; -import { Button } from "../../components/ui/button"; -import { CheckBoxDiv } from "../../components/ui/checkbox"; -import { Input } from "../../components/ui/input"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "../../components/ui/table"; -import { - PAGINATION_PAGE, - PAGINATION_ROWS_COUNT, - PAGINATION_SIZE, -} from "../../constants/constants"; -import { AuthContext } from "../../contexts/authContext"; -import ConfirmationModal from "../../modals/confirmationModal"; -import UserManagementModal from "../../modals/userManagementModal"; -import useAlertStore from "../../stores/alertStore"; -import type { Users } from "../../types/api"; -import type { UserInputType } from "../../types/components"; - -export default function AdminPage() { - const { t } = useTranslation(); - const [inputValue, setInputValue] = useState(""); - - const [size, setPageSize] = useState(PAGINATION_SIZE); - const [index, setPageIndex] = useState(PAGINATION_PAGE); - const setSuccessData = useAlertStore((state) => state.setSuccessData); - const setErrorData = useAlertStore((state) => state.setErrorData); - const { userData } = useContext(AuthContext); - const navigate = useCustomNavigate(); - const [totalRowsCount, setTotalRowsCount] = useState(0); - - const { mutate: mutateDeleteUser } = useDeleteUsers(); - const { mutate: mutateUpdateUser } = useUpdateUser(); - const { mutate: mutateAddUser } = useAddUser(); - - const [userList, setUserList] = useState([]); - - const { mutate: mutateGetUsers, isPending, isIdle } = useGetUsers({}); - - const fetchUsers = useCallback( - (skip: number, limit: number, search?: string) => { - mutateGetUsers( - { skip, limit, search: search || undefined }, - { - onSuccess: (users) => { - setTotalRowsCount(users["total_count"]); - setUserList(users["users"]); - }, - onError: () => {}, - }, - ); - }, - [mutateGetUsers], - ); - - useEffect(() => { - setTimeout(() => { - fetchUsers(size * (index - 1), size); - }, 500); - }, []); - - function handleChangePagination(pageIndex: number, pageSize: number) { - setPageSize(pageSize); - setPageIndex(pageIndex); - fetchUsers(pageSize * (pageIndex - 1), pageSize, inputValue); - } - - function resetFilter() { - setInputValue(""); - setPageIndex(PAGINATION_PAGE); - setPageSize(PAGINATION_SIZE); - fetchUsers(0, PAGINATION_SIZE); - } - - const debouncedSearch = useCallback( - debounce((search: string) => { - setPageIndex(PAGINATION_PAGE); - fetchUsers(0, size, search); - }, 300), - [size, fetchUsers], - ); - - useEffect(() => { - return () => { - debouncedSearch.cancel(); - }; - }, [debouncedSearch]); - - function handleFilterUsers(input: string) { - setInputValue(input); - debouncedSearch(input); - } - - function handleDeleteUser(user) { - mutateDeleteUser( - { user_id: user.id }, - { - onSuccess: () => { - resetFilter(); - setSuccessData({ - title: t("success.userDeleted"), - }); - }, - onError: (error) => { - setErrorData({ - title: t("errors.deleteUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }, - ); - } - - function handleEditUser(userId, user) { - mutateUpdateUser( - { user_id: userId, user: user }, - { - onSuccess: () => { - resetFilter(); - setSuccessData({ - title: t("success.userEdited"), - }); - }, - onError: (error) => { - setErrorData({ - title: t("errors.editUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }, - ); - } - - function handleDisableUser(check, userId, user) { - const userEdit = cloneDeep(user); - userEdit.is_active = !check; - - mutateUpdateUser( - { user_id: userId, user: userEdit }, - { - onSuccess: () => { - resetFilter(); - setSuccessData({ - title: t("success.userEdited"), - }); - }, - onError: (error) => { - setErrorData({ - title: t("errors.editUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }, - ); - } - - function handleSuperUserEdit(check, userId, user) { - const userEdit = cloneDeep(user); - userEdit.is_superuser = !check; - - mutateUpdateUser( - { user_id: userId, user: userEdit }, - { - onSuccess: () => { - resetFilter(); - setSuccessData({ - title: t("success.userEdited"), - }); - }, - onError: (error) => { - setErrorData({ - title: t("errors.editUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }, - ); - } - - function handleNewUser(user: UserInputType) { - mutateAddUser(user, { - onSuccess: (res) => { - mutateUpdateUser( - { - user_id: res["id"], - user: { - is_active: user.is_active, - is_superuser: user.is_superuser, - }, - }, - { - onSuccess: () => { - resetFilter(); - setSuccessData({ - title: t("success.userAdded"), - }); - }, - onError: (error) => { - setErrorData({ - title: t("errors.addUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }, - ); - }, - onError: (error) => { - setErrorData({ - title: t("errors.addUser"), - list: [error["response"]["data"]["detail"]], - }); - }, - }); - } - - return ( - <> - {userData && ( -
-
- - - - {t("settings.adminTitle")} - -
- - {t("settings.adminDescription")} - -
-
- handleFilterUsers(e.target.value)} - /> - {inputValue.length > 0 ? ( - - ) : ( -
- -
- )} -
-
- { - handleNewUser(user); - }} - asChild - > - - -
-
- {(isPending || isIdle) && userList.length === 0 ? ( -
- -
- ) : userList.length === 0 && !isPending && !isIdle ? ( - <> -
- {t("admin.noUsersRegistered")} -
- - ) : ( -
-
- - - - - {t("admin.columnId")} - - - {t("admin.columnUsername")} - - - {t("admin.columnActive")} - - - {t("admin.columnSuperuser")} - - - {t("admin.columnCreatedAt")} - - - {t("admin.columnUpdatedAt")} - - - - - { - - {userList.map((user: UserInputType, index) => ( - - - - {user.id} - - - - - - {user.username} - - - - - {user.id === userData?.id ? ( - -
- -
-
- ) : ( - { - handleDisableUser( - user.is_active, - user.id, - user, - ); - }} - > - - - {t("admin.editUserConfirmContent")} - - - -
- -
-
-
- )} -
- - { - handleSuperUserEdit( - user.is_superuser, - user.id, - user, - ); - }} - > - - {t("admin.editUserConfirmContent")} - - -
- -
-
-
-
- - { - new Date(user.create_at!) - .toISOString() - .split("T")[0] - } - - - { - new Date(user.updated_at!) - .toISOString() - .split("T")[0] - } - - -
- { - handleEditUser(user.id, editUser); - }} - > - - {/* 24x24 min target (WCAG 2.5.8) with the - icon centered inside. */} -
- -
-
-
- - { - handleDeleteUser(user); - }} - > - - - {t("admin.deleteUserConfirmContent")} - - - - {/* 24x24 min target (WCAG 2.5.8) with the - icon centered inside. */} -
- -
-
-
-
-
-
- ))} -
- } -
-
- -
- -
-
- )} -
- )} - - ); -} 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..7928450e0804 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,10 +39,20 @@ 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(), })); +jest.mock("@/pages/LoginPage/components/dot-grid-background", () => ({ + __esModule: true, + default: () => null, +})); + function renderLoginPage() { const queryClient = new QueryClient(); return render( @@ -54,6 +65,7 @@ function renderLoginPage() { describe("LoginPage accessibility", () => { beforeEach(() => { jest.clearAllMocks(); + mockCustomLoginSsoOptions.mockReturnValue(null); useAlertStore.setState({ notificationList: [], tempNotificationList: [], @@ -84,6 +96,30 @@ describe("LoginPage accessibility", () => { expect(mockLoginMutate).not.toHaveBeenCalled(); }); + it("renders downstream login options between password sign-in and sign-up", () => { + mockCustomLoginSsoOptions.mockReturnValue( +
, + ); + + renderLoginPage(); + + const signInButton = screen.getByRole("button", { name: /sign in/i }); + const loginOptions = screen.getByTestId("custom-login-sso-options"); + const signUpLink = screen.getByRole("link", { name: /sign up/i }); + + expect(screen.getByText("Langflow")).toBeInTheDocument(); + expect(screen.getByText(/don't have an account\?/i)).toBeInTheDocument(); + expect( + signInButton.compareDocumentPosition(loginOptions) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect( + loginOptions.compareDocumentPosition(signUpLink) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(signUpLink).toHaveAttribute("href", "/signup"); + }); + it("should_have_no_axe_violations", async () => { const { container } = renderLoginPage(); @@ -93,11 +129,20 @@ 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" }), + ).toHaveAttribute("autocomplete", "current-password"); + }); + + it("names_the_login_form_region", () => { + renderLoginPage(); + + expect( + screen.getByRole("region", { name: /sign in to langflow/i }), ).toBeInTheDocument(); }); 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..7df23dcfdef0 --- /dev/null +++ b/src/frontend/src/pages/LoginPage/components/__tests__/dot-grid-background.test.tsx @@ -0,0 +1,108 @@ +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("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(); + 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 new file mode 100644 index 000000000000..16e3d9f7b630 --- /dev/null +++ b/src/frontend/src/pages/LoginPage/components/dot-grid-background.tsx @@ -0,0 +1,180 @@ +import { useEffect, useRef } from "react"; + +const DOT_SPACING = 24; +const INTERACTION_RADIUS = 160; + +type Dot = { + x: number; + y: number; + baseX: number; + baseY: number; + radius: number; +}; + +export default function DotGridBackground() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const context = canvas.getContext("2d"); + if (!context) return; + + const reducedMotionMedia = window.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + const colorSchemeMedia = window.matchMedia("(prefers-color-scheme: dark)"); + let reducedMotion = reducedMotionMedia.matches; + 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, viewportWidth, viewportHeight); + const isDark = + themeElement?.classList.contains("dark") ?? colorSchemeMedia.matches; + + dots.forEach((dot) => { + const deltaX = pointer.x - dot.baseX; + const deltaY = pointer.y - dot.baseY; + const distance = Math.hypot(deltaX, deltaY); + let targetX = dot.baseX; + let targetY = dot.baseY; + let targetRadius = 1; + + if (!reducedMotion && distance < INTERACTION_RADIUS) { + const force = (INTERACTION_RADIUS - distance) / INTERACTION_RADIUS; + const easedForce = force * force; + targetX += deltaX * easedForce * 0.35; + targetY += deltaY * easedForce * 0.35; + targetRadius += easedForce * 2; + } + + dot.x += (targetX - dot.x) * 0.15; + dot.y += (targetY - dot.y) * 0.15; + dot.radius += (targetRadius - dot.radius) * 0.15; + + const intensity = Math.min(1, (dot.radius - 1) / 2); + // Light dots on dark canvas; darker dots on light canvas. + const shade = isDark + ? Math.round(51 + (200 - 51) * intensity) + : Math.round(200 - (200 - 110) * intensity); + context.beginPath(); + context.arc(dot.x, dot.y, dot.radius, 0, Math.PI * 2); + context.fillStyle = `rgb(${shade} ${shade} ${shade})`; + context.fill(); + }); + + if (!reducedMotion) { + animationFrame = window.requestAnimationFrame(draw); + } + }; + + const resize = () => { + const pixelRatio = window.devicePixelRatio || 1; + 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(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) { + 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; + }; + + const resetPointer = () => { + pointer.x = -1000; + pointer.y = -1000; + }; + + resize(); + 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( + "pointerleave", + resetPointer, + ); + }; + }, []); + + return ( +