From e0119adc64644193f761c35482fcfb1d8562f378 Mon Sep 17 00:00:00 2001 From: MicrosoftWindows96 Date: Sat, 16 May 2026 15:41:50 +0100 Subject: [PATCH] feat(identity): add identity foundation Add the identity crate, core identity ports, migrations, password auth, sessions, API tokens, OIDC, SAML, SCIM, multi-IdP routing, email outbox, service tokens, SSO test compose, fuzz and benchmark coverage, and operator documentation. Add CI gates, branch-protection payload updates, OpenAPI coverage, standards-map checks, and CI setup for SAML system dependencies, fuzz smoke, and SSO compose. Signed-off-by: MicrosoftWindows96 --- .env.example | 7 + .github/branch-protection.json | 3 + .github/workflows/rust-signin-bench.yml | 50 + .github/workflows/rust.yml | 82 + ...cf792363eb188013d8f77dcc2c56aa2eed4e2.json | 14 + ...2ca0f4a771ef2c54278387ff63746297a6361.json | 22 + ...6c9a90fb56ad32c9f27bb733b56d96ebbbed2.json | 16 + ...35fe2b0d3ff023b293ac62b3aa15458da4111.json | 100 + ...db117bf242c2238d8cde56a31804e5c1cfc26.json | 61 + ...f996c706b131cb0a00b46516e21f0c68944a6.json | 83 + ...02682a2f4c05c744b722c4c3caa2794b1ec58.json | 17 + ...7e396b5ba2c632143bcfcd61ae6915f7d3e06.json | 14 + ...ec646ebf58f09f3601de4886803473a73405d.json | 14 + ...9e67b59d6440dcc477a53ce91142160e247f7.json | 15 + ...544a04ba3153cc5ffc767e2c75d61a3b05535.json | 14 + ...994c24769193e28e073007c68c1f16df2895a.json | 67 + ...45a9fd20592a1fffd6d9924f1685ba2d238c1.json | 14 + ...3c01164683c10d0280389b3297b3e6426e39c.json | 14 + ...2fbfe2993274d460da2c1f7562d4a5a725353.json | 46 + ...5de412fb82718dbe9a64d927b5680d26223f8.json | 89 + ...014fa8d73a6c089ed6d344633211ca11ad1ee.json | 15 + ...7c6749c2d724a4402e8754d0896a3d3d84a9f.json | 14 + ...1628fb194936ccacabe2e9ee7d12e665fdcbe.json | 84 + ...f31dd98cc84b747f00340d6b7a1e587cb0279.json | 69 + ...e2fa2f12984fabe46ee91acdb6cac5e566828.json | 82 + ...97fdf1449a14f56bce91fdb0e4302bc88f477.json | 15 + ...e95753e8eed7ca4290d220f40149446406ba5.json | 14 + ...2014759103556b04d52888a59094443f5b06b.json | 100 + ...f37f0dcb369ab535d61a052a7883faa7ddf16.json | 29 + ...91baafed426de772ac96b89c4abeaa0884fd0.json | 47 + ...6e4af2365393e0e32c784e205a9bf6fafb1e8.json | 33 + ...45ab16d6ad5f7c0ba548a8b51858e6ce248ed.json | 64 + ...46dd35bcbbd3852f2e091a32a8736e6281873.json | 14 + ...735426864f098f2bdd3fda195a19e215a4d97.json | 74 + ...bc9cb9739d37c2687a55021be4bfb25e03ff9.json | 101 + ...e4e89611b375e0327d661e6008405ff846e18.json | 107 + ...1b73984bdc885319d64886c0dec41af6ed3e7.json | 14 + ...07b9e966342c5bf5895165c7401e0ef6d50d4.json | 15 + ...338198354da7fa96bfd614fbd9ecf2a6a7809.json | 88 + ...2ce8e7f9a41c700baf3c034c9b6cfd05bff9b.json | 25 + ...d9905c08a0e2c419e3c7374fa6809c0ac19d6.json | 14 + ...5b93e79589ddbef3c0ed4f65b06383171fdd1.json | 17 + ...b77d1e54994e65b93b3896eecbf6f484f1ff2.json | 71 + ...9da7e8ee5fc6d3dc99926750cfff06637644d.json | 15 + ...3030ce330f3b4a9433ef86ed6e4c20c5ff29e.json | 18 + ...cc31b2534b7ae286f5798c755715d24c0e0a5.json | 15 + ...8ff64e57b1d99185464679e917f7ff26575fa.json | 106 + ...f94baf213c219a2a87dc5372c993c7488cb0c.json | 83 + ...003a85c1cd40feb27960d3b5855f55a124f45.json | 58 + ...46e8451a232dbf4c30f022b3631c3aea7b137.json | 14 + ...f69775eaeb13ba40f4ff5016fd2002864e7bb.json | 83 + ...b8982b41c58a3f5af053e35782f653e205d5c.json | 66 + ...a01745dbf377046950561b5e9c301b55b733c.json | 14 + ...ad65ac5de4b2a08049933c6a11cc02a18cee1.json | 61 + ...70866da93b8667d2d517dd7d2af6491c04dae.json | 14 + ...307f65692caf297ec963a1da605cce3df5064.json | 65 + ...04c88003ea883f3ca8a1a9203481194b7bf71.json | 15 + ...dee5ce5b0f829a4a9980b0591cd679bbde6f9.json | 30 + ...42f1500e5a043021ef71e925a5a2427cbcb44.json | 66 + ...3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json | 100 + ...3521e9c1038a2ae122e671c820463594b8a37.json | 58 + ...958fa5c3cafa588929bb22b43e38ca584a5d7.json | 65 + ...25c05335c78d4d1355d3e61695a860542ffe3.json | 14 + ...3209a96d31585bff814fc9c586b8364471f01.json | 22 + ...39b84ea556a76becac8e530f439d82dbb3b75.json | 62 + ...d33801ec15d45165663d3b8b1879a97d87ab7.json | 58 + ...e8707173aefaf0526fe1c0c1c429498fc012d.json | 82 + ...cf928ad3f6edbd25e5f49e27f77d4528ff699.json | 14 + ...dc131a79aea4982daeabf0ae0d9f0d9e1ab66.json | 76 + ...a780b0b64d1ee4216327a853fbb8a41f8df13.json | 22 + ...255dbd18428936eaebe441c171b63ce7ed1cd.json | 28 + ...a583643d14c5573c700ee7aacd0569fe1eb0f.json | 72 + ...e2496760a54befc3cb9a7b2f9c521e1c5478c.json | 14 + ...a4759b46b5657caec83da5549f0e92fdaeff1.json | 15 + ...307a03eac188c1964ba0449f3faa51cea4d87.json | 18 + ...aec103684470e7acd1b92ed432779eaab7e2d.json | 28 + ...4fe46a4e973b138670f22cb4342fed1004cbe.json | 15 + ...2b8c7301ea7bb478505a70bcd458cb1b7e41a.json | 14 + ...8c5522242bc373a3fe6f47e2d57fee507b76f.json | 108 + ...ef24914f0c8fcde9073917e73a106b68a5f22.json | 15 + ...cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json | 58 + ...07d6afef5058c18fa75896ddac311e84a4e4e.json | 62 + ...148ca110ae850451ea963614fbca19dcbcb45.json | 14 + ...b3487c4c55616230afb94be5790771406ab78.json | 111 + ...e77065e22e80c357fe17e9974152b5944f796.json | 14 + ...b3ecc2d4e3ecd26fca5b08bc605335db1edde.json | 17 + ...1335774b17a9ffb9fc7e1d14c55e3d3930859.json | 16 + ...3c1189e9937069911b27ad442eee81e353350.json | 112 + ...469656531f661b2dd571a24de6fd80c117adc.json | 70 + ...19b2819730f8d923da116b40590d579a8ccfa.json | 108 + ...f4cce8ce858efcb295c7ec7ff24def6d7fb9f.json | 22 + ...1136330a61c95cc60b6105ad2664adc82d80e.json | 22 + ...8d277bea51f6edea9fb39f7daa1fe824ce212.json | 14 + ...c7cea00a7c64821cb34a0b166250ec0383924.json | 40 + ...70cfff4ee3de19ceac32ae9dac315e038eaa1.json | 73 + ...62daf3a0ff9dfd6d147bccc5b15b3e9907477.json | 24 + ...e37aa080b04c42b74a6bbddc5e50e9c997995.json | 22 + ...76b3e15b3a339bf3bcbbf223b7904c074506b.json | 15 + ...846378110775877d30b48678e9ecfaaad1d49.json | 68 + ...374c79e194fa6c135c0ceae7c5967311417e1.json | 15 + ...f93218a00dc2d44840519a5fdc15339f08306.json | 23 + ...4ccf4a015b5dbee257c0ec9b22679f1df2627.json | 40 + ...1b3f538cc7ca38748ec878fb2b3db000525dc.json | 94 + ...9e6a39bb7c943ba09e65561ffd69b7118501f.json | 15 + ...6e38522167ae02338e3bd96e6507516c8b040.json | 17 + ...1ccaf9d5c86e6f371b10dd5c71e8bf148c236.json | 15 + ...c4400ebea029a0a87c3cd91b3036962db007e.json | 14 + ...8f3779f0a141cf7a5b3f5cb17b25416e148b7.json | 24 + ...8572a85f0192b61d91c6f97c4c9c55a56ad98.json | 15 + ...c2f280afdbf834fd65674fb35c7c7b5422cce.json | 106 + ...d35ac0721e27d184988350fcbf0930046bc56.json | 14 + ...a957683e43f53efbbba9063a5975e62c017a7.json | 23 + ...7e585a44e3f4d4f6c69326ebef6464a6ac7a2.json | 22 + ...d758915ed3b5c31df7e0ea20724b63df4d5ab.json | 106 + ...a7c86c974ca03de30a2388e3dcf9fc323c402.json | 14 + ...f35811317a67a82bf660d66a1e9742e644a76.json | 22 + ...a723cfc6bbb66140ed256fd7f4f09f4d387a2.json | 95 + ...2c76e07f177df58cb8ecbe6d83cc39e522215.json | 68 + ...63d66b07783ba82d4a2afb157a01228831818.json | 88 + ...7b848a4467cbd985d1257546b264c32f8877f.json | 22 + ...86693d77a8b0f7205d7ecbf79318107fefb07.json | 42 + ...f51570c5630a6e31520a6d24e17d619e86923.json | 64 + ...58fee8a81977fe329040ef22fd2cf85998f32.json | 34 + ...7ff2a235d86beeb83dc4ecc511bf4cec24f8f.json | 58 + ...aa3ca137ec06d1cb5504e00b42a0b846853d1.json | 83 + ...d2267471f582f11f5296de10e6de0f4790081.json | 64 + ...ac9da09b7e01ac1ac7dd98d28c7c4244275de.json | 96 + ...1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json | 14 + ...ba01a6a5c34585302797bb1819c454408c903.json | 15 + CHANGELOG.md | 85 + Cargo.lock | 4749 ++++++++++++-- Cargo.toml | 140 +- crates/zagrosi-core/Cargo.toml | 5 + crates/zagrosi-core/src/audit.rs | 983 +++ crates/zagrosi-core/src/auth_context.rs | 895 +++ crates/zagrosi-core/src/breach_list_client.rs | 59 + crates/zagrosi-core/src/config.rs | 4 +- crates/zagrosi-core/src/email_transport.rs | 266 + crates/zagrosi-core/src/key_provider.rs | 151 + crates/zagrosi-core/src/lib.rs | 43 +- crates/zagrosi-core/src/mfa_policy.rs | 139 + crates/zagrosi-core/src/rate_limiter.rs | 205 + .../zagrosi-core/src/session_introspector.rs | 32 + .../tests/audit_event_v1_schema.rs | 17 + .../tests/fixtures/audit_event_v1.json | 18 + crates/zagrosi-identity/.sqlx/.gitkeep | 0 ...cf792363eb188013d8f77dcc2c56aa2eed4e2.json | 14 + ...2ca0f4a771ef2c54278387ff63746297a6361.json | 22 + ...35fe2b0d3ff023b293ac62b3aa15458da4111.json | 100 + ...db117bf242c2238d8cde56a31804e5c1cfc26.json | 61 + ...02682a2f4c05c744b722c4c3caa2794b1ec58.json | 17 + ...7e396b5ba2c632143bcfcd61ae6915f7d3e06.json | 14 + ...9e67b59d6440dcc477a53ce91142160e247f7.json | 15 + ...45a9fd20592a1fffd6d9924f1685ba2d238c1.json | 14 + ...3c01164683c10d0280389b3297b3e6426e39c.json | 14 + ...5de412fb82718dbe9a64d927b5680d26223f8.json | 89 + ...014fa8d73a6c089ed6d344633211ca11ad1ee.json | 15 + ...7c6749c2d724a4402e8754d0896a3d3d84a9f.json | 14 + ...f31dd98cc84b747f00340d6b7a1e587cb0279.json | 69 + ...97fdf1449a14f56bce91fdb0e4302bc88f477.json | 15 + ...ee366f76f7e7b8ef54e04c48f3aa48e8ddfb2.json | 58 + ...2014759103556b04d52888a59094443f5b06b.json | 100 + ...6e4af2365393e0e32c784e205a9bf6fafb1e8.json | 33 + ...45ab16d6ad5f7c0ba548a8b51858e6ce248ed.json | 64 + ...bc9cb9739d37c2687a55021be4bfb25e03ff9.json | 101 + ...1b73984bdc885319d64886c0dec41af6ed3e7.json | 14 + ...07b9e966342c5bf5895165c7401e0ef6d50d4.json | 15 + ...338198354da7fa96bfd614fbd9ecf2a6a7809.json | 88 + ...d9905c08a0e2c419e3c7374fa6809c0ac19d6.json | 14 + ...5b93e79589ddbef3c0ed4f65b06383171fdd1.json | 17 + ...9da7e8ee5fc6d3dc99926750cfff06637644d.json | 15 + ...7f1402fc222a3903b7cc3724a16772b37f5e0.json | 14 + ...f94baf213c219a2a87dc5372c993c7488cb0c.json | 83 + ...f69775eaeb13ba40f4ff5016fd2002864e7bb.json | 83 + ...a01745dbf377046950561b5e9c301b55b733c.json | 14 + ...ad65ac5de4b2a08049933c6a11cc02a18cee1.json | 61 + ...70866da93b8667d2d517dd7d2af6491c04dae.json | 14 + ...42f1500e5a043021ef71e925a5a2427cbcb44.json | 66 + ...3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json | 100 + ...3521e9c1038a2ae122e671c820463594b8a37.json | 58 + ...9d639ddac0f7ac2d34e667bee346a88b9b217.json | 93 + ...25c05335c78d4d1355d3e61695a860542ffe3.json | 14 + ...cf928ad3f6edbd25e5f49e27f77d4528ff699.json | 14 + ...e2496760a54befc3cb9a7b2f9c521e1c5478c.json | 14 + ...4a1cef504ff5f2c2c29093cf4036f6c635f37.json | 25 + ...307a03eac188c1964ba0449f3faa51cea4d87.json | 18 + ...4fe46a4e973b138670f22cb4342fed1004cbe.json | 15 + ...2b8c7301ea7bb478505a70bcd458cb1b7e41a.json | 14 + ...8c5522242bc373a3fe6f47e2d57fee507b76f.json | 108 + ...c7c3e3f65e8843e2256d0458979ad55cc7f18.json | 76 + ...cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json | 58 + ...148ca110ae850451ea963614fbca19dcbcb45.json | 14 + ...a16dd4ed45669eed26e4c268c9c9a02a12b8c.json | 17 + ...e77065e22e80c357fe17e9974152b5944f796.json | 14 + ...9ef65d38ddbd45efc864c0470553902fbdbaf.json | 88 + ...1335774b17a9ffb9fc7e1d14c55e3d3930859.json | 16 + ...469656531f661b2dd571a24de6fd80c117adc.json | 70 + ...1136330a61c95cc60b6105ad2664adc82d80e.json | 22 + ...8d277bea51f6edea9fb39f7daa1fe824ce212.json | 14 + ...c7cea00a7c64821cb34a0b166250ec0383924.json | 40 + ...c252eff27fef0ac665163e86dd865a9bf106f.json | 88 + ...374c79e194fa6c135c0ceae7c5967311417e1.json | 15 + ...4ccf4a015b5dbee257c0ec9b22679f1df2627.json | 40 + ...9e6a39bb7c943ba09e65561ffd69b7118501f.json | 15 + ...c4400ebea029a0a87c3cd91b3036962db007e.json | 14 + ...8f3779f0a141cf7a5b3f5cb17b25416e148b7.json | 24 + ...8572a85f0192b61d91c6f97c4c9c55a56ad98.json | 15 + ...d35ac0721e27d184988350fcbf0930046bc56.json | 14 + ...a7c86c974ca03de30a2388e3dcf9fc323c402.json | 14 + ...a723cfc6bbb66140ed256fd7f4f09f4d387a2.json | 95 + ...2c76e07f177df58cb8ecbe6d83cc39e522215.json | 68 + ...63d66b07783ba82d4a2afb157a01228831818.json | 88 + ...86693d77a8b0f7205d7ecbf79318107fefb07.json | 42 + ...aa3ca137ec06d1cb5504e00b42a0b846853d1.json | 83 + ...d2267471f582f11f5296de10e6de0f4790081.json | 64 + ...ac9da09b7e01ac1ac7dd98d28c7c4244275de.json | 96 + ...1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json | 14 + crates/zagrosi-identity/Cargo.toml | 145 + .../benches/argon2_calibration.rs | 29 + crates/zagrosi-identity/benches/common/mod.rs | 135 + .../benches/session_resolve_bench.rs | 40 + .../benches/session_resolve_bench_cold.rs | 42 + .../benches/signin_oidc_callback_bench.rs | 33 + .../benches/signin_password_bench.rs | 41 + .../benches/signin_saml_acs_bench.rs | 35 + crates/zagrosi-identity/fuzz/.gitignore | 8 + crates/zagrosi-identity/fuzz/Cargo.lock | 5449 +++++++++++++++++ crates/zagrosi-identity/fuzz/Cargo.toml | 67 + .../corpus/oidc_id_token/id_token_no_sig.json | 1 + .../fuzz/corpus/saml_assertion/.gitkeep | 0 .../fuzz/corpus/saml_assertion/xsw_a.xml | 1 + .../corpus/scim_filter/filter_invalid_001.txt | 1 + .../fuzz/corpus/secrets_open/.gitkeep | 0 .../fuzz/fuzz_targets/oidc_id_token.rs | 43 + .../fuzz/fuzz_targets/saml_assertion.rs | 33 + .../fuzz/fuzz_targets/scim_filter.rs | 19 + .../fuzz/fuzz_targets/secrets_open.rs | 41 + ...0260508120000_001_roles_and_extensions.sql | 21 + .../migrations/20260508120100_002_orgs.sql | 21 + .../migrations/20260508120200_003_users.sql | 30 + ...0260508120300_004_user_org_memberships.sql | 27 + .../20260508120400_005_sessions.sql | 37 + .../20260508120500_006_api_tokens.sql | 26 + ...assword_resets_and_email_verifications.sql | 45 + ...20700_008_org_idps_and_org_idp_domains.sql | 52 + .../20260508120800_009_scim_tokens.sql | 29 + .../20260508120900_010_email_outbox.sql | 36 + .../20260508121000_011_oidc_pending_auth.sql | 36 + ...20260508121100_012_oidc_refresh_tokens.sql | 22 + ...260508121200_013_saml_assertion_replay.sql | 18 + ...0260508121300_014_federated_identities.sql | 25 + ...508121400_015_failed_signin_aggregates.sql | 41 + .../20260508121500_016_service_tokens.sql | 29 + .../20260509191612_017_saml_pending_auth.sql | 33 + .../20260510000100_018_users_scim_columns.sql | 30 + .../20260510000200_019_scim_groups.sql | 65 + ...00_020_org_idp_domains_challenge_token.sql | 16 + .../zagrosi-identity/src/api_tokens/cache.rs | 351 ++ crates/zagrosi-identity/src/api_tokens/mod.rs | 73 + .../zagrosi-identity/src/api_tokens/model.rs | 182 + .../src/api_tokens/resolver.rs | 312 + .../src/api_tokens/service.rs | 265 + .../src/api_tokens/write_behind.rs | 233 + crates/zagrosi-identity/src/config.rs | 1536 +++++ crates/zagrosi-identity/src/crypto/mod.rs | 20 + crates/zagrosi-identity/src/crypto/secrets.rs | 394 ++ .../zagrosi-identity/src/domain/api_token.rs | 40 + .../zagrosi-identity/src/domain/federated.rs | 33 + crates/zagrosi-identity/src/domain/group.rs | 56 + .../zagrosi-identity/src/domain/membership.rs | 32 + crates/zagrosi-identity/src/domain/mod.rs | 81 + .../src/domain/oidc_pending.rs | 39 + .../src/domain/oidc_refresh.rs | 30 + crates/zagrosi-identity/src/domain/org.rs | 28 + crates/zagrosi-identity/src/domain/org_idp.rs | 43 + .../src/domain/org_idp_domain.rs | 73 + .../src/domain/saml_pending.rs | 48 + .../src/domain/saml_replay.rs | 25 + .../src/domain/scim_resource.rs | 50 + .../src/domain/service_token.rs | 33 + crates/zagrosi-identity/src/domain/session.rs | 50 + .../src/domain/token_format.rs | 440 ++ crates/zagrosi-identity/src/domain/user.rs | 61 + crates/zagrosi-identity/src/email/dispatch.rs | 489 ++ crates/zagrosi-identity/src/email/mod.rs | 42 + crates/zagrosi-identity/src/email/outbox.rs | 159 + crates/zagrosi-identity/src/email/retry.rs | 110 + crates/zagrosi-identity/src/email/template.rs | 62 + .../zagrosi-identity/src/email/transport.rs | 366 ++ crates/zagrosi-identity/src/email/worker.rs | 350 ++ crates/zagrosi-identity/src/error.rs | 842 +++ crates/zagrosi-identity/src/http/admin.rs | 74 + .../zagrosi-identity/src/http/api_tokens.rs | 243 + crates/zagrosi-identity/src/http/auth.rs | 111 + crates/zagrosi-identity/src/http/csrf.rs | 167 + .../zagrosi-identity/src/http/email_verify.rs | 49 + crates/zagrosi-identity/src/http/landing.rs | 110 + crates/zagrosi-identity/src/http/mod.rs | 321 + crates/zagrosi-identity/src/http/oidc.rs | 387 ++ .../src/http/password_reset.rs | 76 + crates/zagrosi-identity/src/http/saml.rs | 336 + .../zagrosi-identity/src/http/scim/attrs.rs | 280 + crates/zagrosi-identity/src/http/scim/auth.rs | 209 + .../src/http/scim/discovery.rs | 123 + crates/zagrosi-identity/src/http/scim/etag.rs | 140 + .../zagrosi-identity/src/http/scim/filter.rs | 804 +++ .../zagrosi-identity/src/http/scim/groups.rs | 678 ++ crates/zagrosi-identity/src/http/scim/mod.rs | 431 ++ .../zagrosi-identity/src/http/scim/patch.rs | 528 ++ .../src/http/scim/translate.rs | 530 ++ .../zagrosi-identity/src/http/scim/users.rs | 760 +++ .../zagrosi-identity/src/http/scim_tokens.rs | 429 ++ .../src/http/service_tokens.rs | 220 + crates/zagrosi-identity/src/http/sessions.rs | 243 + crates/zagrosi-identity/src/lib.rs | 94 + crates/zagrosi-identity/src/oidc/client.rs | 597 ++ crates/zagrosi-identity/src/oidc/config.rs | 533 ++ crates/zagrosi-identity/src/oidc/cookie.rs | 372 ++ crates/zagrosi-identity/src/oidc/discovery.rs | 724 +++ crates/zagrosi-identity/src/oidc/jit.rs | 268 + crates/zagrosi-identity/src/oidc/mod.rs | 53 + crates/zagrosi-identity/src/oidc/pending.rs | 185 + crates/zagrosi-identity/src/oidc/refresh.rs | 424 ++ crates/zagrosi-identity/src/oidc/service.rs | 891 +++ .../zagrosi-identity/src/password/breach.rs | 153 + .../src/password/calibration.rs | 81 + .../zagrosi-identity/src/password/hasher.rs | 243 + crates/zagrosi-identity/src/password/mod.rs | 31 + .../zagrosi-identity/src/password/policy.rs | 92 + .../src/rate_limit/headers.rs | 174 + crates/zagrosi-identity/src/rate_limit/lua.rs | 202 + crates/zagrosi-identity/src/rate_limit/mod.rs | 34 + .../zagrosi-identity/src/rate_limit/valkey.rs | 330 + .../src/repo/api_token_repo.rs | 363 ++ crates/zagrosi-identity/src/repo/cascade.rs | 162 + .../src/repo/email_verification_repo.rs | 111 + .../src/repo/failed_signin_repo.rs | 89 + .../src/repo/federated_repo.rs | 331 + .../zagrosi-identity/src/repo/group_repo.rs | 498 ++ .../src/repo/membership_repo.rs | 253 + crates/zagrosi-identity/src/repo/mod.rs | 114 + .../src/repo/oidc_pending_repo.rs | 199 + .../src/repo/oidc_refresh_repo.rs | 258 + .../src/repo/org_idp_domain_repo.rs | 379 ++ .../zagrosi-identity/src/repo/org_idp_repo.rs | 266 + crates/zagrosi-identity/src/repo/org_repo.rs | 135 + .../zagrosi-identity/src/repo/org_scoped.rs | 96 + .../src/repo/password_reset_repo.rs | 110 + .../src/repo/saml_pending_repo.rs | 215 + .../src/repo/saml_replay_repo.rs | 128 + .../src/repo/scim_resource_repo.rs | 248 + .../src/repo/service_token_repo.rs | 214 + .../zagrosi-identity/src/repo/session_repo.rs | 489 ++ crates/zagrosi-identity/src/repo/user_repo.rs | 742 +++ .../zagrosi-identity/src/routing/blocklist.rs | 148 + crates/zagrosi-identity/src/routing/cache.rs | 141 + .../zagrosi-identity/src/routing/data/mod.rs | 9 + .../src/routing/data/public_domain_extras.rs | 103 + .../zagrosi-identity/src/routing/discover.rs | 425 ++ crates/zagrosi-identity/src/routing/dns.rs | 491 ++ .../src/routing/domain_verify.rs | 463 ++ .../src/routing/email_normalise.rs | 207 + crates/zagrosi-identity/src/routing/mod.rs | 96 + crates/zagrosi-identity/src/routing/state.rs | 70 + .../zagrosi-identity/src/routing/tombstone.rs | 99 + crates/zagrosi-identity/src/saml/acs.rs | 1220 ++++ crates/zagrosi-identity/src/saml/attribute.rs | 177 + crates/zagrosi-identity/src/saml/authn.rs | 226 + crates/zagrosi-identity/src/saml/config.rs | 272 + crates/zagrosi-identity/src/saml/errors.rs | 201 + crates/zagrosi-identity/src/saml/jit.rs | 309 + crates/zagrosi-identity/src/saml/metadata.rs | 690 +++ crates/zagrosi-identity/src/saml/mod.rs | 69 + .../zagrosi-identity/src/saml/relay_state.rs | 77 + .../zagrosi-identity/src/saml/request_id.rs | 63 + crates/zagrosi-identity/src/saml/service.rs | 273 + .../src/service/email_verify.rs | 81 + crates/zagrosi-identity/src/service/mod.rs | 192 + .../src/service/password_reset.rs | 187 + crates/zagrosi-identity/src/service/signin.rs | 271 + .../zagrosi-identity/src/service/signout.rs | 48 + crates/zagrosi-identity/src/service/signup.rs | 207 + .../src/service_tokens/cache.rs | 269 + .../src/service_tokens/mod.rs | 40 + .../src/service_tokens/model.rs | 178 + .../src/service_tokens/resolver.rs | 261 + .../src/service_tokens/service.rs | 296 + crates/zagrosi-identity/src/session/cache.rs | 320 + .../src/session/continuation.rs | 114 + crates/zagrosi-identity/src/session/cookie.rs | 149 + crates/zagrosi-identity/src/session/events.rs | 290 + .../src/session/introspector.rs | 331 + crates/zagrosi-identity/src/session/issuer.rs | 287 + crates/zagrosi-identity/src/session/mod.rs | 62 + crates/zagrosi-identity/src/session/port.rs | 58 + crates/zagrosi-identity/src/session/revoke.rs | 207 + .../src/session/switch_org.rs | 189 + .../src/session/write_behind.rs | 187 + .../en-US/account_already_exists.ftl | 5 + .../templates/en-US/org_invite.ftl | 9 + .../templates/en-US/password_reset.ftl | 7 + .../templates/en-US/verify_email.ftl | 6 + crates/zagrosi-identity/tests/api_tokens.rs | 1579 +++++ crates/zagrosi-identity/tests/bench_smoke.rs | 150 + .../tests/common/authentik.rs | 78 + .../zagrosi-identity/tests/common/compose.rs | 99 + .../zagrosi-identity/tests/common/fixtures.rs | 108 + .../zagrosi-identity/tests/common/mailpit.rs | 92 + crates/zagrosi-identity/tests/common/mod.rs | 238 + .../tests/common/saml_helpers.rs | 340 + .../tests/common/simplesaml.rs | 63 + .../zagrosi-identity/tests/crypto_secrets.rs | 186 + crates/zagrosi-identity/tests/docs_handoff.rs | 162 + crates/zagrosi-identity/tests/email_outbox.rs | 381 ++ .../tests/fixtures/bench/oidc_id_token.json | 28 + .../tests/fixtures/bench/saml_assertion.xml | 38 + .../tests/fixtures/hibp_responses/clean.txt | 3 + .../tests/fixtures/hibp_responses/pwned.txt | 4 + .../negative/oidc/id_token_bad_aud.json | 1 + .../negative/oidc/id_token_bad_iss.json | 1 + .../negative/oidc/id_token_bad_nonce.json | 1 + .../negative/oidc/id_token_expired.json | 1 + .../negative/oidc/id_token_no_sig.json | 1 + .../negative/oidc/rfc9207_iss_mismatch.txt | 1 + .../fixtures/negative/saml/.GENERATOR.md | 14 + .../fixtures/negative/saml/bad_recipient.xml | 1 + .../fixtures/negative/saml/duplicate_id.xml | 1 + .../negative/saml/expired_notonorafter.xml | 1 + .../fixtures/negative/saml/idp_initiated.xml | 1 + .../negative/saml/replay_assertion.xml | 1 + .../tests/fixtures/negative/saml/xsw_a.xml | 1 + .../tests/fixtures/negative/saml/xsw_b.xml | 1 + .../tests/fixtures/negative/saml/xsw_c.xml | 1 + .../tests/fixtures/negative/saml/xsw_d.xml | 1 + .../tests/fixtures/negative/saml/xsw_e.xml | 1 + .../tests/fixtures/negative/saml/xsw_f.xml | 1 + .../tests/fixtures/negative/saml/xsw_g.xml | 1 + .../tests/fixtures/negative/saml/xsw_h.xml | 1 + .../tests/fixtures/negative/saml/xxe_dtd.xml | 1 + .../negative/saml/xxe_external_entity.xml | 1 + .../negative/scim/filter_invalid_001.txt | 5 + .../tests/fixtures/scim_resource_types.json | 28 + .../tests/fixtures/scim_schemas.json | 178 + .../scim_service_provider_config.json | 24 + .../tests/migrations_smoke.rs | 782 +++ .../tests/multi_idp_routing.rs | 59 + .../tests/multi_idp_routing_unit.rs | 745 +++ .../tests/oidc_chain_invariants.rs | 724 +++ crates/zagrosi-identity/tests/oidc_flow.rs | 78 + .../zagrosi-identity/tests/oidc_negative.rs | 59 + .../zagrosi-identity/tests/partial_uniques.rs | 284 + .../zagrosi-identity/tests/password_flow.rs | 428 ++ .../tests/rate_limit_valkey.rs | 555 ++ .../tests/repo_round_trips.rs | 389 ++ crates/zagrosi-identity/tests/saml_flow.rs | 547 ++ .../tests/saml_negative_corpus.rs | 84 + .../tests/schema_invariants.rs | 229 + .../tests/scim_conformance.rs | 56 + .../tests/scim_filter_grammar.rs | 212 + .../tests/scim_inbound_authentik.rs | 50 + crates/zagrosi-identity/tests/scim_server.rs | 1087 ++++ .../zagrosi-identity/tests/service_tokens.rs | 420 ++ .../tests/soft_delete_cascade.rs | 279 + .../tests/standards_conformance_map.rs | 62 + .../tests/tenant_isolation.rs | 272 + deny.toml | 61 +- deploy/docker/compose.test.yaml | 87 + documentation/api/identity.openapi.yaml | 1456 +++++ documentation/governance.md | 5 +- documentation/identity.md | 172 + infra/authentik/bootstrap.yaml | 7 + infra/postgres/init/00-multi-db.sh | 24 + infra/simplesaml/authsources.php | 15 + infra/simplesaml/config.php | 20 + .../simplesaml/metadata/saml20-sp-remote.php | 5 + infra/simplesaml/saml20-idp-hosted.php | 7 + scripts/bootstrap-authentik.sh | 57 + scripts/check-bench-gate.sh | 41 + scripts/check-branch-protection.sh | 33 + scripts/seed-fuzz-corpus.sh | 17 + scripts/smoke-sso.sh | 92 + 481 files changed, 71183 insertions(+), 580 deletions(-) create mode 100644 .github/workflows/rust-signin-bench.yml create mode 100644 .sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json create mode 100644 .sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json create mode 100644 .sqlx/query-04f453dd44347573bfb50c646056c9a90fb56ad32c9f27bb733b56d96ebbbed2.json create mode 100644 .sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json create mode 100644 .sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json create mode 100644 .sqlx/query-08150edbcb053f141547de79013f996c706b131cb0a00b46516e21f0c68944a6.json create mode 100644 .sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json create mode 100644 .sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json create mode 100644 .sqlx/query-0d46f43e4df605f64c2aaa9d2baec646ebf58f09f3601de4886803473a73405d.json create mode 100644 .sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json create mode 100644 .sqlx/query-0fc2c309e39d77125627a0d1eca544a04ba3153cc5ffc767e2c75d61a3b05535.json create mode 100644 .sqlx/query-0ff4f704235c7259319c18299aa994c24769193e28e073007c68c1f16df2895a.json create mode 100644 .sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json create mode 100644 .sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json create mode 100644 .sqlx/query-171579ffb61867ecc279769f3162fbfe2993274d460da2c1f7562d4a5a725353.json create mode 100644 .sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json create mode 100644 .sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json create mode 100644 .sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json create mode 100644 .sqlx/query-1b192324e2195693ff9b1dc85811628fb194936ccacabe2e9ee7d12e665fdcbe.json create mode 100644 .sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json create mode 100644 .sqlx/query-1d952c19875b1fec1262efa827de2fa2f12984fabe46ee91acdb6cac5e566828.json create mode 100644 .sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json create mode 100644 .sqlx/query-239ed67141d01585e482950468be95753e8eed7ca4290d220f40149446406ba5.json create mode 100644 .sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json create mode 100644 .sqlx/query-273adb8382aa5c2fe0f587ed313f37f0dcb369ab535d61a052a7883faa7ddf16.json create mode 100644 .sqlx/query-28812f235c45ceaad83628e6dbf91baafed426de772ac96b89c4abeaa0884fd0.json create mode 100644 .sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json create mode 100644 .sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json create mode 100644 .sqlx/query-2d175633fa638c033a18adc2d3a46dd35bcbbd3852f2e091a32a8736e6281873.json create mode 100644 .sqlx/query-3154e51600aa2a771e63118b0ae735426864f098f2bdd3fda195a19e215a4d97.json create mode 100644 .sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json create mode 100644 .sqlx/query-3412c0b01272536f1ae5e6f40a0e4e89611b375e0327d661e6008405ff846e18.json create mode 100644 .sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json create mode 100644 .sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json create mode 100644 .sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json create mode 100644 .sqlx/query-39397a9c183acdcc7ec008f016f2ce8e7f9a41c700baf3c034c9b6cfd05bff9b.json create mode 100644 .sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json create mode 100644 .sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json create mode 100644 .sqlx/query-3cf33d9b88c9036a4909da485d6b77d1e54994e65b93b3896eecbf6f484f1ff2.json create mode 100644 .sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json create mode 100644 .sqlx/query-40f69cf0cb76e0e4a32d2365cb53030ce330f3b4a9433ef86ed6e4c20c5ff29e.json create mode 100644 .sqlx/query-430b85a07fb4b7421b7e7ff73d8cc31b2534b7ae286f5798c755715d24c0e0a5.json create mode 100644 .sqlx/query-44ee3b52dcf75318fac7926b8fa8ff64e57b1d99185464679e917f7ff26575fa.json create mode 100644 .sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json create mode 100644 .sqlx/query-462550cbdc0980bf6f8f16ff2e4003a85c1cd40feb27960d3b5855f55a124f45.json create mode 100644 .sqlx/query-464e4ebda5bc617485aa352e08146e8451a232dbf4c30f022b3631c3aea7b137.json create mode 100644 .sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json create mode 100644 .sqlx/query-547e6ba62b661614d11d58b140bb8982b41c58a3f5af053e35782f653e205d5c.json create mode 100644 .sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json create mode 100644 .sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json create mode 100644 .sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json create mode 100644 .sqlx/query-5a2422a93db689887746de2720d307f65692caf297ec963a1da605cce3df5064.json create mode 100644 .sqlx/query-5dd2ad31e27d3fb1a9a58eea96c04c88003ea883f3ca8a1a9203481194b7bf71.json create mode 100644 .sqlx/query-65e816ec4da2a68c94415ef6c73dee5ce5b0f829a4a9980b0591cd679bbde6f9.json create mode 100644 .sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json create mode 100644 .sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json create mode 100644 .sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json create mode 100644 .sqlx/query-6e95c1944c0672cd9379fd04ccf958fa5c3cafa588929bb22b43e38ca584a5d7.json create mode 100644 .sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json create mode 100644 .sqlx/query-71f7d3f835fdaa087c2a117d8e53209a96d31585bff814fc9c586b8364471f01.json create mode 100644 .sqlx/query-723d102aefa8dadaab5e618d4c539b84ea556a76becac8e530f439d82dbb3b75.json create mode 100644 .sqlx/query-7463263fbe0aaa0376678fd256cd33801ec15d45165663d3b8b1879a97d87ab7.json create mode 100644 .sqlx/query-76e4c87636449662fff4066bf8ce8707173aefaf0526fe1c0c1c429498fc012d.json create mode 100644 .sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json create mode 100644 .sqlx/query-7aa4ff4a093de219c976a7a96e0dc131a79aea4982daeabf0ae0d9f0d9e1ab66.json create mode 100644 .sqlx/query-7bc55472ebc1ba1d639ea51e852a780b0b64d1ee4216327a853fbb8a41f8df13.json create mode 100644 .sqlx/query-7d7eedd9440576b20675573ffc1255dbd18428936eaebe441c171b63ce7ed1cd.json create mode 100644 .sqlx/query-7d8b9a8384fab0c34938c1f785da583643d14c5573c700ee7aacd0569fe1eb0f.json create mode 100644 .sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json create mode 100644 .sqlx/query-84df6e098194d019f8a157072e2a4759b46b5657caec83da5549f0e92fdaeff1.json create mode 100644 .sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json create mode 100644 .sqlx/query-8892e2b6434cde68fffbbf66c1baec103684470e7acd1b92ed432779eaab7e2d.json create mode 100644 .sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json create mode 100644 .sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json create mode 100644 .sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json create mode 100644 .sqlx/query-93e36a7d98c1d3a0f68687d04d5ef24914f0c8fcde9073917e73a106b68a5f22.json create mode 100644 .sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json create mode 100644 .sqlx/query-983da2c47f94557880f8b49672007d6afef5058c18fa75896ddac311e84a4e4e.json create mode 100644 .sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json create mode 100644 .sqlx/query-9e62ff56e6225fa34ade3cb1a90b3487c4c55616230afb94be5790771406ab78.json create mode 100644 .sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json create mode 100644 .sqlx/query-a2f48c6ccd1f95f1aedaefd233db3ecc2d4e3ecd26fca5b08bc605335db1edde.json create mode 100644 .sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json create mode 100644 .sqlx/query-aadfe99c702899c360d70469c253c1189e9937069911b27ad442eee81e353350.json create mode 100644 .sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json create mode 100644 .sqlx/query-acf69b45685d3fc1ce5794476b219b2819730f8d923da116b40590d579a8ccfa.json create mode 100644 .sqlx/query-b047ddeb24df01a1eba37058388f4cce8ce858efcb295c7ec7ff24def6d7fb9f.json create mode 100644 .sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json create mode 100644 .sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json create mode 100644 .sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json create mode 100644 .sqlx/query-b7fa6125685066c510ceff8da8a70cfff4ee3de19ceac32ae9dac315e038eaa1.json create mode 100644 .sqlx/query-b84f7c9d63fcfb34c8b450c383d62daf3a0ff9dfd6d147bccc5b15b3e9907477.json create mode 100644 .sqlx/query-ba3905d4ebfb9a86625530476f7e37aa080b04c42b74a6bbddc5e50e9c997995.json create mode 100644 .sqlx/query-bb199f13eca194c3604cbb4971676b3e15b3a339bf3bcbbf223b7904c074506b.json create mode 100644 .sqlx/query-bd64fa061dac112fa453f1723a3846378110775877d30b48678e9ecfaaad1d49.json create mode 100644 .sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json create mode 100644 .sqlx/query-c5447abb1d0c41629a700bdeb3cf93218a00dc2d44840519a5fdc15339f08306.json create mode 100644 .sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json create mode 100644 .sqlx/query-c7b9b1531a3208059cd936473df1b3f538cc7ca38748ec878fb2b3db000525dc.json create mode 100644 .sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json create mode 100644 .sqlx/query-ce558982cf10387b6fcd9b72c5a6e38522167ae02338e3bd96e6507516c8b040.json create mode 100644 .sqlx/query-ce764f1f2e36e10790204ac1e701ccaf9d5c86e6f371b10dd5c71e8bf148c236.json create mode 100644 .sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json create mode 100644 .sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json create mode 100644 .sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json create mode 100644 .sqlx/query-d9bd39fd26f25355d477d48c097c2f280afdbf834fd65674fb35c7c7b5422cce.json create mode 100644 .sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json create mode 100644 .sqlx/query-e00c1cb9c4ddbae78d285ffb174a957683e43f53efbbba9063a5975e62c017a7.json create mode 100644 .sqlx/query-e16714f6882867b35cf8342cdbd7e585a44e3f4d4f6c69326ebef6464a6ac7a2.json create mode 100644 .sqlx/query-e204fb68f0c64b4e4efb2e5c4c5d758915ed3b5c31df7e0ea20724b63df4d5ab.json create mode 100644 .sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json create mode 100644 .sqlx/query-e4b26ee6dc07b0d42756fbaa21df35811317a67a82bf660d66a1e9742e644a76.json create mode 100644 .sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json create mode 100644 .sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json create mode 100644 .sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json create mode 100644 .sqlx/query-ed336797264e95f16c683d751337b848a4467cbd985d1257546b264c32f8877f.json create mode 100644 .sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json create mode 100644 .sqlx/query-f127a372a6a77fba6b38afa59b6f51570c5630a6e31520a6d24e17d619e86923.json create mode 100644 .sqlx/query-f403c774a0ac3904f71ad61839b58fee8a81977fe329040ef22fd2cf85998f32.json create mode 100644 .sqlx/query-f4fb885157da083d9c5efb055a57ff2a235d86beeb83dc4ecc511bf4cec24f8f.json create mode 100644 .sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json create mode 100644 .sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json create mode 100644 .sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json create mode 100644 .sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json create mode 100644 .sqlx/query-fdc28e5856d7689243160941e4cba01a6a5c34585302797bb1819c454408c903.json create mode 100644 CHANGELOG.md create mode 100644 crates/zagrosi-core/src/audit.rs create mode 100644 crates/zagrosi-core/src/auth_context.rs create mode 100644 crates/zagrosi-core/src/breach_list_client.rs create mode 100644 crates/zagrosi-core/src/email_transport.rs create mode 100644 crates/zagrosi-core/src/key_provider.rs create mode 100644 crates/zagrosi-core/src/mfa_policy.rs create mode 100644 crates/zagrosi-core/src/rate_limiter.rs create mode 100644 crates/zagrosi-core/src/session_introspector.rs create mode 100644 crates/zagrosi-core/tests/audit_event_v1_schema.rs create mode 100644 crates/zagrosi-core/tests/fixtures/audit_event_v1.json create mode 100644 crates/zagrosi-identity/.sqlx/.gitkeep create mode 100644 crates/zagrosi-identity/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json create mode 100644 crates/zagrosi-identity/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json create mode 100644 crates/zagrosi-identity/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json create mode 100644 crates/zagrosi-identity/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json create mode 100644 crates/zagrosi-identity/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json create mode 100644 crates/zagrosi-identity/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json create mode 100644 crates/zagrosi-identity/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json create mode 100644 crates/zagrosi-identity/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json create mode 100644 crates/zagrosi-identity/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json create mode 100644 crates/zagrosi-identity/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json create mode 100644 crates/zagrosi-identity/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json create mode 100644 crates/zagrosi-identity/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json create mode 100644 crates/zagrosi-identity/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json create mode 100644 crates/zagrosi-identity/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json create mode 100644 crates/zagrosi-identity/.sqlx/query-200fdcb46878290659a2129b97bee366f76f7e7b8ef54e04c48f3aa48e8ddfb2.json create mode 100644 crates/zagrosi-identity/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json create mode 100644 crates/zagrosi-identity/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json create mode 100644 crates/zagrosi-identity/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json create mode 100644 crates/zagrosi-identity/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json create mode 100644 crates/zagrosi-identity/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json create mode 100644 crates/zagrosi-identity/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json create mode 100644 crates/zagrosi-identity/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json create mode 100644 crates/zagrosi-identity/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json create mode 100644 crates/zagrosi-identity/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json create mode 100644 crates/zagrosi-identity/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json create mode 100644 crates/zagrosi-identity/.sqlx/query-4156ed35c22480c46beba3af7027f1402fc222a3903b7cc3724a16772b37f5e0.json create mode 100644 crates/zagrosi-identity/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json create mode 100644 crates/zagrosi-identity/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json create mode 100644 crates/zagrosi-identity/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json create mode 100644 crates/zagrosi-identity/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json create mode 100644 crates/zagrosi-identity/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json create mode 100644 crates/zagrosi-identity/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json create mode 100644 crates/zagrosi-identity/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json create mode 100644 crates/zagrosi-identity/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json create mode 100644 crates/zagrosi-identity/.sqlx/query-6dae596eb74d934944208a9165c9d639ddac0f7ac2d34e667bee346a88b9b217.json create mode 100644 crates/zagrosi-identity/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json create mode 100644 crates/zagrosi-identity/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json create mode 100644 crates/zagrosi-identity/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json create mode 100644 crates/zagrosi-identity/.sqlx/query-8517dcb8c9cdf42394645c45a4c4a1cef504ff5f2c2c29093cf4036f6c635f37.json create mode 100644 crates/zagrosi-identity/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json create mode 100644 crates/zagrosi-identity/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json create mode 100644 crates/zagrosi-identity/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json create mode 100644 crates/zagrosi-identity/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json create mode 100644 crates/zagrosi-identity/.sqlx/query-938df43d9ea37785750d513556cc7c3e3f65e8843e2256d0458979ad55cc7f18.json create mode 100644 crates/zagrosi-identity/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json create mode 100644 crates/zagrosi-identity/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json create mode 100644 crates/zagrosi-identity/.sqlx/query-9f3c30f1771f7998de35a278aa4a16dd4ed45669eed26e4c268c9c9a02a12b8c.json create mode 100644 crates/zagrosi-identity/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json create mode 100644 crates/zagrosi-identity/.sqlx/query-a07d3291607c4c65f0549e6679f9ef65d38ddbd45efc864c0470553902fbdbaf.json create mode 100644 crates/zagrosi-identity/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json create mode 100644 crates/zagrosi-identity/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json create mode 100644 crates/zagrosi-identity/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json create mode 100644 crates/zagrosi-identity/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json create mode 100644 crates/zagrosi-identity/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json create mode 100644 crates/zagrosi-identity/.sqlx/query-b92a174edbdd59c2b66e2e68f15c252eff27fef0ac665163e86dd865a9bf106f.json create mode 100644 crates/zagrosi-identity/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json create mode 100644 crates/zagrosi-identity/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json create mode 100644 crates/zagrosi-identity/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json create mode 100644 crates/zagrosi-identity/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json create mode 100644 crates/zagrosi-identity/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json create mode 100644 crates/zagrosi-identity/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json create mode 100644 crates/zagrosi-identity/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json create mode 100644 crates/zagrosi-identity/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json create mode 100644 crates/zagrosi-identity/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json create mode 100644 crates/zagrosi-identity/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json create mode 100644 crates/zagrosi-identity/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json create mode 100644 crates/zagrosi-identity/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json create mode 100644 crates/zagrosi-identity/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json create mode 100644 crates/zagrosi-identity/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json create mode 100644 crates/zagrosi-identity/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json create mode 100644 crates/zagrosi-identity/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json create mode 100644 crates/zagrosi-identity/Cargo.toml create mode 100644 crates/zagrosi-identity/benches/argon2_calibration.rs create mode 100644 crates/zagrosi-identity/benches/common/mod.rs create mode 100644 crates/zagrosi-identity/benches/session_resolve_bench.rs create mode 100644 crates/zagrosi-identity/benches/session_resolve_bench_cold.rs create mode 100644 crates/zagrosi-identity/benches/signin_oidc_callback_bench.rs create mode 100644 crates/zagrosi-identity/benches/signin_password_bench.rs create mode 100644 crates/zagrosi-identity/benches/signin_saml_acs_bench.rs create mode 100644 crates/zagrosi-identity/fuzz/.gitignore create mode 100644 crates/zagrosi-identity/fuzz/Cargo.lock create mode 100644 crates/zagrosi-identity/fuzz/Cargo.toml create mode 100644 crates/zagrosi-identity/fuzz/corpus/oidc_id_token/id_token_no_sig.json create mode 100644 crates/zagrosi-identity/fuzz/corpus/saml_assertion/.gitkeep create mode 100644 crates/zagrosi-identity/fuzz/corpus/saml_assertion/xsw_a.xml create mode 100644 crates/zagrosi-identity/fuzz/corpus/scim_filter/filter_invalid_001.txt create mode 100644 crates/zagrosi-identity/fuzz/corpus/secrets_open/.gitkeep create mode 100644 crates/zagrosi-identity/fuzz/fuzz_targets/oidc_id_token.rs create mode 100644 crates/zagrosi-identity/fuzz/fuzz_targets/saml_assertion.rs create mode 100644 crates/zagrosi-identity/fuzz/fuzz_targets/scim_filter.rs create mode 100644 crates/zagrosi-identity/fuzz/fuzz_targets/secrets_open.rs create mode 100644 crates/zagrosi-identity/migrations/20260508120000_001_roles_and_extensions.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120100_002_orgs.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120200_003_users.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120300_004_user_org_memberships.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120400_005_sessions.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120500_006_api_tokens.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120600_007_password_resets_and_email_verifications.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120700_008_org_idps_and_org_idp_domains.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120800_009_scim_tokens.sql create mode 100644 crates/zagrosi-identity/migrations/20260508120900_010_email_outbox.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121000_011_oidc_pending_auth.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121100_012_oidc_refresh_tokens.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121200_013_saml_assertion_replay.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121300_014_federated_identities.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121400_015_failed_signin_aggregates.sql create mode 100644 crates/zagrosi-identity/migrations/20260508121500_016_service_tokens.sql create mode 100644 crates/zagrosi-identity/migrations/20260509191612_017_saml_pending_auth.sql create mode 100644 crates/zagrosi-identity/migrations/20260510000100_018_users_scim_columns.sql create mode 100644 crates/zagrosi-identity/migrations/20260510000200_019_scim_groups.sql create mode 100644 crates/zagrosi-identity/migrations/20260510000300_020_org_idp_domains_challenge_token.sql create mode 100644 crates/zagrosi-identity/src/api_tokens/cache.rs create mode 100644 crates/zagrosi-identity/src/api_tokens/mod.rs create mode 100644 crates/zagrosi-identity/src/api_tokens/model.rs create mode 100644 crates/zagrosi-identity/src/api_tokens/resolver.rs create mode 100644 crates/zagrosi-identity/src/api_tokens/service.rs create mode 100644 crates/zagrosi-identity/src/api_tokens/write_behind.rs create mode 100644 crates/zagrosi-identity/src/config.rs create mode 100644 crates/zagrosi-identity/src/crypto/mod.rs create mode 100644 crates/zagrosi-identity/src/crypto/secrets.rs create mode 100644 crates/zagrosi-identity/src/domain/api_token.rs create mode 100644 crates/zagrosi-identity/src/domain/federated.rs create mode 100644 crates/zagrosi-identity/src/domain/group.rs create mode 100644 crates/zagrosi-identity/src/domain/membership.rs create mode 100644 crates/zagrosi-identity/src/domain/mod.rs create mode 100644 crates/zagrosi-identity/src/domain/oidc_pending.rs create mode 100644 crates/zagrosi-identity/src/domain/oidc_refresh.rs create mode 100644 crates/zagrosi-identity/src/domain/org.rs create mode 100644 crates/zagrosi-identity/src/domain/org_idp.rs create mode 100644 crates/zagrosi-identity/src/domain/org_idp_domain.rs create mode 100644 crates/zagrosi-identity/src/domain/saml_pending.rs create mode 100644 crates/zagrosi-identity/src/domain/saml_replay.rs create mode 100644 crates/zagrosi-identity/src/domain/scim_resource.rs create mode 100644 crates/zagrosi-identity/src/domain/service_token.rs create mode 100644 crates/zagrosi-identity/src/domain/session.rs create mode 100644 crates/zagrosi-identity/src/domain/token_format.rs create mode 100644 crates/zagrosi-identity/src/domain/user.rs create mode 100644 crates/zagrosi-identity/src/email/dispatch.rs create mode 100644 crates/zagrosi-identity/src/email/mod.rs create mode 100644 crates/zagrosi-identity/src/email/outbox.rs create mode 100644 crates/zagrosi-identity/src/email/retry.rs create mode 100644 crates/zagrosi-identity/src/email/template.rs create mode 100644 crates/zagrosi-identity/src/email/transport.rs create mode 100644 crates/zagrosi-identity/src/email/worker.rs create mode 100644 crates/zagrosi-identity/src/error.rs create mode 100644 crates/zagrosi-identity/src/http/admin.rs create mode 100644 crates/zagrosi-identity/src/http/api_tokens.rs create mode 100644 crates/zagrosi-identity/src/http/auth.rs create mode 100644 crates/zagrosi-identity/src/http/csrf.rs create mode 100644 crates/zagrosi-identity/src/http/email_verify.rs create mode 100644 crates/zagrosi-identity/src/http/landing.rs create mode 100644 crates/zagrosi-identity/src/http/mod.rs create mode 100644 crates/zagrosi-identity/src/http/oidc.rs create mode 100644 crates/zagrosi-identity/src/http/password_reset.rs create mode 100644 crates/zagrosi-identity/src/http/saml.rs create mode 100644 crates/zagrosi-identity/src/http/scim/attrs.rs create mode 100644 crates/zagrosi-identity/src/http/scim/auth.rs create mode 100644 crates/zagrosi-identity/src/http/scim/discovery.rs create mode 100644 crates/zagrosi-identity/src/http/scim/etag.rs create mode 100644 crates/zagrosi-identity/src/http/scim/filter.rs create mode 100644 crates/zagrosi-identity/src/http/scim/groups.rs create mode 100644 crates/zagrosi-identity/src/http/scim/mod.rs create mode 100644 crates/zagrosi-identity/src/http/scim/patch.rs create mode 100644 crates/zagrosi-identity/src/http/scim/translate.rs create mode 100644 crates/zagrosi-identity/src/http/scim/users.rs create mode 100644 crates/zagrosi-identity/src/http/scim_tokens.rs create mode 100644 crates/zagrosi-identity/src/http/service_tokens.rs create mode 100644 crates/zagrosi-identity/src/http/sessions.rs create mode 100644 crates/zagrosi-identity/src/lib.rs create mode 100644 crates/zagrosi-identity/src/oidc/client.rs create mode 100644 crates/zagrosi-identity/src/oidc/config.rs create mode 100644 crates/zagrosi-identity/src/oidc/cookie.rs create mode 100644 crates/zagrosi-identity/src/oidc/discovery.rs create mode 100644 crates/zagrosi-identity/src/oidc/jit.rs create mode 100644 crates/zagrosi-identity/src/oidc/mod.rs create mode 100644 crates/zagrosi-identity/src/oidc/pending.rs create mode 100644 crates/zagrosi-identity/src/oidc/refresh.rs create mode 100644 crates/zagrosi-identity/src/oidc/service.rs create mode 100644 crates/zagrosi-identity/src/password/breach.rs create mode 100644 crates/zagrosi-identity/src/password/calibration.rs create mode 100644 crates/zagrosi-identity/src/password/hasher.rs create mode 100644 crates/zagrosi-identity/src/password/mod.rs create mode 100644 crates/zagrosi-identity/src/password/policy.rs create mode 100644 crates/zagrosi-identity/src/rate_limit/headers.rs create mode 100644 crates/zagrosi-identity/src/rate_limit/lua.rs create mode 100644 crates/zagrosi-identity/src/rate_limit/mod.rs create mode 100644 crates/zagrosi-identity/src/rate_limit/valkey.rs create mode 100644 crates/zagrosi-identity/src/repo/api_token_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/cascade.rs create mode 100644 crates/zagrosi-identity/src/repo/email_verification_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/failed_signin_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/federated_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/group_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/membership_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/mod.rs create mode 100644 crates/zagrosi-identity/src/repo/oidc_pending_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/oidc_refresh_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/org_idp_domain_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/org_idp_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/org_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/org_scoped.rs create mode 100644 crates/zagrosi-identity/src/repo/password_reset_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/saml_pending_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/saml_replay_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/scim_resource_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/service_token_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/session_repo.rs create mode 100644 crates/zagrosi-identity/src/repo/user_repo.rs create mode 100644 crates/zagrosi-identity/src/routing/blocklist.rs create mode 100644 crates/zagrosi-identity/src/routing/cache.rs create mode 100644 crates/zagrosi-identity/src/routing/data/mod.rs create mode 100644 crates/zagrosi-identity/src/routing/data/public_domain_extras.rs create mode 100644 crates/zagrosi-identity/src/routing/discover.rs create mode 100644 crates/zagrosi-identity/src/routing/dns.rs create mode 100644 crates/zagrosi-identity/src/routing/domain_verify.rs create mode 100644 crates/zagrosi-identity/src/routing/email_normalise.rs create mode 100644 crates/zagrosi-identity/src/routing/mod.rs create mode 100644 crates/zagrosi-identity/src/routing/state.rs create mode 100644 crates/zagrosi-identity/src/routing/tombstone.rs create mode 100644 crates/zagrosi-identity/src/saml/acs.rs create mode 100644 crates/zagrosi-identity/src/saml/attribute.rs create mode 100644 crates/zagrosi-identity/src/saml/authn.rs create mode 100644 crates/zagrosi-identity/src/saml/config.rs create mode 100644 crates/zagrosi-identity/src/saml/errors.rs create mode 100644 crates/zagrosi-identity/src/saml/jit.rs create mode 100644 crates/zagrosi-identity/src/saml/metadata.rs create mode 100644 crates/zagrosi-identity/src/saml/mod.rs create mode 100644 crates/zagrosi-identity/src/saml/relay_state.rs create mode 100644 crates/zagrosi-identity/src/saml/request_id.rs create mode 100644 crates/zagrosi-identity/src/saml/service.rs create mode 100644 crates/zagrosi-identity/src/service/email_verify.rs create mode 100644 crates/zagrosi-identity/src/service/mod.rs create mode 100644 crates/zagrosi-identity/src/service/password_reset.rs create mode 100644 crates/zagrosi-identity/src/service/signin.rs create mode 100644 crates/zagrosi-identity/src/service/signout.rs create mode 100644 crates/zagrosi-identity/src/service/signup.rs create mode 100644 crates/zagrosi-identity/src/service_tokens/cache.rs create mode 100644 crates/zagrosi-identity/src/service_tokens/mod.rs create mode 100644 crates/zagrosi-identity/src/service_tokens/model.rs create mode 100644 crates/zagrosi-identity/src/service_tokens/resolver.rs create mode 100644 crates/zagrosi-identity/src/service_tokens/service.rs create mode 100644 crates/zagrosi-identity/src/session/cache.rs create mode 100644 crates/zagrosi-identity/src/session/continuation.rs create mode 100644 crates/zagrosi-identity/src/session/cookie.rs create mode 100644 crates/zagrosi-identity/src/session/events.rs create mode 100644 crates/zagrosi-identity/src/session/introspector.rs create mode 100644 crates/zagrosi-identity/src/session/issuer.rs create mode 100644 crates/zagrosi-identity/src/session/mod.rs create mode 100644 crates/zagrosi-identity/src/session/port.rs create mode 100644 crates/zagrosi-identity/src/session/revoke.rs create mode 100644 crates/zagrosi-identity/src/session/switch_org.rs create mode 100644 crates/zagrosi-identity/src/session/write_behind.rs create mode 100644 crates/zagrosi-identity/templates/en-US/account_already_exists.ftl create mode 100644 crates/zagrosi-identity/templates/en-US/org_invite.ftl create mode 100644 crates/zagrosi-identity/templates/en-US/password_reset.ftl create mode 100644 crates/zagrosi-identity/templates/en-US/verify_email.ftl create mode 100644 crates/zagrosi-identity/tests/api_tokens.rs create mode 100644 crates/zagrosi-identity/tests/bench_smoke.rs create mode 100644 crates/zagrosi-identity/tests/common/authentik.rs create mode 100644 crates/zagrosi-identity/tests/common/compose.rs create mode 100644 crates/zagrosi-identity/tests/common/fixtures.rs create mode 100644 crates/zagrosi-identity/tests/common/mailpit.rs create mode 100644 crates/zagrosi-identity/tests/common/mod.rs create mode 100644 crates/zagrosi-identity/tests/common/saml_helpers.rs create mode 100644 crates/zagrosi-identity/tests/common/simplesaml.rs create mode 100644 crates/zagrosi-identity/tests/crypto_secrets.rs create mode 100644 crates/zagrosi-identity/tests/docs_handoff.rs create mode 100644 crates/zagrosi-identity/tests/email_outbox.rs create mode 100644 crates/zagrosi-identity/tests/fixtures/bench/oidc_id_token.json create mode 100644 crates/zagrosi-identity/tests/fixtures/bench/saml_assertion.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/hibp_responses/clean.txt create mode 100644 crates/zagrosi-identity/tests/fixtures/hibp_responses/pwned.txt create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/id_token_bad_aud.json create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/id_token_bad_iss.json create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/id_token_bad_nonce.json create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/id_token_expired.json create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/id_token_no_sig.json create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/oidc/rfc9207_iss_mismatch.txt create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/.GENERATOR.md create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/bad_recipient.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/duplicate_id.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/expired_notonorafter.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/idp_initiated.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/replay_assertion.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_a.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_b.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_c.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_d.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_e.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_f.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_g.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xsw_h.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xxe_dtd.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/saml/xxe_external_entity.xml create mode 100644 crates/zagrosi-identity/tests/fixtures/negative/scim/filter_invalid_001.txt create mode 100644 crates/zagrosi-identity/tests/fixtures/scim_resource_types.json create mode 100644 crates/zagrosi-identity/tests/fixtures/scim_schemas.json create mode 100644 crates/zagrosi-identity/tests/fixtures/scim_service_provider_config.json create mode 100644 crates/zagrosi-identity/tests/migrations_smoke.rs create mode 100644 crates/zagrosi-identity/tests/multi_idp_routing.rs create mode 100644 crates/zagrosi-identity/tests/multi_idp_routing_unit.rs create mode 100644 crates/zagrosi-identity/tests/oidc_chain_invariants.rs create mode 100644 crates/zagrosi-identity/tests/oidc_flow.rs create mode 100644 crates/zagrosi-identity/tests/oidc_negative.rs create mode 100644 crates/zagrosi-identity/tests/partial_uniques.rs create mode 100644 crates/zagrosi-identity/tests/password_flow.rs create mode 100644 crates/zagrosi-identity/tests/rate_limit_valkey.rs create mode 100644 crates/zagrosi-identity/tests/repo_round_trips.rs create mode 100644 crates/zagrosi-identity/tests/saml_flow.rs create mode 100644 crates/zagrosi-identity/tests/saml_negative_corpus.rs create mode 100644 crates/zagrosi-identity/tests/schema_invariants.rs create mode 100644 crates/zagrosi-identity/tests/scim_conformance.rs create mode 100644 crates/zagrosi-identity/tests/scim_filter_grammar.rs create mode 100644 crates/zagrosi-identity/tests/scim_inbound_authentik.rs create mode 100644 crates/zagrosi-identity/tests/scim_server.rs create mode 100644 crates/zagrosi-identity/tests/service_tokens.rs create mode 100644 crates/zagrosi-identity/tests/soft_delete_cascade.rs create mode 100644 crates/zagrosi-identity/tests/standards_conformance_map.rs create mode 100644 crates/zagrosi-identity/tests/tenant_isolation.rs create mode 100644 deploy/docker/compose.test.yaml create mode 100644 documentation/api/identity.openapi.yaml create mode 100644 documentation/identity.md create mode 100644 infra/authentik/bootstrap.yaml create mode 100755 infra/postgres/init/00-multi-db.sh create mode 100644 infra/simplesaml/authsources.php create mode 100644 infra/simplesaml/config.php create mode 100644 infra/simplesaml/metadata/saml20-sp-remote.php create mode 100644 infra/simplesaml/saml20-idp-hosted.php create mode 100755 scripts/bootstrap-authentik.sh create mode 100755 scripts/check-bench-gate.sh create mode 100755 scripts/check-branch-protection.sh create mode 100755 scripts/seed-fuzz-corpus.sh create mode 100755 scripts/smoke-sso.sh diff --git a/.env.example b/.env.example index b9b9273..e8c6435 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,10 @@ VALKEY_PORT=6379 NATS_CLIENT_PORT=4222 NATS_MONITOR_PORT=8222 NATS_SERVER_NAME=nats-dev + +# Test compose stack (only required when RUN_INTEGRATION=1 is set) +AUTHENTIK_SECRET_KEY= +AUTHENTIK_BOOTSTRAP_PASSWORD= +AUTHENTIK_BOOTSTRAP_TOKEN= +ZAGROSI_TEST_SCIM_BEARER= +ZAGROSI_TEST_USER_PASSWORD= diff --git a/.github/branch-protection.json b/.github/branch-protection.json index d7cdf46..62ad522 100644 --- a/.github/branch-protection.json +++ b/.github/branch-protection.json @@ -37,6 +37,9 @@ { "context": "rust / cargo deny" }, { "context": "rust / cargo sbom" }, { "context": "rust / compose smoke" }, + { "context": "rust / sso-integration" }, + { "context": "rust / signin-bench" }, + { "context": "rust / fuzz-smoke" }, { "context": "web / pnpm lint" }, { "context": "web / pnpm typecheck" }, { "context": "web / pnpm test" }, diff --git a/.github/workflows/rust-signin-bench.yml b/.github/workflows/rust-signin-bench.yml new file mode 100644 index 0000000..021bb46 --- /dev/null +++ b/.github/workflows/rust-signin-bench.yml @@ -0,0 +1,50 @@ +name: rust / signin-bench + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + signin-bench: + name: signin-bench + runs-on: ubuntu-latest + env: + CARGO_TERM_COLOR: always + RUSTFLAGS: "-D warnings" + ZAGROSI_ARGON2_M_COST: "8" + ZAGROSI_ARGON2_T_COST: "1" + ZAGROSI_ARGON2_P_COST: "1" + ZAGROSI_ARGON2_MAX_CONCURRENCY: "1" + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + - name: Compile identity benches + run: cargo bench --no-run -p zagrosi-identity + - name: Install SAML system dependencies + run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev pkg-config + - name: Compile SAML bench + run: cargo bench --no-run -p zagrosi-identity --features saml --bench signin_saml_acs_bench + - name: Run Argon2 calibration bench + run: cargo bench -p zagrosi-identity --bench argon2_calibration + - name: Run password sign-in bench + run: cargo bench -p zagrosi-identity --bench signin_password_bench + - name: Run OIDC callback bench + run: cargo bench -p zagrosi-identity --bench signin_oidc_callback_bench + - name: Run SAML ACS bench + run: cargo bench -p zagrosi-identity --features saml --bench signin_saml_acs_bench + - name: Run session resolve bench + run: cargo bench -p zagrosi-identity --bench session_resolve_bench + - name: Run cold session resolve bench + run: cargo bench -p zagrosi-identity --bench session_resolve_bench_cold + - name: Gate session resolve throughput + run: scripts/check-bench-gate.sh session_resolve_bench 10000 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: signin-bench-results + path: target/criterion/**/* diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index e6941ff..340292d 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -47,6 +47,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + - name: Install SAML system dependencies + run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev pkg-config - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 with: shared-key: clippy @@ -63,6 +65,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + - name: Install SAML system dependencies + run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev pkg-config - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 with: shared-key: test @@ -115,3 +119,81 @@ jobs: cp .env.example .env chmod +x scripts/smoke-compose.sh bash scripts/smoke-compose.sh + + sso-integration: + name: rust / sso-integration + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 + with: + shared-key: sso-integration + - name: Install SAML system dependencies + run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev pkg-config + - name: Prepare env + run: | + cp .env.example .env + { + echo "POSTGRES_USER=zagrosi" + echo "POSTGRES_PASSWORD=smoke-test-password-not-secret" + echo "POSTGRES_DB=zagrosi" + echo "AUTHENTIK_SECRET_KEY=$(openssl rand -hex 32)" + echo "AUTHENTIK_BOOTSTRAP_PASSWORD=$(openssl rand -hex 16)" + echo "AUTHENTIK_BOOTSTRAP_TOKEN=$(openssl rand -hex 32)" + echo "ZAGROSI_TEST_SCIM_BEARER=scim_$(openssl rand -hex 32)" + echo "ZAGROSI_TEST_USER_PASSWORD=$(openssl rand -hex 16)" + echo "RUN_INTEGRATION=1" + } >> .env + - name: Bring up test compose smoke + run: | + chmod +x scripts/smoke-sso.sh scripts/bootstrap-authentik.sh + bash scripts/smoke-sso.sh + - name: Bring up test compose for tests + run: | + set -a; source .env; set +a + docker compose -f deploy/docker/compose.yaml -f deploy/docker/compose.test.yaml up -d --wait + bash scripts/bootstrap-authentik.sh + - name: Run integration tests + env: + RUN_INTEGRATION: "1" + SQLX_OFFLINE: "true" + DATABASE_URL: postgres://zagrosi:smoke-test-password-not-secret@127.0.0.1:5432/zagrosi + run: cargo test -p zagrosi-identity --features saml --tests --no-fail-fast + - name: Tear down + if: always() + run: | + set -a + [ ! -f .env ] || source .env + set +a + docker compose -f deploy/docker/compose.yaml -f deploy/docker/compose.test.yaml down -v --remove-orphans + + fuzz-smoke: + name: rust / fuzz-smoke + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1.16.0 + with: + toolchain: nightly + - uses: Swatinem/rust-cache@23869a5bd66c73db3c0ac40331f3206eb23791dc # v2.9.1 + with: + shared-key: fuzz-smoke + - name: Install SAML system dependencies + run: sudo apt-get update && sudo apt-get install -y libxmlsec1-dev pkg-config + - name: Install cargo-fuzz + run: RUSTFLAGS="" cargo +stable install cargo-fuzz --locked --version '^0.12' + - name: Build fuzz targets + working-directory: crates/zagrosi-identity + run: | + cargo +nightly fuzz build saml_assertion + cargo +nightly fuzz build scim_filter + cargo +nightly fuzz build oidc_id_token + - name: Smoke each target for 60s + working-directory: crates/zagrosi-identity + run: | + for target in saml_assertion scim_filter oidc_id_token; do + cargo +nightly fuzz run "$target" -- -max_total_time=60 -timeout=10 + done diff --git a/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json b/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json new file mode 100644 index 0000000..8860c87 --- /dev/null +++ b/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\n SET email_verified_at = now(),\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL AND email_verified_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2" +} diff --git a/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json b/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json new file mode 100644 index 0000000..6be9fae --- /dev/null +++ b/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT set_config('app.current_org_id', $1::text, true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "set_config", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361" +} diff --git a/.sqlx/query-04f453dd44347573bfb50c646056c9a90fb56ad32c9f27bb733b56d96ebbbed2.json b/.sqlx/query-04f453dd44347573bfb50c646056c9a90fb56ad32c9f27bb733b56d96ebbbed2.json new file mode 100644 index 0000000..7e071b4 --- /dev/null +++ b/.sqlx/query-04f453dd44347573bfb50c646056c9a90fb56ad32c9f27bb733b56d96ebbbed2.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO group_memberships (id, group_id, user_id)\n VALUES ($1, $2, $3)\n ON CONFLICT (group_id, user_id)\n WHERE deleted_at IS NULL\n DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "04f453dd44347573bfb50c646056c9a90fb56ad32c9f27bb733b56d96ebbbed2" +} diff --git a/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json b/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json new file mode 100644 index 0000000..acfb3f0 --- /dev/null +++ b/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111" +} diff --git a/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json b/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json new file mode 100644 index 0000000..1c01e4c --- /dev/null +++ b/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oidc_refresh_tokens (\n id, session_id, token_hash, prev_id\n )\n VALUES ($1, $2, $3, $4)\n RETURNING id, session_id, token_hash, prev_id,\n issued_at, used_at, revoked_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "prev_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "issued_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26" +} diff --git a/.sqlx/query-08150edbcb053f141547de79013f996c706b131cb0a00b46516e21f0c68944a6.json b/.sqlx/query-08150edbcb053f141547de79013f996c706b131cb0a00b46516e21f0c68944a6.json new file mode 100644 index 0000000..65aca38 --- /dev/null +++ b/.sqlx/query-08150edbcb053f141547de79013f996c706b131cb0a00b46516e21f0c68944a6.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, display_name, scopes, allowed_cidrs, tolerant_mode,\n last_used_at, last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n FROM scim_tokens\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 4, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "08150edbcb053f141547de79013f996c706b131cb0a00b46516e21f0c68944a6" +} diff --git a/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json b/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json new file mode 100644 index 0000000..3cba763 --- /dev/null +++ b/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET password_hash = $2,\n password_hash_version = $3,\n password_updated_at = $4,\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int2", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58" +} diff --git a/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json b/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json new file mode 100644 index 0000000..6cc31fa --- /dev/null +++ b/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE email_verifications\n SET used_at = now()\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06" +} diff --git a/.sqlx/query-0d46f43e4df605f64c2aaa9d2baec646ebf58f09f3601de4886803473a73405d.json b/.sqlx/query-0d46f43e4df605f64c2aaa9d2baec646ebf58f09f3601de4886803473a73405d.json new file mode 100644 index 0000000..888f365 --- /dev/null +++ b/.sqlx/query-0d46f43e4df605f64c2aaa9d2baec646ebf58f09f3601de4886803473a73405d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE service_tokens\n SET revoked_at = now()\n WHERE id = $1 AND revoked_at IS NULL AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "0d46f43e4df605f64c2aaa9d2baec646ebf58f09f3601de4886803473a73405d" +} diff --git a/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json b/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json new file mode 100644 index 0000000..afbfbe0 --- /dev/null +++ b/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE federated_identities\n SET last_login_at = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7" +} diff --git a/.sqlx/query-0fc2c309e39d77125627a0d1eca544a04ba3153cc5ffc767e2c75d61a3b05535.json b/.sqlx/query-0fc2c309e39d77125627a0d1eca544a04ba3153cc5ffc767e2c75d61a3b05535.json new file mode 100644 index 0000000..6b8fd40 --- /dev/null +++ b/.sqlx/query-0fc2c309e39d77125627a0d1eca544a04ba3153cc5ffc767e2c75d61a3b05535.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM saml_pending_auth\n WHERE expires_at < $1 OR used_at IS NOT NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0fc2c309e39d77125627a0d1eca544a04ba3153cc5ffc767e2c75d61a3b05535" +} diff --git a/.sqlx/query-0ff4f704235c7259319c18299aa994c24769193e28e073007c68c1f16df2895a.json b/.sqlx/query-0ff4f704235c7259319c18299aa994c24769193e28e073007c68c1f16df2895a.json new file mode 100644 index 0000000..efd12f4 --- /dev/null +++ b/.sqlx/query-0ff4f704235c7259319c18299aa994c24769193e28e073007c68c1f16df2895a.json @@ -0,0 +1,67 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO groups (id, org_id, display_name, external_id)\n VALUES ($1, $2, $3, $4)\n RETURNING id, org_id, display_name, external_id, row_version,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "0ff4f704235c7259319c18299aa994c24769193e28e073007c68c1f16df2895a" +} diff --git a/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json b/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json new file mode 100644 index 0000000..813c863 --- /dev/null +++ b/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE sessions SET revoked_at = now()\n WHERE org_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1" +} diff --git a/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json b/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json new file mode 100644 index 0000000..0861b19 --- /dev/null +++ b/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE scim_tokens SET deleted_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c" +} diff --git a/.sqlx/query-171579ffb61867ecc279769f3162fbfe2993274d460da2c1f7562d4a5a725353.json b/.sqlx/query-171579ffb61867ecc279769f3162fbfe2993274d460da2c1f7562d4a5a725353.json new file mode 100644 index 0000000..922f393 --- /dev/null +++ b/.sqlx/query-171579ffb61867ecc279769f3162fbfe2993274d460da2c1f7562d4a5a725353.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT i.id AS org_idp_id,\n i.org_id AS org_id,\n i.protocol AS protocol,\n i.display_name AS display_name,\n d.priority AS priority\n FROM org_idp_domains AS d\n JOIN org_idps AS i ON i.id = d.org_idp_id\n WHERE lower(d.domain) = $1\n AND d.verified_at IS NOT NULL\n AND d.deleted_at IS NULL\n AND i.enabled = TRUE\n AND i.deleted_at IS NULL\n ORDER BY d.priority ASC, i.display_name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "priority", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "171579ffb61867ecc279769f3162fbfe2993274d460da2c1f7562d4a5a725353" +} diff --git a/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json b/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json new file mode 100644 index 0000000..0a827dc --- /dev/null +++ b/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json @@ -0,0 +1,89 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n FROM org_idps\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8" +} diff --git a/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json b/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json new file mode 100644 index 0000000..57d5a8e --- /dev/null +++ b/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_org_memberships\n SET deleted_at = now()\n WHERE user_id = $1 AND org_id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee" +} diff --git a/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json b/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json new file mode 100644 index 0000000..1b8a0b9 --- /dev/null +++ b/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE password_resets\n SET used_at = now()\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f" +} diff --git a/.sqlx/query-1b192324e2195693ff9b1dc85811628fb194936ccacabe2e9ee7d12e665fdcbe.json b/.sqlx/query-1b192324e2195693ff9b1dc85811628fb194936ccacabe2e9ee7d12e665fdcbe.json new file mode 100644 index 0000000..965ace9 --- /dev/null +++ b/.sqlx/query-1b192324e2195693ff9b1dc85811628fb194936ccacabe2e9ee7d12e665fdcbe.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE org_id = $1 AND user_id = $2 AND id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "1b192324e2195693ff9b1dc85811628fb194936ccacabe2e9ee7d12e665fdcbe" +} diff --git a/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json b/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json new file mode 100644 index 0000000..de953a5 --- /dev/null +++ b/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json @@ -0,0 +1,69 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_org_memberships (\n id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at, created_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "basic_role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "joined_via", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "jit_provisioned_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279" +} diff --git a/.sqlx/query-1d952c19875b1fec1262efa827de2fa2f12984fabe46ee91acdb6cac5e566828.json b/.sqlx/query-1d952c19875b1fec1262efa827de2fa2f12984fabe46ee91acdb6cac5e566828.json new file mode 100644 index 0000000..2924846 --- /dev/null +++ b/.sqlx/query-1d952c19875b1fec1262efa827de2fa2f12984fabe46ee91acdb6cac5e566828.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "1d952c19875b1fec1262efa827de2fa2f12984fabe46ee91acdb6cac5e566828" +} diff --git a/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json b/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json new file mode 100644 index 0000000..631ffd1 --- /dev/null +++ b/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND user_id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477" +} diff --git a/.sqlx/query-239ed67141d01585e482950468be95753e8eed7ca4290d220f40149446406ba5.json b/.sqlx/query-239ed67141d01585e482950468be95753e8eed7ca4290d220f40149446406ba5.json new file mode 100644 index 0000000..480f1da --- /dev/null +++ b/.sqlx/query-239ed67141d01585e482950468be95753e8eed7ca4290d220f40149446406ba5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE api_tokens SET revoked_at = now()\n WHERE org_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "239ed67141d01585e482950468be95753e8eed7ca4290d220f40149446406ba5" +} diff --git a/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json b/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json new file mode 100644 index 0000000..2e42529 --- /dev/null +++ b/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE user_id = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b" +} diff --git a/.sqlx/query-273adb8382aa5c2fe0f587ed313f37f0dcb369ab535d61a052a7883faa7ddf16.json b/.sqlx/query-273adb8382aa5c2fe0f587ed313f37f0dcb369ab535d61a052a7883faa7ddf16.json new file mode 100644 index 0000000..5665689 --- /dev/null +++ b/.sqlx/query-273adb8382aa5c2fe0f587ed313f37f0dcb369ab535d61a052a7883faa7ddf16.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE groups\n SET row_version = row_version + 1,\n updated_at = now()\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n RETURNING row_version, updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "273adb8382aa5c2fe0f587ed313f37f0dcb369ab535d61a052a7883faa7ddf16" +} diff --git a/.sqlx/query-28812f235c45ceaad83628e6dbf91baafed426de772ac96b89c4abeaa0884fd0.json b/.sqlx/query-28812f235c45ceaad83628e6dbf91baafed426de772ac96b89c4abeaa0884fd0.json new file mode 100644 index 0000000..0d109d7 --- /dev/null +++ b/.sqlx/query-28812f235c45ceaad83628e6dbf91baafed426de772ac96b89c4abeaa0884fd0.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id, m.group_id, m.user_id, m.created_at, m.deleted_at\n FROM group_memberships m\n JOIN groups g ON g.id = m.group_id\n WHERE g.org_id = $1\n AND m.group_id = $2\n AND m.deleted_at IS NULL\n AND g.deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "group_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "28812f235c45ceaad83628e6dbf91baafed426de772ac96b89c4abeaa0884fd0" +} diff --git a/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json b/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json new file mode 100644 index 0000000..abdbaa5 --- /dev/null +++ b/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO failed_signin_aggregates (\n id, org_id, user_id, ip, window_start, count,\n first_attempt_at, last_attempt_at\n )\n VALUES ($1, $2, $3, $4, $5, 1, $6, $6)\n ON CONFLICT (user_id, window_start) DO UPDATE\n SET count = failed_signin_aggregates.count + 1,\n last_attempt_at = EXCLUDED.last_attempt_at\n RETURNING count, (xmax = 0) AS \"first!: bool\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first!: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Inet", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8" +} diff --git a/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json b/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json new file mode 100644 index 0000000..e68e848 --- /dev/null +++ b/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n FROM service_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed" +} diff --git a/.sqlx/query-2d175633fa638c033a18adc2d3a46dd35bcbbd3852f2e091a32a8736e6281873.json b/.sqlx/query-2d175633fa638c033a18adc2d3a46dd35bcbbd3852f2e091a32a8736e6281873.json new file mode 100644 index 0000000..4830051 --- /dev/null +++ b/.sqlx/query-2d175633fa638c033a18adc2d3a46dd35bcbbd3852f2e091a32a8736e6281873.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE group_memberships\n SET deleted_at = now()\n WHERE group_id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2d175633fa638c033a18adc2d3a46dd35bcbbd3852f2e091a32a8736e6281873" +} diff --git a/.sqlx/query-3154e51600aa2a771e63118b0ae735426864f098f2bdd3fda195a19e215a4d97.json b/.sqlx/query-3154e51600aa2a771e63118b0ae735426864f098f2bdd3fda195a19e215a4d97.json new file mode 100644 index 0000000..74f5cb5 --- /dev/null +++ b/.sqlx/query-3154e51600aa2a771e63118b0ae735426864f098f2bdd3fda195a19e215a4d97.json @@ -0,0 +1,74 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO org_idp_domains (\n id, org_idp_id, domain, challenge_token,\n priority\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, org_idp_id, domain, challenge_token,\n verified_at, last_verified_via,\n priority, created_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "challenge_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_verified_via", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "3154e51600aa2a771e63118b0ae735426864f098f2bdd3fda195a19e215a4d97" +} diff --git a/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json b/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json new file mode 100644 index 0000000..ee60313 --- /dev/null +++ b/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO scim_tokens (\n id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, last_used_at,\n last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 6, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Bytea", + "TextArray", + "InetArray", + "Bool", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9" +} diff --git a/.sqlx/query-3412c0b01272536f1ae5e6f40a0e4e89611b375e0327d661e6008405ff846e18.json b/.sqlx/query-3412c0b01272536f1ae5e6f40a0e4e89611b375e0327d661e6008405ff846e18.json new file mode 100644 index 0000000..656ef8b --- /dev/null +++ b/.sqlx/query-3412c0b01272536f1ae5e6f40a0e4e89611b375e0327d661e6008405ff846e18.json @@ -0,0 +1,107 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id, u.email, u.email_lower as \"email_lower!\",\n u.display_name, u.email_verified_at, u.password_hash,\n u.password_updated_at, u.password_hash_version,\n u.mfa_enrolled_at, u.active, u.external_id, u.row_version,\n u.created_at, u.updated_at, u.deleted_at\n FROM users u\n JOIN user_org_memberships m\n ON m.user_id = u.id\n WHERE u.id = $2\n AND m.org_id = $1\n AND u.deleted_at IS NULL\n AND m.deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "3412c0b01272536f1ae5e6f40a0e4e89611b375e0327d661e6008405ff846e18" +} diff --git a/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json b/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json new file mode 100644 index 0000000..3058e86 --- /dev/null +++ b/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_refresh_tokens\n SET revoked_at = now()\n WHERE session_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7" +} diff --git a/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json b/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json new file mode 100644 index 0000000..5dc4cdc --- /dev/null +++ b/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_pending_auth\n SET used_at = $2\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4" +} diff --git a/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json b/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json new file mode 100644 index 0000000..a4f3b89 --- /dev/null +++ b/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO api_tokens (\n id, token_hash, user_id, org_id, display_name,\n scopes, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809" +} diff --git a/.sqlx/query-39397a9c183acdcc7ec008f016f2ce8e7f9a41c700baf3c034c9b6cfd05bff9b.json b/.sqlx/query-39397a9c183acdcc7ec008f016f2ce8e7f9a41c700baf3c034c9b6cfd05bff9b.json new file mode 100644 index 0000000..814ee91 --- /dev/null +++ b/.sqlx/query-39397a9c183acdcc7ec008f016f2ce8e7f9a41c700baf3c034c9b6cfd05bff9b.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idps\n SET config = $3,\n config_version = $4 + 1,\n updated_at = now()\n WHERE org_id = $1\n AND id = $2\n AND config_version = $4\n AND deleted_at IS NULL\n RETURNING config_version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "config_version", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Jsonb", + "Int2" + ] + }, + "nullable": [ + false + ] + }, + "hash": "39397a9c183acdcc7ec008f016f2ce8e7f9a41c700baf3c034c9b6cfd05bff9b" +} diff --git a/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json b/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json new file mode 100644 index 0000000..5e9194a --- /dev/null +++ b/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE org_idp_domains SET deleted_at = now()\n WHERE org_idp_id IN (SELECT id FROM org_idps WHERE org_id = $1)\n AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6" +} diff --git a/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json b/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json new file mode 100644 index 0000000..e72dcf5 --- /dev/null +++ b/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO password_resets (id, user_id, token_hash, expires_at)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1" +} diff --git a/.sqlx/query-3cf33d9b88c9036a4909da485d6b77d1e54994e65b93b3896eecbf6f484f1ff2.json b/.sqlx/query-3cf33d9b88c9036a4909da485d6b77d1e54994e65b93b3896eecbf6f484f1ff2.json new file mode 100644 index 0000000..d7dbdee --- /dev/null +++ b/.sqlx/query-3cf33d9b88c9036a4909da485d6b77d1e54994e65b93b3896eecbf6f484f1ff2.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT d.id, d.org_idp_id, d.domain, d.challenge_token,\n d.verified_at, d.last_verified_via,\n d.priority, d.created_at, d.deleted_at\n FROM org_idp_domains AS d\n JOIN org_idps AS i ON i.id = d.org_idp_id\n WHERE i.org_id = $1\n AND d.org_idp_id = $2\n AND d.deleted_at IS NULL\n AND i.deleted_at IS NULL\n ORDER BY d.priority ASC, d.domain ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "challenge_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_verified_via", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "3cf33d9b88c9036a4909da485d6b77d1e54994e65b93b3896eecbf6f484f1ff2" +} diff --git a/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json b/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json new file mode 100644 index 0000000..649d70b --- /dev/null +++ b/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d" +} diff --git a/.sqlx/query-40f69cf0cb76e0e4a32d2365cb53030ce330f3b4a9433ef86ed6e4c20c5ff29e.json b/.sqlx/query-40f69cf0cb76e0e4a32d2365cb53030ce330f3b4a9433ef86ed6e4c20c5ff29e.json new file mode 100644 index 0000000..0d5e815 --- /dev/null +++ b/.sqlx/query-40f69cf0cb76e0e4a32d2365cb53030ce330f3b4a9433ef86ed6e4c20c5ff29e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO api_tokens (id, token_hash, user_id, org_id, display_name, scopes, expires_at)\n VALUES ($1, $2, $3, $4, 'expired', '{}', $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "40f69cf0cb76e0e4a32d2365cb53030ce330f3b4a9433ef86ed6e4c20c5ff29e" +} diff --git a/.sqlx/query-430b85a07fb4b7421b7e7ff73d8cc31b2534b7ae286f5798c755715d24c0e0a5.json b/.sqlx/query-430b85a07fb4b7421b7e7ff73d8cc31b2534b7ae286f5798c755715d24c0e0a5.json new file mode 100644 index 0000000..28874df --- /dev/null +++ b/.sqlx/query-430b85a07fb4b7421b7e7ff73d8cc31b2534b7ae286f5798c755715d24c0e0a5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE groups\n SET deleted_at = now(), updated_at = now()\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "430b85a07fb4b7421b7e7ff73d8cc31b2534b7ae286f5798c755715d24c0e0a5" +} diff --git a/.sqlx/query-44ee3b52dcf75318fac7926b8fa8ff64e57b1d99185464679e917f7ff26575fa.json b/.sqlx/query-44ee3b52dcf75318fac7926b8fa8ff64e57b1d99185464679e917f7ff26575fa.json new file mode 100644 index 0000000..c6fa300 --- /dev/null +++ b/.sqlx/query-44ee3b52dcf75318fac7926b8fa8ff64e57b1d99185464679e917f7ff26575fa.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, active, external_id, row_version,\n created_at, updated_at, deleted_at\n FROM users\n WHERE email_lower = lower($1) AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "44ee3b52dcf75318fac7926b8fa8ff64e57b1d99185464679e917f7ff26575fa" +} diff --git a/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json b/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json new file mode 100644 index 0000000..cc4469d --- /dev/null +++ b/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE org_id = $1\n AND user_id = $2\n AND revoked_at IS NULL\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c" +} diff --git a/.sqlx/query-462550cbdc0980bf6f8f16ff2e4003a85c1cd40feb27960d3b5855f55a124f45.json b/.sqlx/query-462550cbdc0980bf6f8f16ff2e4003a85c1cd40feb27960d3b5855f55a124f45.json new file mode 100644 index 0000000..cd1276e --- /dev/null +++ b/.sqlx/query-462550cbdc0980bf6f8f16ff2e4003a85c1cd40feb27960d3b5855f55a124f45.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, session_id, token_hash, prev_id,\n issued_at, used_at, revoked_at\n FROM oidc_refresh_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "prev_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "issued_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "462550cbdc0980bf6f8f16ff2e4003a85c1cd40feb27960d3b5855f55a124f45" +} diff --git a/.sqlx/query-464e4ebda5bc617485aa352e08146e8451a232dbf4c30f022b3631c3aea7b137.json b/.sqlx/query-464e4ebda5bc617485aa352e08146e8451a232dbf4c30f022b3631c3aea7b137.json new file mode 100644 index 0000000..70b5bf8 --- /dev/null +++ b/.sqlx/query-464e4ebda5bc617485aa352e08146e8451a232dbf4c30f022b3631c3aea7b137.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET email_verified_at = now() WHERE email_lower = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "464e4ebda5bc617485aa352e08146e8451a232dbf4c30f022b3631c3aea7b137" +} diff --git a/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json b/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json new file mode 100644 index 0000000..cefc809 --- /dev/null +++ b/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE org_id = $1\n AND token_hash = $2\n AND revoked_at IS NULL\n AND (expires_at IS NULL OR expires_at > now())\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb" +} diff --git a/.sqlx/query-547e6ba62b661614d11d58b140bb8982b41c58a3f5af053e35782f653e205d5c.json b/.sqlx/query-547e6ba62b661614d11d58b140bb8982b41c58a3f5af053e35782f653e205d5c.json new file mode 100644 index 0000000..c54a9c4 --- /dev/null +++ b/.sqlx/query-547e6ba62b661614d11d58b140bb8982b41c58a3f5af053e35782f653e205d5c.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, display_name, external_id, row_version,\n created_at, updated_at, deleted_at\n FROM groups\n WHERE org_id = $1 AND deleted_at IS NULL\n ORDER BY id ASC\n OFFSET $2 LIMIT $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "547e6ba62b661614d11d58b140bb8982b41c58a3f5af053e35782f653e205d5c" +} diff --git a/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json b/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json new file mode 100644 index 0000000..a36ff42 --- /dev/null +++ b/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_org_memberships SET deleted_at = now()\n WHERE user_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c" +} diff --git a/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json b/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json new file mode 100644 index 0000000..12fbc0e --- /dev/null +++ b/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO orgs (id, slug, display_name, primary_domain)\n VALUES ($1, $2, $3, $4)\n RETURNING id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1" +} diff --git a/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json b/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json new file mode 100644 index 0000000..c247458 --- /dev/null +++ b/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE federated_identities SET user_id = NULL\n WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae" +} diff --git a/.sqlx/query-5a2422a93db689887746de2720d307f65692caf297ec963a1da605cce3df5064.json b/.sqlx/query-5a2422a93db689887746de2720d307f65692caf297ec963a1da605cce3df5064.json new file mode 100644 index 0000000..92748a1 --- /dev/null +++ b/.sqlx/query-5a2422a93db689887746de2720d307f65692caf297ec963a1da605cce3df5064.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, display_name, external_id, row_version,\n created_at, updated_at, deleted_at\n FROM groups\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "5a2422a93db689887746de2720d307f65692caf297ec963a1da605cce3df5064" +} diff --git a/.sqlx/query-5dd2ad31e27d3fb1a9a58eea96c04c88003ea883f3ca8a1a9203481194b7bf71.json b/.sqlx/query-5dd2ad31e27d3fb1a9a58eea96c04c88003ea883f3ca8a1a9203481194b7bf71.json new file mode 100644 index 0000000..dff5982 --- /dev/null +++ b/.sqlx/query-5dd2ad31e27d3fb1a9a58eea96c04c88003ea883f3ca8a1a9203481194b7bf71.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE user_id = $1\n AND org_id = $2\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5dd2ad31e27d3fb1a9a58eea96c04c88003ea883f3ca8a1a9203481194b7bf71" +} diff --git a/.sqlx/query-65e816ec4da2a68c94415ef6c73dee5ce5b0f829a4a9980b0591cd679bbde6f9.json b/.sqlx/query-65e816ec4da2a68c94415ef6c73dee5ce5b0f829a4a9980b0591cd679bbde6f9.json new file mode 100644 index 0000000..9407405 --- /dev/null +++ b/.sqlx/query-65e816ec4da2a68c94415ef6c73dee5ce5b0f829a4a9980b0591cd679bbde6f9.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id, org_idp_id\n FROM federated_identities\n WHERE protocol = $1\n AND issuer_or_entity_id = $2\n AND subject_or_nameid = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "65e816ec4da2a68c94415ef6c73dee5ce5b0f829a4a9980b0591cd679bbde6f9" +} diff --git a/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json b/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json new file mode 100644 index 0000000..fc91340 --- /dev/null +++ b/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, protocol, issuer_or_entity_id, subject_or_nameid,\n org_idp_id, user_id, created_at, last_login_at\n FROM federated_identities\n WHERE protocol = $1\n AND issuer_or_entity_id = $2\n AND subject_or_nameid = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issuer_or_entity_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "subject_or_nameid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44" +} diff --git a/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json b/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json new file mode 100644 index 0000000..02f4876 --- /dev/null +++ b/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE id = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738" +} diff --git a/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json b/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json new file mode 100644 index 0000000..5c5e239 --- /dev/null +++ b/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n FROM orgs\n WHERE slug = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37" +} diff --git a/.sqlx/query-6e95c1944c0672cd9379fd04ccf958fa5c3cafa588929bb22b43e38ca584a5d7.json b/.sqlx/query-6e95c1944c0672cd9379fd04ccf958fa5c3cafa588929bb22b43e38ca584a5d7.json new file mode 100644 index 0000000..98ecced --- /dev/null +++ b/.sqlx/query-6e95c1944c0672cd9379fd04ccf958fa5c3cafa588929bb22b43e38ca584a5d7.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at, created_at, deleted_at\n FROM user_org_memberships\n WHERE user_id = $1 AND org_id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "basic_role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "joined_via", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "jit_provisioned_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "6e95c1944c0672cd9379fd04ccf958fa5c3cafa588929bb22b43e38ca584a5d7" +} diff --git a/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json b/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json new file mode 100644 index 0000000..283e67c --- /dev/null +++ b/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE federated_identities\n SET user_id = NULL\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3" +} diff --git a/.sqlx/query-71f7d3f835fdaa087c2a117d8e53209a96d31585bff814fc9c586b8364471f01.json b/.sqlx/query-71f7d3f835fdaa087c2a117d8e53209a96d31585bff814fc9c586b8364471f01.json new file mode 100644 index 0000000..0296898 --- /dev/null +++ b/.sqlx/query-71f7d3f835fdaa087c2a117d8e53209a96d31585bff814fc9c586b8364471f01.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT active FROM users WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "active", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "71f7d3f835fdaa087c2a117d8e53209a96d31585bff814fc9c586b8364471f01" +} diff --git a/.sqlx/query-723d102aefa8dadaab5e618d4c539b84ea556a76becac8e530f439d82dbb3b75.json b/.sqlx/query-723d102aefa8dadaab5e618d4c539b84ea556a76becac8e530f439d82dbb3b75.json new file mode 100644 index 0000000..fc96d89 --- /dev/null +++ b/.sqlx/query-723d102aefa8dadaab5e618d4c539b84ea556a76becac8e530f439d82dbb3b75.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO saml_pending_auth (\n id, request_id, relay_state, org_idp_id, expires_at\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, request_id, relay_state, org_idp_id,\n created_at, expires_at, used_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "request_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "relay_state", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "723d102aefa8dadaab5e618d4c539b84ea556a76becac8e530f439d82dbb3b75" +} diff --git a/.sqlx/query-7463263fbe0aaa0376678fd256cd33801ec15d45165663d3b8b1879a97d87ab7.json b/.sqlx/query-7463263fbe0aaa0376678fd256cd33801ec15d45165663d3b8b1879a97d87ab7.json new file mode 100644 index 0000000..aa5b180 --- /dev/null +++ b/.sqlx/query-7463263fbe0aaa0376678fd256cd33801ec15d45165663d3b8b1879a97d87ab7.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, request_id, relay_state, org_idp_id,\n created_at, expires_at, used_at\n FROM saml_pending_auth\n WHERE relay_state = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "request_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "relay_state", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "7463263fbe0aaa0376678fd256cd33801ec15d45165663d3b8b1879a97d87ab7" +} diff --git a/.sqlx/query-76e4c87636449662fff4066bf8ce8707173aefaf0526fe1c0c1c429498fc012d.json b/.sqlx/query-76e4c87636449662fff4066bf8ce8707173aefaf0526fe1c0c1c429498fc012d.json new file mode 100644 index 0000000..e588778 --- /dev/null +++ b/.sqlx/query-76e4c87636449662fff4066bf8ce8707173aefaf0526fe1c0c1c429498fc012d.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, display_name, scopes, allowed_cidrs, tolerant_mode,\n last_used_at, last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n FROM scim_tokens\n WHERE org_id = $1 AND deleted_at IS NULL\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 3, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 4, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "76e4c87636449662fff4066bf8ce8707173aefaf0526fe1c0c1c429498fc012d" +} diff --git a/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json b/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json new file mode 100644 index 0000000..eb47e16 --- /dev/null +++ b/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE orgs SET deleted_at = now(), updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699" +} diff --git a/.sqlx/query-7aa4ff4a093de219c976a7a96e0dc131a79aea4982daeabf0ae0d9f0d9e1ab66.json b/.sqlx/query-7aa4ff4a093de219c976a7a96e0dc131a79aea4982daeabf0ae0d9f0d9e1ab66.json new file mode 100644 index 0000000..35c53af --- /dev/null +++ b/.sqlx/query-7aa4ff4a093de219c976a7a96e0dc131a79aea4982daeabf0ae0d9f0d9e1ab66.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_idp_id, state_hash, nonce_hash, verifier_hash,\n csrf_cookie_hash, redirect_uri, created_at, expires_at, used_at\n FROM oidc_pending_auth\n WHERE state_hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "nonce_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "verifier_hash", + "type_info": "Bytea" + }, + { + "ordinal": 5, + "name": "csrf_cookie_hash", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "7aa4ff4a093de219c976a7a96e0dc131a79aea4982daeabf0ae0d9f0d9e1ab66" +} diff --git a/.sqlx/query-7bc55472ebc1ba1d639ea51e852a780b0b64d1ee4216327a853fbb8a41f8df13.json b/.sqlx/query-7bc55472ebc1ba1d639ea51e852a780b0b64d1ee4216327a853fbb8a41f8df13.json new file mode 100644 index 0000000..8b68f04 --- /dev/null +++ b/.sqlx/query-7bc55472ebc1ba1d639ea51e852a780b0b64d1ee4216327a853fbb8a41f8df13.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) AS \"count!\" FROM sessions\n WHERE user_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7bc55472ebc1ba1d639ea51e852a780b0b64d1ee4216327a853fbb8a41f8df13" +} diff --git a/.sqlx/query-7d7eedd9440576b20675573ffc1255dbd18428936eaebe441c171b63ce7ed1cd.json b/.sqlx/query-7d7eedd9440576b20675573ffc1255dbd18428936eaebe441c171b63ce7ed1cd.json new file mode 100644 index 0000000..2213f65 --- /dev/null +++ b/.sqlx/query-7d7eedd9440576b20675573ffc1255dbd18428936eaebe441c171b63ce7ed1cd.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT org_id, user_id\n FROM sessions\n WHERE id = $1\n AND revoked_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "7d7eedd9440576b20675573ffc1255dbd18428936eaebe441c171b63ce7ed1cd" +} diff --git a/.sqlx/query-7d8b9a8384fab0c34938c1f785da583643d14c5573c700ee7aacd0569fe1eb0f.json b/.sqlx/query-7d8b9a8384fab0c34938c1f785da583643d14c5573c700ee7aacd0569fe1eb0f.json new file mode 100644 index 0000000..083bde3 --- /dev/null +++ b/.sqlx/query-7d8b9a8384fab0c34938c1f785da583643d14c5573c700ee7aacd0569fe1eb0f.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT d.id, d.org_idp_id, d.domain, d.challenge_token,\n d.verified_at, d.last_verified_via,\n d.priority, d.created_at, d.deleted_at\n FROM org_idp_domains AS d\n JOIN org_idps AS i ON i.id = d.org_idp_id\n WHERE i.org_id = $1\n AND d.org_idp_id = $2\n AND d.id = $3\n AND d.deleted_at IS NULL\n AND i.deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "challenge_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_verified_via", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "7d8b9a8384fab0c34938c1f785da583643d14c5573c700ee7aacd0569fe1eb0f" +} diff --git a/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json b/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json new file mode 100644 index 0000000..48c7785 --- /dev/null +++ b/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET deleted_at = now(), updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c" +} diff --git a/.sqlx/query-84df6e098194d019f8a157072e2a4759b46b5657caec83da5549f0e92fdaeff1.json b/.sqlx/query-84df6e098194d019f8a157072e2a4759b46b5657caec83da5549f0e92fdaeff1.json new file mode 100644 index 0000000..272caa3 --- /dev/null +++ b/.sqlx/query-84df6e098194d019f8a157072e2a4759b46b5657caec83da5549f0e92fdaeff1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE saml_pending_auth\n SET used_at = $2\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "84df6e098194d019f8a157072e2a4759b46b5657caec83da5549f0e92fdaeff1" +} diff --git a/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json b/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json new file mode 100644 index 0000000..e0375a0 --- /dev/null +++ b/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO email_verifications (id, user_id, email, token_hash, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87" +} diff --git a/.sqlx/query-8892e2b6434cde68fffbbf66c1baec103684470e7acd1b92ed432779eaab7e2d.json b/.sqlx/query-8892e2b6434cde68fffbbf66c1baec103684470e7acd1b92ed432779eaab7e2d.json new file mode 100644 index 0000000..98cc48a --- /dev/null +++ b/.sqlx/query-8892e2b6434cde68fffbbf66c1baec103684470e7acd1b92ed432779eaab7e2d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT last_used_at, last_used_ip FROM api_tokens WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "last_used_ip", + "type_info": "Inet" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "8892e2b6434cde68fffbbf66c1baec103684470e7acd1b92ed432779eaab7e2d" +} diff --git a/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json b/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json new file mode 100644 index 0000000..370ae86 --- /dev/null +++ b/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET email_verified_at = $2,\n updated_at = now()\n WHERE id = $1\n AND deleted_at IS NULL\n AND email_verified_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe" +} diff --git a/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json b/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json new file mode 100644 index 0000000..cb63987 --- /dev/null +++ b/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE sessions SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a" +} diff --git a/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json b/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json new file mode 100644 index 0000000..c4e669c --- /dev/null +++ b/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json @@ -0,0 +1,108 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sessions (\n id, token_hash, user_id, org_id,\n user_agent, ip_addr, amr, acr, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid", + "Text", + "Inet", + "TextArray", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f" +} diff --git a/.sqlx/query-93e36a7d98c1d3a0f68687d04d5ef24914f0c8fcde9073917e73a106b68a5f22.json b/.sqlx/query-93e36a7d98c1d3a0f68687d04d5ef24914f0c8fcde9073917e73a106b68a5f22.json new file mode 100644 index 0000000..e926f01 --- /dev/null +++ b/.sqlx/query-93e36a7d98c1d3a0f68687d04d5ef24914f0c8fcde9073917e73a106b68a5f22.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE scim_tokens\n SET last_used_at = now(),\n last_used_ip = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Inet" + ] + }, + "nullable": [] + }, + "hash": "93e36a7d98c1d3a0f68687d04d5ef24914f0c8fcde9073917e73a106b68a5f22" +} diff --git a/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json b/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json new file mode 100644 index 0000000..45e9460 --- /dev/null +++ b/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n FROM orgs\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff" +} diff --git a/.sqlx/query-983da2c47f94557880f8b49672007d6afef5058c18fa75896ddac311e84a4e4e.json b/.sqlx/query-983da2c47f94557880f8b49672007d6afef5058c18fa75896ddac311e84a4e4e.json new file mode 100644 index 0000000..5100bbb --- /dev/null +++ b/.sqlx/query-983da2c47f94557880f8b49672007d6afef5058c18fa75896ddac311e84a4e4e.json @@ -0,0 +1,62 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n FROM service_tokens\n WHERE revoked_at IS NULL AND deleted_at IS NULL\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "983da2c47f94557880f8b49672007d6afef5058c18fa75896ddac311e84a4e4e" +} diff --git a/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json b/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json new file mode 100644 index 0000000..9827273 --- /dev/null +++ b/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45" +} diff --git a/.sqlx/query-9e62ff56e6225fa34ade3cb1a90b3487c4c55616230afb94be5790771406ab78.json b/.sqlx/query-9e62ff56e6225fa34ade3cb1a90b3487c4c55616230afb94be5790771406ab78.json new file mode 100644 index 0000000..fe96765 --- /dev/null +++ b/.sqlx/query-9e62ff56e6225fa34ade3cb1a90b3487c4c55616230afb94be5790771406ab78.json @@ -0,0 +1,111 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET display_name = $3,\n external_id = $4,\n active = $5,\n row_version = row_version + 1,\n updated_at = now()\n WHERE id = $1\n AND deleted_at IS NULL\n AND EXISTS (\n SELECT 1 FROM user_org_memberships m\n WHERE m.user_id = users.id\n AND m.org_id = $2\n AND m.deleted_at IS NULL\n )\n AND ($6::BIGINT IS NULL OR row_version = $6)\n RETURNING\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, active, external_id, row_version,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Bool", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "9e62ff56e6225fa34ade3cb1a90b3487c4c55616230afb94be5790771406ab78" +} diff --git a/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json b/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json new file mode 100644 index 0000000..5da1009 --- /dev/null +++ b/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_org_memberships SET deleted_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796" +} diff --git a/.sqlx/query-a2f48c6ccd1f95f1aedaefd233db3ecc2d4e3ecd26fca5b08bc605335db1edde.json b/.sqlx/query-a2f48c6ccd1f95f1aedaefd233db3ecc2d4e3ecd26fca5b08bc605335db1edde.json new file mode 100644 index 0000000..c7a0a1e --- /dev/null +++ b/.sqlx/query-a2f48c6ccd1f95f1aedaefd233db3ecc2d4e3ecd26fca5b08bc605335db1edde.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET\n last_used_at = GREATEST(COALESCE(last_used_at, $3), $3),\n last_used_ip = CASE\n WHEN last_used_at IS NULL OR last_used_at < $3 THEN $4\n ELSE last_used_ip\n END\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Inet" + ] + }, + "nullable": [] + }, + "hash": "a2f48c6ccd1f95f1aedaefd233db3ecc2d4e3ecd26fca5b08bc605335db1edde" +} diff --git a/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json b/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json new file mode 100644 index 0000000..48bd60b --- /dev/null +++ b/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\n SET password_hash = $2,\n password_hash_version = 1,\n password_updated_at = $3,\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859" +} diff --git a/.sqlx/query-aadfe99c702899c360d70469c253c1189e9937069911b27ad442eee81e353350.json b/.sqlx/query-aadfe99c702899c360d70469c253c1189e9937069911b27ad442eee81e353350.json new file mode 100644 index 0000000..3b80f17 --- /dev/null +++ b/.sqlx/query-aadfe99c702899c360d70469c253c1189e9937069911b27ad442eee81e353350.json @@ -0,0 +1,112 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (\n id, email, display_name, password_hash,\n password_updated_at, password_hash_version,\n external_id\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, active, external_id, row_version,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Timestamptz", + "Int2", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "aadfe99c702899c360d70469c253c1189e9937069911b27ad442eee81e353350" +} diff --git a/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json b/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json new file mode 100644 index 0000000..1b425b3 --- /dev/null +++ b/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO federated_identities (\n id, protocol, issuer_or_entity_id, subject_or_nameid,\n org_idp_id, user_id, last_login_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, protocol, issuer_or_entity_id,\n subject_or_nameid, org_idp_id, user_id,\n created_at, last_login_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issuer_or_entity_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "subject_or_nameid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc" +} diff --git a/.sqlx/query-acf69b45685d3fc1ce5794476b219b2819730f8d923da116b40590d579a8ccfa.json b/.sqlx/query-acf69b45685d3fc1ce5794476b219b2819730f8d923da116b40590d579a8ccfa.json new file mode 100644 index 0000000..b81761f --- /dev/null +++ b/.sqlx/query-acf69b45685d3fc1ce5794476b219b2819730f8d923da116b40590d579a8ccfa.json @@ -0,0 +1,108 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n u.id, u.email, u.email_lower as \"email_lower!\",\n u.display_name, u.email_verified_at, u.password_hash,\n u.password_updated_at, u.password_hash_version,\n u.mfa_enrolled_at, u.active, u.external_id, u.row_version,\n u.created_at, u.updated_at, u.deleted_at\n FROM users u\n JOIN user_org_memberships m ON m.user_id = u.id\n WHERE m.org_id = $1\n AND u.deleted_at IS NULL\n AND m.deleted_at IS NULL\n ORDER BY u.id ASC\n OFFSET $2 LIMIT $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "acf69b45685d3fc1ce5794476b219b2819730f8d923da116b40590d579a8ccfa" +} diff --git a/.sqlx/query-b047ddeb24df01a1eba37058388f4cce8ce858efcb295c7ec7ff24def6d7fb9f.json b/.sqlx/query-b047ddeb24df01a1eba37058388f4cce8ce858efcb295c7ec7ff24def6d7fb9f.json new file mode 100644 index 0000000..c05303d --- /dev/null +++ b/.sqlx/query-b047ddeb24df01a1eba37058388f4cce8ce858efcb295c7ec7ff24def6d7fb9f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) AS \"count!\"\n FROM groups\n WHERE org_id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b047ddeb24df01a1eba37058388f4cce8ce858efcb295c7ec7ff24def6d7fb9f" +} diff --git a/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json b/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json new file mode 100644 index 0000000..688472c --- /dev/null +++ b/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO email_outbox (\n id, org_id, to_address, from_address, subject,\n body_text, body_html, template_key, locale,\n idempotency_key, state, attempts, next_attempt_at\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, 'en',\n $9, 'queued', 0, now()\n )\n ON CONFLICT (org_id, idempotency_key) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e" +} diff --git a/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json b/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json new file mode 100644 index 0000000..e821f33 --- /dev/null +++ b/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212" +} diff --git a/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json b/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json new file mode 100644 index 0000000..5772399 --- /dev/null +++ b/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, expires_at, used_at\n FROM email_verifications\n WHERE token_hash = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924" +} diff --git a/.sqlx/query-b7fa6125685066c510ceff8da8a70cfff4ee3de19ceac32ae9dac315e038eaa1.json b/.sqlx/query-b7fa6125685066c510ceff8da8a70cfff4ee3de19ceac32ae9dac315e038eaa1.json new file mode 100644 index 0000000..0b9788b --- /dev/null +++ b/.sqlx/query-b7fa6125685066c510ceff8da8a70cfff4ee3de19ceac32ae9dac315e038eaa1.json @@ -0,0 +1,73 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idp_domains AS d\n SET verified_at = now(),\n last_verified_via = $4\n FROM org_idps AS i\n WHERE d.org_idp_id = i.id\n AND i.org_id = $1\n AND d.org_idp_id = $2\n AND d.id = $3\n AND d.deleted_at IS NULL\n AND i.deleted_at IS NULL\n RETURNING d.id, d.org_idp_id, d.domain, d.challenge_token,\n d.verified_at, d.last_verified_via,\n d.priority, d.created_at, d.deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "domain", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "challenge_token", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "last_verified_via", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "priority", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "b7fa6125685066c510ceff8da8a70cfff4ee3de19ceac32ae9dac315e038eaa1" +} diff --git a/.sqlx/query-b84f7c9d63fcfb34c8b450c383d62daf3a0ff9dfd6d147bccc5b15b3e9907477.json b/.sqlx/query-b84f7c9d63fcfb34c8b450c383d62daf3a0ff9dfd6d147bccc5b15b3e9907477.json new file mode 100644 index 0000000..b5deb8c --- /dev/null +++ b/.sqlx/query-b84f7c9d63fcfb34c8b450c383d62daf3a0ff9dfd6d147bccc5b15b3e9907477.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idp_domains AS d\n SET deleted_at = now()\n FROM org_idps AS i\n WHERE d.org_idp_id = i.id\n AND i.org_id = $1\n AND d.org_idp_id = $2\n AND d.id = $3\n AND d.deleted_at IS NULL\n AND i.deleted_at IS NULL\n RETURNING d.domain\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "domain", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b84f7c9d63fcfb34c8b450c383d62daf3a0ff9dfd6d147bccc5b15b3e9907477" +} diff --git a/.sqlx/query-ba3905d4ebfb9a86625530476f7e37aa080b04c42b74a6bbddc5e50e9c997995.json b/.sqlx/query-ba3905d4ebfb9a86625530476f7e37aa080b04c42b74a6bbddc5e50e9c997995.json new file mode 100644 index 0000000..d9aeb37 --- /dev/null +++ b/.sqlx/query-ba3905d4ebfb9a86625530476f7e37aa080b04c42b74a6bbddc5e50e9c997995.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT template_key FROM email_outbox WHERE to_address = $1 ORDER BY created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "template_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ba3905d4ebfb9a86625530476f7e37aa080b04c42b74a6bbddc5e50e9c997995" +} diff --git a/.sqlx/query-bb199f13eca194c3604cbb4971676b3e15b3a339bf3bcbbf223b7904c074506b.json b/.sqlx/query-bb199f13eca194c3604cbb4971676b3e15b3a339bf3bcbbf223b7904c074506b.json new file mode 100644 index 0000000..f54c4cf --- /dev/null +++ b/.sqlx/query-bb199f13eca194c3604cbb4971676b3e15b3a339bf3bcbbf223b7904c074506b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE group_memberships\n SET deleted_at = now()\n WHERE group_id = $1\n AND user_id = $2\n AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "bb199f13eca194c3604cbb4971676b3e15b3a339bf3bcbbf223b7904c074506b" +} diff --git a/.sqlx/query-bd64fa061dac112fa453f1723a3846378110775877d30b48678e9ecfaaad1d49.json b/.sqlx/query-bd64fa061dac112fa453f1723a3846378110775877d30b48678e9ecfaaad1d49.json new file mode 100644 index 0000000..c275016 --- /dev/null +++ b/.sqlx/query-bd64fa061dac112fa453f1723a3846378110775877d30b48678e9ecfaaad1d49.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE groups\n SET display_name = $3,\n external_id = $4,\n row_version = row_version + 1,\n updated_at = now()\n WHERE org_id = $1\n AND id = $2\n AND deleted_at IS NULL\n AND ($5::BIGINT IS NULL OR row_version = $5)\n RETURNING id, org_id, display_name, external_id, row_version,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "bd64fa061dac112fa453f1723a3846378110775877d30b48678e9ecfaaad1d49" +} diff --git a/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json b/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json new file mode 100644 index 0000000..e7be83c --- /dev/null +++ b/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE scim_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1" +} diff --git a/.sqlx/query-c5447abb1d0c41629a700bdeb3cf93218a00dc2d44840519a5fdc15339f08306.json b/.sqlx/query-c5447abb1d0c41629a700bdeb3cf93218a00dc2d44840519a5fdc15339f08306.json new file mode 100644 index 0000000..8930689 --- /dev/null +++ b/.sqlx/query-c5447abb1d0c41629a700bdeb3cf93218a00dc2d44840519a5fdc15339f08306.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 AS hit\n FROM federated_identities f\n JOIN org_idps i ON i.id = f.org_idp_id\n WHERE f.user_id = $1\n AND i.org_id <> $2\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hit", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c5447abb1d0c41629a700bdeb3cf93218a00dc2d44840519a5fdc15339f08306" +} diff --git a/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json b/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json new file mode 100644 index 0000000..5188b07 --- /dev/null +++ b/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, expires_at, used_at\n FROM password_resets\n WHERE token_hash = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627" +} diff --git a/.sqlx/query-c7b9b1531a3208059cd936473df1b3f538cc7ca38748ec878fb2b3db000525dc.json b/.sqlx/query-c7b9b1531a3208059cd936473df1b3f538cc7ca38748ec878fb2b3db000525dc.json new file mode 100644 index 0000000..6703c5c --- /dev/null +++ b/.sqlx/query-c7b9b1531a3208059cd936473df1b3f538cc7ca38748ec878fb2b3db000525dc.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, last_used_at,\n last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n FROM scim_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND (expires_at IS NULL OR expires_at > now())\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 6, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "c7b9b1531a3208059cd936473df1b3f538cc7ca38748ec878fb2b3db000525dc" +} diff --git a/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json b/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json new file mode 100644 index 0000000..3ec201e --- /dev/null +++ b/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idps\n SET deleted_at = now(), updated_at = now()\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f" +} diff --git a/.sqlx/query-ce558982cf10387b6fcd9b72c5a6e38522167ae02338e3bd96e6507516c8b040.json b/.sqlx/query-ce558982cf10387b6fcd9b72c5a6e38522167ae02338e3bd96e6507516c8b040.json new file mode 100644 index 0000000..ea78337 --- /dev/null +++ b/.sqlx/query-ce558982cf10387b6fcd9b72c5a6e38522167ae02338e3bd96e6507516c8b040.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sessions (id, token_hash, user_id, org_id,\n version, amr, expires_at)\n VALUES ($1, $2, $3, $4, 0, ARRAY['pwd']::TEXT[], now() + INTERVAL '1 hour')\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ce558982cf10387b6fcd9b72c5a6e38522167ae02338e3bd96e6507516c8b040" +} diff --git a/.sqlx/query-ce764f1f2e36e10790204ac1e701ccaf9d5c86e6f371b10dd5c71e8bf148c236.json b/.sqlx/query-ce764f1f2e36e10790204ac1e701ccaf9d5c86e6f371b10dd5c71e8bf148c236.json new file mode 100644 index 0000000..eae0a1c --- /dev/null +++ b/.sqlx/query-ce764f1f2e36e10790204ac1e701ccaf9d5c86e6f371b10dd5c71e8bf148c236.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE group_memberships AS gm\n SET deleted_at = now()\n FROM groups g\n WHERE gm.group_id = $1\n AND g.id = gm.group_id\n AND g.org_id = $2\n AND gm.deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ce764f1f2e36e10790204ac1e701ccaf9d5c86e6f371b10dd5c71e8bf148c236" +} diff --git a/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json b/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json new file mode 100644 index 0000000..49d3874 --- /dev/null +++ b/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE api_tokens SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e" +} diff --git a/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json b/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json new file mode 100644 index 0000000..e81a311 --- /dev/null +++ b/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET org_id = $2,\n version = version + 1,\n last_seen_at = now()\n WHERE id = $1\n AND version = $3\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n RETURNING version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7" +} diff --git a/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json b/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json new file mode 100644 index 0000000..8a84b3c --- /dev/null +++ b/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_refresh_tokens\n SET used_at = $2\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98" +} diff --git a/.sqlx/query-d9bd39fd26f25355d477d48c097c2f280afdbf834fd65674fb35c7c7b5422cce.json b/.sqlx/query-d9bd39fd26f25355d477d48c097c2f280afdbf834fd65674fb35c7c7b5422cce.json new file mode 100644 index 0000000..40f2398 --- /dev/null +++ b/.sqlx/query-d9bd39fd26f25355d477d48c097c2f280afdbf834fd65674fb35c7c7b5422cce.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, active, external_id, row_version,\n created_at, updated_at, deleted_at\n FROM users\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "d9bd39fd26f25355d477d48c097c2f280afdbf834fd65674fb35c7c7b5422cce" +} diff --git a/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json b/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json new file mode 100644 index 0000000..9d2474b --- /dev/null +++ b/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM saml_assertion_replay\n WHERE not_on_or_after < $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56" +} diff --git a/.sqlx/query-e00c1cb9c4ddbae78d285ffb174a957683e43f53efbbba9063a5975e62c017a7.json b/.sqlx/query-e00c1cb9c4ddbae78d285ffb174a957683e43f53efbbba9063a5975e62c017a7.json new file mode 100644 index 0000000..9d98f49 --- /dev/null +++ b/.sqlx/query-e00c1cb9c4ddbae78d285ffb174a957683e43f53efbbba9063a5975e62c017a7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT 1 AS sentinel\n FROM org_idps\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sentinel", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e00c1cb9c4ddbae78d285ffb174a957683e43f53efbbba9063a5975e62c017a7" +} diff --git a/.sqlx/query-e16714f6882867b35cf8342cdbd7e585a44e3f4d4f6c69326ebef6464a6ac7a2.json b/.sqlx/query-e16714f6882867b35cf8342cdbd7e585a44e3f4d4f6c69326ebef6464a6ac7a2.json new file mode 100644 index 0000000..9d5c4bd --- /dev/null +++ b/.sqlx/query-e16714f6882867b35cf8342cdbd7e585a44e3f4d4f6c69326ebef6464a6ac7a2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(*) AS \"count!\"\n FROM users u\n JOIN user_org_memberships m ON m.user_id = u.id\n WHERE m.org_id = $1\n AND u.deleted_at IS NULL\n AND m.deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e16714f6882867b35cf8342cdbd7e585a44e3f4d4f6c69326ebef6464a6ac7a2" +} diff --git a/.sqlx/query-e204fb68f0c64b4e4efb2e5c4c5d758915ed3b5c31df7e0ea20724b63df4d5ab.json b/.sqlx/query-e204fb68f0c64b4e4efb2e5c4c5d758915ed3b5c31df7e0ea20724b63df4d5ab.json new file mode 100644 index 0000000..79de5a8 --- /dev/null +++ b/.sqlx/query-e204fb68f0c64b4e4efb2e5c4c5d758915ed3b5c31df7e0ea20724b63df4d5ab.json @@ -0,0 +1,106 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, active, external_id, row_version,\n created_at, updated_at, deleted_at\n FROM users\n WHERE email_lower = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "active", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "external_id", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "row_version", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 14, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + true, + false, + false, + false, + true + ] + }, + "hash": "e204fb68f0c64b4e4efb2e5c4c5d758915ed3b5c31df7e0ea20724b63df4d5ab" +} diff --git a/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json b/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json new file mode 100644 index 0000000..a5d06ce --- /dev/null +++ b/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE org_idps SET deleted_at = now(), updated_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402" +} diff --git a/.sqlx/query-e4b26ee6dc07b0d42756fbaa21df35811317a67a82bf660d66a1e9742e644a76.json b/.sqlx/query-e4b26ee6dc07b0d42756fbaa21df35811317a67a82bf660d66a1e9742e644a76.json new file mode 100644 index 0000000..1c4a8b0 --- /dev/null +++ b/.sqlx/query-e4b26ee6dc07b0d42756fbaa21df35811317a67a82bf660d66a1e9742e644a76.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT revoked_at FROM api_tokens WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e4b26ee6dc07b0d42756fbaa21df35811317a67a82bf660d66a1e9742e644a76" +} diff --git a/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json b/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json new file mode 100644 index 0000000..8cf2083 --- /dev/null +++ b/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, last_used_at,\n last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n FROM scim_tokens\n WHERE org_id = $1\n AND token_hash = $2\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND (expires_at IS NULL OR expires_at > now())\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 6, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2" +} diff --git a/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json b/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json new file mode 100644 index 0000000..a0b900a --- /dev/null +++ b/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO service_tokens (\n id, service_name, token_hash, allowed_subjects, display_name\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215" +} diff --git a/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json b/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json new file mode 100644 index 0000000..e558a5a --- /dev/null +++ b/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n FROM org_idps\n WHERE org_id = $1 AND deleted_at IS NULL\n ORDER BY display_name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818" +} diff --git a/.sqlx/query-ed336797264e95f16c683d751337b848a4467cbd985d1257546b264c32f8877f.json b/.sqlx/query-ed336797264e95f16c683d751337b848a4467cbd985d1257546b264c32f8877f.json new file mode 100644 index 0000000..1c138e9 --- /dev/null +++ b/.sqlx/query-ed336797264e95f16c683d751337b848a4467cbd985d1257546b264c32f8877f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT template_key FROM email_outbox WHERE to_address = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "template_key", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ed336797264e95f16c683d751337b848a4467cbd985d1257546b264c32f8877f" +} diff --git a/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json b/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json new file mode 100644 index 0000000..e61fb5b --- /dev/null +++ b/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO saml_assertion_replay (\n org_idp_id, assertion_id, not_on_or_after\n )\n VALUES ($1, $2, $3)\n RETURNING org_idp_id, assertion_id, not_on_or_after, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "assertion_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "not_on_or_after", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07" +} diff --git a/.sqlx/query-f127a372a6a77fba6b38afa59b6f51570c5630a6e31520a6d24e17d619e86923.json b/.sqlx/query-f127a372a6a77fba6b38afa59b6f51570c5630a6e31520a6d24e17d619e86923.json new file mode 100644 index 0000000..97aa4f2 --- /dev/null +++ b/.sqlx/query-f127a372a6a77fba6b38afa59b6f51570c5630a6e31520a6d24e17d619e86923.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n FROM service_tokens\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "f127a372a6a77fba6b38afa59b6f51570c5630a6e31520a6d24e17d619e86923" +} diff --git a/.sqlx/query-f403c774a0ac3904f71ad61839b58fee8a81977fe329040ef22fd2cf85998f32.json b/.sqlx/query-f403c774a0ac3904f71ad61839b58fee8a81977fe329040ef22fd2cf85998f32.json new file mode 100644 index 0000000..4f4ec52 --- /dev/null +++ b/.sqlx/query-f403c774a0ac3904f71ad61839b58fee8a81977fe329040ef22fd2cf85998f32.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT password_hash, password_updated_at, email_verified_at FROM users WHERE email_lower = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "email_verified_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true + ] + }, + "hash": "f403c774a0ac3904f71ad61839b58fee8a81977fe329040ef22fd2cf85998f32" +} diff --git a/.sqlx/query-f4fb885157da083d9c5efb055a57ff2a235d86beeb83dc4ecc511bf4cec24f8f.json b/.sqlx/query-f4fb885157da083d9c5efb055a57ff2a235d86beeb83dc4ecc511bf4cec24f8f.json new file mode 100644 index 0000000..0a122df --- /dev/null +++ b/.sqlx/query-f4fb885157da083d9c5efb055a57ff2a235d86beeb83dc4ecc511bf4cec24f8f.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, request_id, relay_state, org_idp_id,\n created_at, expires_at, used_at\n FROM saml_pending_auth\n WHERE relay_state = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "request_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "relay_state", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f4fb885157da083d9c5efb055a57ff2a235d86beeb83dc4ecc511bf4cec24f8f" +} diff --git a/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json b/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json new file mode 100644 index 0000000..20d0ca6 --- /dev/null +++ b/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oidc_pending_auth (\n id, org_idp_id, state_hash, nonce_hash, verifier_hash,\n csrf_cookie_hash, redirect_uri, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, org_idp_id, state_hash, nonce_hash,\n verifier_hash, csrf_cookie_hash, redirect_uri,\n created_at, expires_at, used_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "nonce_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "verifier_hash", + "type_info": "Bytea" + }, + { + "ordinal": 5, + "name": "csrf_cookie_hash", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Bytea", + "Bytea", + "Bytea", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1" +} diff --git a/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json b/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json new file mode 100644 index 0000000..78aa4b3 --- /dev/null +++ b/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at, created_at, deleted_at\n FROM user_org_memberships\n WHERE user_id = $1 AND deleted_at IS NULL\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "basic_role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "joined_via", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "jit_provisioned_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081" +} diff --git a/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json b/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json new file mode 100644 index 0000000..689e8d7 --- /dev/null +++ b/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json @@ -0,0 +1,96 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO org_idps (\n id, org_id, protocol, display_name,\n config, config_version, jit_provisioning,\n is_default, enabled\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Jsonb", + "Int2", + "Bool", + "Bool", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de" +} diff --git a/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json b/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json new file mode 100644 index 0000000..be7d372 --- /dev/null +++ b/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE org_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c" +} diff --git a/.sqlx/query-fdc28e5856d7689243160941e4cba01a6a5c34585302797bb1819c454408c903.json b/.sqlx/query-fdc28e5856d7689243160941e4cba01a6a5c34585302797bb1819c454408c903.json new file mode 100644 index 0000000..88cf91b --- /dev/null +++ b/.sqlx/query-fdc28e5856d7689243160941e4cba01a6a5c34585302797bb1819c454408c903.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE group_memberships AS gm\n SET deleted_at = now()\n FROM groups g\n WHERE gm.group_id = $1\n AND g.id = gm.group_id\n AND g.org_id = $2\n AND gm.deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fdc28e5856d7689243160941e4cba01a6a5c34585302797bb1819c454408c903" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..47411df --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# Changelog + +All notable changes to the Zagrosi platform are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- identity: `zagrosi-identity` foundation crate and `zagrosi-core` + ports for `AuthContext`, `AuditEvent`, `Auditor`, `EmailTransport`, + `BreachListClient`, `KeyProvider`, `RateLimiter`, `MfaPolicy`, and + `SessionIntrospector`. +- identity: password sign-up, sign-in, sign-out, email verification, + and password reset with NIST 800-63B length policy, HIBP + k-anonymity support, anti-enumeration responses, dummy verify on + unknown sign-in email, and Argon2id calibration. +- identity: browser sessions with `sid_<43>` token format, + `__Host-zagrosi_sid` cookies, double-submit CSRF, active-org + switching, in-process LRU cache, fail-closed degraded TTL, and NATS + revocation hints. +- identity: personal access tokens with `pat_<43>` format, scope + checks, per-token rate limiting, last-used write-behind, and + self-revoke support. +- identity: OIDC Authorization Code with PKCE S256, RFC 9207 `iss` + validation, nonce and ID-token checks, JIT linking through + `federated_identities`, refresh-token chain replay detection, + discovery pre-warm, and optional JWKS thumbprint pinning. +- identity: feature-gated SAML SP with AuthnRequest, strict ACS + validation order, replay table, metadata export, signed-node-only + extraction, and negative/fuzz corpus coverage. +- identity: SCIM 2.0 Users, Groups, discovery endpoints, bearer + tokens, CIDR allowlist, RFC 7644 filter grammar, ETag and + `If-Match`, pagination, sorting, PATCH, and SCIM token issuance. +- identity: multi-IdP routing with DNSSEC domain verification, + resolver quorum, PSL plus curated catch-all blocklist, and + tombstone-aware SSO linking. +- identity: Valkey-backed rate limiting with per-IP sliding window, + per-account lockout, admin unlock, `Retry-After`, and `RateLimit-*` + headers. +- identity: email outbox worker with NATS wakeup, SMTP transport, + idempotency keys, retry backoff, dead-letter state, and Fluent + templates. +- identity: service-token CRUD with `svc_<43>` format and constrained + allowed NATS subjects. +- identity: AES-256-GCM secrets envelope for persisted client secrets + and SAML SP keys. +- identity: deterministic test compose stack with Authentik, + SimpleSAMLphp, and Mailpit plus CI jobs `rust / sso-integration`, + `rust / fuzz-smoke`, and `rust / signin-bench`. +- identity: Criterion performance benches and warm session-resolve + throughput gate. +- docs: OpenAPI spec at `documentation/api/identity.openapi.yaml` and + operator handoff at `documentation/identity.md`. + +### Changed + +- ci: branch-protection payload now requires `rust / sso-integration`, + `rust / signin-bench`, and `rust / fuzz-smoke`. + +### Deferred + +- KMS-backed envelope and Argon2 pepper. +- Per-tenant SMTP transport. +- Account-merge admin UX. +- TOTP and WebAuthn MFA. +- Idle-account Argon2 background rehash job. +- Offline-mirror HIBP client. +- SAML SP signing-key rotation flow. +- SCIM Bulk support. +- zxcvbn-style entropy estimator. + +### Security + +- Sign-up and password-reset request paths are anti-enumeration by + construction. +- SSO account linking is anchored on `(protocol, issuer, subject)`; + email is not an SSO key. +- IdP-initiated SAML is disabled by default. +- SCIM bearer authentication and CIDR allowlist checks run before + resource lookup. +- Persisted secrets use the identity secrets envelope rather than + plaintext columns. diff --git a/Cargo.lock b/Cargo.lock index d49ebcd..321b14f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,48 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -11,6 +53,33 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.102" @@ -26,6 +95,122 @@ dependencies = [ "zagrosi-core", ] +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce73b17c62717c4b6a9af10b43e87c578b0cac27e00666d48304d3b7d2c0693" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand 0.8.6", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -37,6 +222,15 @@ dependencies = [ "syn", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.6.1" @@ -65,6 +259,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -132,12 +327,50 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -158,6 +391,101 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64 0.22.1", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64 0.22.1", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] [[package]] name = "bumpalo" @@ -171,11 +499,36 @@ version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" @@ -189,6 +542,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -196,790 +558,2363 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cmake" -version = "0.1.58" +name = "cfg_aliases" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] -name = "core-foundation" -version = "0.10.1" +name = "chacha20" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ - "core-foundation-sys", - "libc", + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", ] [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "chrono" +version = "0.4.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] [[package]] -name = "crossbeam-epoch" -version = "0.9.18" +name = "ciborium" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" dependencies = [ - "crossbeam-utils", + "ciborium-io", + "ciborium-ll", + "serde", ] [[package]] -name = "crossbeam-utils" -version = "0.8.21" +name = "ciborium-io" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" [[package]] -name = "displaydoc" -version = "0.2.5" +name = "ciborium-ll" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ - "proc-macro2", - "quote", - "syn", + "ciborium-io", + "half", ] [[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "equivalent" -version = "1.0.2" +name = "cipher" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] [[package]] -name = "errno" -version = "0.3.14" +name = "clang-sys" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" dependencies = [ + "glob", "libc", - "windows-sys 0.61.2", + "libloading", ] [[package]] -name = "evmap" -version = "11.0.0" +name = "clap" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ - "hashbag", - "left-right", - "smallvec", + "clap_builder", ] [[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "figment" -version = "0.10.19" +name = "clap_builder" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ - "atomic", - "parking_lot", - "pear", - "serde", - "tempfile", - "toml", - "uncased", - "version_check", + "anstyle", + "clap_lex", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "clap_lex" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] -name = "fnv" -version = "1.0.7" +name = "cmake" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] [[package]] -name = "foldhash" -version = "0.1.5" +name = "concurrent-queue" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] [[package]] -name = "foldhash" -version = "0.2.0" +name = "const-oid" +version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "cookie" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "aes-gcm", + "base64 0.22.1", + "hkdf", + "hmac", "percent-encoding", + "rand 0.8.6", + "sha2", + "subtle", + "time", + "version_check", ] [[package]] -name = "fs_extra" -version = "1.3.0" +name = "cookie-factory" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" [[package]] -name = "futures-channel" -version = "0.3.32" +name = "core-foundation" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ - "futures-core", - "futures-sink", + "core-foundation-sys", + "libc", ] [[package]] -name = "futures-core" -version = "0.3.32" +name = "core-foundation-sys" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "futures-executor" -version = "0.3.32" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "futures-core", - "futures-task", - "futures-util", + "libc", ] [[package]] -name = "futures-io" -version = "0.3.32" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] [[package]] -name = "futures-macro" -version = "0.3.32" +name = "crc" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ - "proc-macro2", - "quote", - "syn", + "crc-catalog", ] [[package]] -name = "futures-sink" -version = "0.3.32" +name = "crc-catalog" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] -name = "futures-task" -version = "0.3.32" +name = "crc16" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" [[package]] -name = "futures-util" -version = "0.3.32" +name = "crc32fast" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", + "cfg-if", ] [[package]] -name = "generator" -version = "0.8.8" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" dependencies = [ - "cc", - "cfg-if", - "libc", - "log", - "rustversion", - "windows-link", - "windows-result", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "futures", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "tokio", + "walkdir", ] [[package]] -name = "getrandom" -version = "0.2.17" +name = "criterion-plot" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" dependencies = [ - "cfg-if", - "libc", - "wasi", + "cast", + "itertools 0.10.5", ] [[package]] -name = "getrandom" -version = "0.3.4" +name = "critical-section" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] -name = "getrandom" -version = "0.4.2" +name = "crossbeam-channel" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" dependencies = [ - "cfg-if", - "libc", - "r-efi 6.0.0", - "wasip2", - "wasip3", + "crossbeam-utils", ] [[package]] -name = "h2" -version = "0.4.14" +name = "crossbeam-deque" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", + "crossbeam-epoch", + "crossbeam-utils", ] [[package]] -name = "hashbag" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" - -[[package]] -name = "hashbrown" -version = "0.15.5" +name = "crossbeam-epoch" +version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ - "foldhash 0.1.5", + "crossbeam-utils", ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "crossbeam-queue" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" dependencies = [ - "foldhash 0.2.0", + "crossbeam-utils", ] [[package]] -name = "hashbrown" -version = "0.17.0" +name = "crossbeam-utils" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] -name = "heck" -version = "0.5.0" +name = "crunchy" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "http" -version = "1.4.0" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "bytes", - "itoa", + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", ] [[package]] -name = "http-body" -version = "1.0.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "bytes", - "http", + "generic-array", + "rand_core 0.6.4", + "typenum", ] [[package]] -name = "http-body-util" -version = "0.1.3" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", + "cipher", ] [[package]] -name = "httparse" -version = "1.10.1" +name = "curve25519-dalek" +version = "4.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] [[package]] -name = "httpdate" -version = "1.0.3" +name = "curve25519-dalek-derive" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] -name = "hyper" -version = "1.9.0" +name = "darling" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", + "darling_core 0.20.11", + "darling_macro 0.20.11", ] [[package]] -name = "hyper-rustls" -version = "0.27.9" +name = "darling" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-native-certs", - "tokio", - "tokio-rustls", - "tower-service", + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "darling_core" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] -name = "icu_collections" -version = "2.2.0" +name = "darling_core" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "darling_macro" +version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", + "darling_core 0.20.11", + "quote", + "syn", ] [[package]] -name = "icu_normalizer" -version = "2.2.0" +name = "darling_macro" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", + "darling_core 0.23.0", + "quote", + "syn", ] [[package]] -name = "icu_normalizer_data" -version = "2.2.0" +name = "dashmap" +version = "6.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] [[package]] -name = "icu_properties" -version = "2.2.0" +name = "data-encoding" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", ] [[package]] -name = "icu_properties_data" -version = "2.2.0" +name = "deadpool-runtime" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" [[package]] -name = "icu_provider" -version = "2.2.0" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", + "const-oid", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "id-arena" -version = "2.3.0" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] [[package]] -name = "idna" -version = "1.1.0" +name = "derive_builder" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", + "derive_builder_macro", ] [[package]] -name = "idna_adapter" -version = "1.2.2" +name = "derive_builder_core" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "icu_normalizer", - "icu_properties", + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "indexmap" -version = "2.14.0" +name = "derive_builder_macro" +version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ - "equivalent", - "hashbrown 0.17.0", + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docker_credential" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4564c274ebf369f501de192b02a0b81a5c4bda375abfe526aa70fc702fa6fa0" +dependencies = [ + "base64 0.22.1", "serde", - "serde_core", + "serde_json", ] [[package]] -name = "inlinable_string" -version = "0.1.15" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" [[package]] -name = "ipnet" -version = "2.12.0" +name = "dunce" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] -name = "itertools" -version = "0.14.0" +name = "dyn-clone" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "either", + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "ed25519" +version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] [[package]] -name = "jobserver" -version = "0.1.34" +name = "ed25519-dalek" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" dependencies = [ - "getrandom 0.3.4", - "libc", + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", ] [[package]] -name = "js-sys" -version = "0.3.98" +name = "either" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" dependencies = [ - "cfg-if", - "futures-util", - "once_cell", - "wasm-bindgen", + "serde", ] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] [[package]] -name = "leb128fmt" -version = "0.1.0" +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.1", + "web-time", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "tempfile", + "toml 0.8.23", + "uncased", + "version_check", +] + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "fluent-template-macros" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748050b3fb6fd97b566aedff8e9e021389c963e73d5afbeb92752c2b8d686c6c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unic-langid", + "walkdir", +] + +[[package]] +name = "fluent-templates" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56264446a01f404469aef9cc5fd4a4d736f68a0f52482bf6d1a54d6e9bbd9476" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "intl-memoizer", + "log", + "thiserror 2.0.18", + "unic-langid", + "walkdir", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.6", + "redis-protocol", + "rustls", + "rustls-native-certs", + "semver", + "sha-1", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "aws-lc-rs", + "bitflags", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "rustls-pki-types", + "thiserror 2.0.18", + "time", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "left-right" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.3", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libxml" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe73cdec2bcb36d25a9fe3f607ffcd44bb8907ca0100c4098d1aa342d1e7bec" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap 2.14.0", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "rustls", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e56997f084e57b045edf17c3ed8ba7f9f779c670df8206dfd1c736f4c02dc4a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] -name = "left-right" -version = "0.11.7" +name = "nuid" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" dependencies = [ - "crossbeam-utils", - "loom", - "slab", + "rand 0.8.6", ] [[package]] -name = "libc" -version = "0.2.186" +name = "num" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] [[package]] -name = "linux-raw-sys" -version = "0.12.1" +name = "num-bigint" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] [[package]] -name = "litemap" -version = "0.8.2" +name = "num-bigint-dig" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] [[package]] -name = "lock_api" -version = "0.4.14" +name = "num-complex" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" dependencies = [ - "scopeguard", + "num-traits", ] [[package]] -name = "log" -version = "0.4.29" +name = "num-conv" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] -name = "loom" -version = "0.7.2" +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ - "cfg-if", - "generator", - "scoped-tls", - "tracing", - "tracing-subscriber", + "num-traits", ] [[package]] -name = "matchers" -version = "0.2.0" +name = "num-iter" +version = "0.1.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" dependencies = [ - "regex-automata", + "autocfg", + "num-integer", + "num-traits", ] [[package]] -name = "matchit" -version = "0.8.4" +name = "num-rational" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] [[package]] -name = "memchr" -version = "2.8.0" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] [[package]] -name = "metrics" -version = "0.24.5" +name = "num_cpus" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff56c2e7dce6bd462e3b8919986a617027481b1dcc703175b58cf9dd98a2f071" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "portable-atomic", - "rapidhash", + "hermit-abi", + "libc", ] [[package]] -name = "metrics-exporter-prometheus" -version = "0.18.3" +name = "oauth2" +version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64", - "evmap", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "indexmap", - "ipnet", - "metrics", - "metrics-util", - "quanta", - "rustls", - "thiserror", - "tokio", - "tracing", + "base64 0.21.7", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", ] [[package]] -name = "metrics-util" -version = "0.20.3" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e56997f084e57b045edf17c3ed8ba7f9f779c670df8206dfd1c736f4c02dc4a" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", - "hashbrown 0.16.1", - "metrics", - "quanta", - "rand", - "rand_xoshiro", - "rapidhash", - "sketches-ddsketch", + "critical-section", + "portable-atomic", ] [[package]] -name = "mime" -version = "0.3.17" +name = "oorandom" +version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] -name = "mio" -version = "1.2.0" +name = "opaque-debug" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", ] [[package]] -name = "nu-ansi-term" -version = "0.50.3" +name = "openssl" +version = "0.10.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" dependencies = [ - "windows-sys 0.61.2", + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", ] [[package]] -name = "num-traits" -version = "0.2.19" +name = "openssl-macros" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ - "autocfg", + "proc-macro2", + "quote", + "syn", ] [[package]] -name = "once_cell" -version = "1.21.4" +name = "openssl-probe" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" @@ -987,6 +2922,18 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.31.0" @@ -997,7 +2944,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1027,7 +2974,7 @@ dependencies = [ "opentelemetry_sdk", "prost", "reqwest", - "thiserror", + "thiserror 2.0.18", "tracing", ] @@ -1055,12 +3002,51 @@ dependencies = [ "futures-util", "opentelemetry", "percent-encoding", - "rand", - "thiserror", + "rand 0.9.4", + "thiserror 2.0.18", "tokio", "tokio-stream", ] +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1079,11 +3065,47 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pear" version = "0.2.9" @@ -1101,43 +3123,125 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ - "proc-macro2", - "proc-macro2-diagnostics", - "quote", - "syn", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "pin-project" -version = "1.1.12" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbf0d9e68100b3a7989b4901972f265cd542e560a3a8a724e1e20322f4d06ce9" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" dependencies = [ - "pin-project-internal", + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "pin-project-internal" -version = "1.1.12" +name = "plotters-backend" +version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a990e22f43e84855daf260dded30524ef4a9021cc7541c26540500a50b624389" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" dependencies = [ - "proc-macro2", - "quote", - "syn", + "plotters-backend", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "polyval" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] [[package]] name = "portable-atomic" @@ -1154,6 +3258,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1173,6 +3283,21 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1205,8 +3330,8 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", - "rand_chacha", + "rand 0.9.4", + "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1231,12 +3356,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn", ] +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "psl" +version = "2.1.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bde51f827dca976f8f9a8c91329a3193114dc076b8012a1ee3624f1588c3582" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + [[package]] name = "quanta" version = "0.12.6" @@ -1258,6 +3407,80 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -1267,6 +3490,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + [[package]] name = "r-efi" version = "5.3.0" @@ -1279,14 +3508,46 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ - "rand_chacha", - "rand_core", + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", ] [[package]] @@ -1296,7 +3557,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -1308,13 +3578,19 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1323,7 +3599,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -1344,6 +3620,40 @@ dependencies = [ "bitflags", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom 7.1.3", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1353,6 +3663,47 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -1376,7 +3727,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-core", @@ -1385,16 +3736,21 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1402,6 +3758,23 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", ] [[package]] @@ -1414,10 +3787,45 @@ dependencies = [ "cfg-if", "getrandom 0.2.17", "libc", - "untrusted", + "untrusted 0.9.0", "windows-sys 0.52.0", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1438,7 +3846,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", + "log", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -1451,7 +3861,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ - "openssl-probe", + "openssl-probe 0.2.1", "rustls-pki-types", "schannel", "security-framework", @@ -1463,6 +3873,7 @@ version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ + "web-time", "zeroize", ] @@ -1475,7 +3886,7 @@ dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] [[package]] @@ -1502,6 +3913,41 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "samael" +version = "0.0.20" +source = "git+https://github.com/njaremko/samael.git?rev=f879f1942ec1b34b6d3027ce7e4724ad95d15dfa#f879f1942ec1b34b6d3027ce7e4724ad95d15dfa" +dependencies = [ + "base64 0.22.1", + "bindgen", + "chrono", + "data-encoding", + "derive_builder", + "flate2", + "lazy_static", + "libc", + "libxml", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "pkg-config", + "quick-xml 0.37.5", + "rand 0.9.4", + "serde", + "thiserror 2.0.18", + "url", + "uuid", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scc" version = "2.4.0" @@ -1517,7 +3963,31 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", ] [[package]] @@ -1538,6 +4008,29 @@ version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -1561,6 +4054,12 @@ dependencies = [ "libc", ] +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + [[package]] name = "semver" version = "1.0.28" @@ -1577,6 +4076,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -1610,6 +4119,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + [[package]] name = "serde_path_to_error" version = "0.1.20" @@ -1621,6 +4139,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -1630,6 +4168,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1642,6 +4189,37 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serial_test" version = "3.4.0" @@ -1668,6 +4246,39 @@ dependencies = [ "syn", ] +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1694,44 +4305,344 @@ dependencies = [ ] [[package]] -name = "sketches-ddsketch" -version = "0.3.1" +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera 0.8.0", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "slab" -version = "0.4.12" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] -name = "smallvec" -version = "1.15.1" +name = "stringprep" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "structmeta" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "structmeta-derive" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "subtle" @@ -1770,6 +4681,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tempfile" version = "3.27.0" @@ -1783,13 +4706,82 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera 0.11.0", + "ferroid", + "futures", + "http", + "itertools 0.14.0", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -1812,6 +4804,37 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -1819,9 +4842,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.2" @@ -1834,7 +4883,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -1884,6 +4933,27 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.6", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + [[package]] name = "toml" version = "0.8.23" @@ -1891,11 +4961,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.2", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -1905,18 +4990,36 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap", + "indexmap 2.14.0", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.2", ] [[package]] @@ -1925,6 +5028,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tonic" version = "0.14.6" @@ -1932,15 +5041,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", - "base64", + "axum", + "base64 0.22.1", "bytes", + "h2", "http", "http-body", "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", "percent-encoding", "pin-project", + "socket2 0.6.3", "sync_wrapper", + "tokio", "tokio-stream", + "tower", "tower-layer", "tower-service", "tracing", @@ -1965,9 +5082,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -2094,32 +5214,157 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" +dependencies = [ + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "trybuild" +version = "1.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c635f0191bd3a2941013e5062667100969f8c4e9cd787c14f977265d73616e" +dependencies = [ + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.2+spec-1.1.0", +] + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] -name = "unarray" -version = "0.1.4" +name = "unicode-ident" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "uncased" -version = "0.9.10" +name = "unicode-normalization" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" dependencies = [ - "version_check", + "tinyvec", ] [[package]] -name = "unicode-ident" -version = "1.0.24" +name = "unicode-properties" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-xid" @@ -2127,12 +5372,55 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -2143,20 +5431,66 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -2172,6 +5506,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2205,6 +5549,12 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -2277,7 +5627,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap", + "indexmap 2.14.0", "wasm-encoder", "wasmparser", ] @@ -2290,7 +5640,7 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "semver", ] @@ -2314,6 +5664,40 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" @@ -2330,18 +5714,73 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -2351,13 +5790,31 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -2369,34 +5826,67 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2409,24 +5899,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2442,6 +5956,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" + +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64 0.22.1", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" @@ -2476,7 +6019,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck", - "indexmap", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -2507,7 +6050,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -2526,7 +6069,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap", + "indexmap 2.14.0", "log", "semver", "serde", @@ -2542,6 +6085,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yansi" version = "1.0.1" @@ -2575,7 +6128,9 @@ dependencies = [ name = "zagrosi-core" version = "0.1.0" dependencies = [ + "async-trait", "axum", + "chrono", "figment", "metrics-exporter-prometheus", "opentelemetry", @@ -2583,15 +6138,82 @@ dependencies = [ "opentelemetry_sdk", "proptest", "serde", + "serde_json", "serial_test", "static_assertions", "tempfile", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-util", "tracing", "tracing-opentelemetry", "tracing-subscriber", + "uuid", +] + +[[package]] +name = "zagrosi-identity" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "arc-swap", + "argon2", + "async-nats", + "async-trait", + "aws-lc-rs", + "axum", + "base64 0.22.1", + "chrono", + "cookie", + "criterion", + "dashmap", + "figment", + "flate2", + "fluent-templates", + "fred", + "futures", + "hex", + "hickory-resolver", + "http", + "http-body-util", + "idna", + "lettre", + "metrics", + "mime", + "moka", + "num_cpus", + "openidconnect", + "password-hash", + "proptest", + "psl", + "quick-xml 0.39.4", + "rand_core 0.6.4", + "reqwest", + "samael", + "secrecy", + "serde", + "serde_json", + "serial_test", + "sha1", + "sha2", + "sqlx", + "static_assertions", + "subtle", + "tempfile", + "testcontainers-modules", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tower", + "tracing", + "tracing-test", + "trybuild", + "url", + "uuid", + "validator", + "wiremock", + "zagrosi-core", + "zeroize", ] [[package]] @@ -2640,6 +6262,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" @@ -2658,6 +6294,7 @@ version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 740b04d..9b3db68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] resolver = "3" -members = ["crates/zagrosi-core", "apps/api-gateway"] +members = ["crates/zagrosi-core", "crates/zagrosi-identity", "apps/api-gateway"] +exclude = ["crates/zagrosi-identity/fuzz"] [workspace.package] edition = "2024" @@ -13,10 +14,14 @@ readme = "README.md" [workspace.dependencies] # Core -serde = { version = "1", features = ["derive"] } -serde_json = "1" -thiserror = "2" -anyhow = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +anyhow = "1" +base64 = "0.22" +chrono = { version = "0.4", default-features = false, features = ["std", "serde", "clock"] } +uuid = { version = "1", features = ["serde", "v7"] } +async-trait = "0.1" # Tracing + OpenTelemetry tracing = "0.1" @@ -39,24 +44,139 @@ tokio = { version = "1", features = ["full"] } tokio-util = "0.7" axum = "0.8" once_cell = "1" +# Stream + future combinators consumed by the session-event subscriber. +futures = "0.3" # Testing -proptest = "1" -tempfile = "3" -serial_test = "3" -static_assertions = "1.1" +proptest = "1" +tempfile = "3" +serial_test = "3" +static_assertions = "1.1" +testcontainers-modules = { version = "0.15", features = ["postgres", "valkey"] } + +# Cryptography (secrets shim + token-hashing chokepoint + constant-time compare) +aes-gcm = { version = "0.10", default-features = false, features = ["aes", "alloc", "getrandom", "zeroize"] } +zeroize = { version = "1", features = ["zeroize_derive"] } +secrecy = "0.10" +rand_core = { version = "0.6", features = ["getrandom"] } +sha2 = "0.10" +subtle = "2" # Reserved for later sections (pinned now to prevent version skew) -sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate", "uuid", "chrono", "json"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "macros", "migrate", "uuid", "chrono", "json", "ipnetwork"] } async-nats = "0.47" tantivy = "0.22" rmcp = "0.5" extism = "1" argon2 = "0.5" +password-hash = "0.5" jsonwebtoken = "10" criterion = "0.5" axum-prometheus = "0.10" +# OIDC client (Authorization Code + PKCE S256). Default features pull +# `reqwest` async + rustls-tls so this lines up with the workspace +# shared `reqwest` (rustls). `timing-resistant-secret-traits` enables +# constant-time `PartialEq` on `ClientSecret` / `Nonce` / verifier +# secret types via the crate's own sha256-based equality. +openidconnect = { version = "4", features = ["timing-resistant-secret-traits"] } + +# SAML 2.0 SP. samael's `xmlsec` feature pulls libxmlsec1 + libxml2 +# system C deps via bindgen. Activated only when the `saml` feature on +# `zagrosi-identity` is enabled; default builds never link the C +# stack. The git pin is the upstream 0.0.20 post-release fix for +# xmlsec 1.3.x bindings, where `xmlSecBufferGetSize` may bind as `u32` +# on Ubuntu runners. Later bumps must re-validate the XSW hardening +# through the `tests/saml_negative_corpus.rs` corpus before merging. +samael = { git = "https://github.com/njaremko/samael.git", rev = "f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", default-features = false, features = ["xmlsec"] } +# RSA-2048 / P-256 SP signing-key generation (metadata.xml). aws-lc-rs +# is the same crypto backend rustls + fred 10.x already select; the +# workspace pin keeps a single FIPS-validatable provider rather than +# letting `ring` slip in transitively. +aws-lc-rs = "1" +# DEFLATE compression for HTTP-Redirect binding `SAMLRequest=` query. +flate2 = "1" +# XML render for SP `EntityDescriptor` metadata document. Same parser +# crate samael uses internally; pinning at workspace level keeps a +# single XML implementation in the SAML signing path. +quick-xml = "0.39" + +# Password-auth dependencies. +# `sha1` use is restricted to the HIBP k-anonymity legacy-API path +# (crates/zagrosi-identity/src/password/breach.rs); all other hashing +# uses sha2/sha3 via the `sha2` workspace dep. +sha1 = "0.10" +hex = "0.4" +fluent-templates = { version = "0.13", default-features = false, features = ["walkdir"] } +# Email-outbox worker SMTP transport. `default-features = false` keeps +# the native-tls C stack out; `tokio1-rustls` selects the async rustls +# path and `aws-lc-rs` pins the rustls crypto backend to the same +# FIPS-validatable provider rustls + fred already select (NOT `ring`). +# Verified against lettre 0.11.21 docs: the TLS feature is +# `tokio1-rustls` (not `tokio1-rustls-tls`) and the backend feature is +# `aws-lc-rs`. +lettre = { version = "0.11", default-features = false, features = ["tokio1-rustls", "aws-lc-rs", "webpki-roots", "smtp-transport", "pool", "builder", "hostname"] } +validator = "0.20" +num_cpus = "1.16" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +url = { version = "2", features = ["serde"] } +http = "1" +mime = "0.3" + +# Password-auth dev-dependencies. +wiremock = "0.6" +tracing-test = "0.2" +trybuild = "1" + +# Async client for the Valkey-backed rate limiter + lockout. +# `enable-rustls` selects rustls TLS with the default crypto backend +# (aws-lc-rs in fred 10.x). `i-keys` enables INCR / GET / SET, `i-scripts` +# enables EVAL / EVALSHA for atomic sliding-window scripts, `i-server` +# exposes admin commands needed by integration tests, and `i-tracking` +# is required by the design notes for client-side caching wiring. +fred = { version = "10", default-features = false, features = ["enable-rustls", "i-keys", "i-scripts", "i-server", "i-tracking", "sha-1"] } + +# Session resolver dependencies. +# - `moka` ships an async-native, sized-and-TTL bounded cache used by +# the gateway-facing introspection path. +# - `dashmap` backs the auxiliary session-id reverse-lookup index so +# NATS-driven evictions land in O(1) without holding the moka cache +# lock. +# - `cookie` is the same crate axum uses for `Set-Cookie` builders; +# pinning it at the workspace level keeps cookie-attribute defaults +# consistent across crates. +moka = { version = "0.12", features = ["future"] } +dashmap = "6" +cookie = { version = "0.18", features = ["percent-encode", "secure"] } +# arc-swap backs the session cache's healthy <-> fail-closed TTL flip: +# the underlying moka cache TTL is constructor-bound, so the resolver +# replaces the cache atomically when the broker health probe transitions. +arc-swap = "1" + +# Multi-IdP routing dependencies (section-13). +# +# - `hickory-resolver` is the in-process DNSSEC-validating resolver +# used by the domain-ownership verification flow. The +# `dnssec-aws-lc-rs` feature wires the validating path through the +# workspace's chosen crypto backend (matches `aws-lc-rs` already +# pulled by rustls + fred 10.x) so we do not link a second +# provider transitively. `system-config` enables construction from +# `/etc/resolv.conf` for development; production code constructs +# from a fixed dual-resolver IP pair via `ResolverConfig::from_parts`. +# Default features are disabled so neither hickory's own TLS stack +# nor any non-tokio runtime sneaks in. +# - `psl` ships the Mozilla Public Suffix List snapshot consumed by +# the public-domain blocklist. Exact-pinned so a `cargo update` +# cannot silently shift the bundled snapshot — the quarterly +# review cadence documented in `CHANGELOG.md` is meaningless +# without an exact-pin. The contributor bumping the version is +# responsible for the matching changelog entry. +# - `idna` punycodes international domains so the routing-lookup key +# is normalised to ASCII before hitting the partial unique index. +hickory-resolver = { version = "0.25", default-features = false, features = ["tokio", "dnssec-aws-lc-rs", "system-config"] } +psl = "=2.1.208" +idna = "1" + [workspace.lints.rust] unsafe_code = "forbid" missing_docs = "warn" diff --git a/crates/zagrosi-core/Cargo.toml b/crates/zagrosi-core/Cargo.toml index 1aabb41..67684eb 100644 --- a/crates/zagrosi-core/Cargo.toml +++ b/crates/zagrosi-core/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Foundation library for the Zagrosi platform: error types, configuration loader, and observability skeleton." keywords = ["zagrosi", "observability", "tracing", "config"] categories = ["development-tools"] +publish = false edition.workspace = true rust-version.workspace = true license.workspace = true @@ -17,6 +18,7 @@ workspace = true [dependencies] serde = { workspace = true } +serde_json = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } @@ -29,6 +31,9 @@ figment = { workspace = true } tokio = { workspace = true } tokio-util = { workspace = true } axum = { workspace = true } +chrono = { workspace = true } +uuid = { workspace = true } +async-trait = { workspace = true } [dev-dependencies] proptest = { workspace = true } diff --git a/crates/zagrosi-core/src/audit.rs b/crates/zagrosi-core/src/audit.rs new file mode 100644 index 0000000..7db0d8a --- /dev/null +++ b/crates/zagrosi-core/src/audit.rs @@ -0,0 +1,983 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Audit port + versioned event envelope. +//! +//! Identity emits events via [`Auditor`]; the `PostgresAuditor` impl +//! lives in the tenant-isolation layer's `zagrosi-audit` crate. The +//! default impl shipped here is [`NoopAuditor`] so wiring works before the +//! audit crate lands. +//! +//! [`AuditEvent`] is a versioned envelope. v0.1 only ships [`AuditEventV1`]; +//! future fields land via an additive `AuditEventV2` variant rather than by +//! editing v1, preserving forward compatibility for downstream audit storage. +//! +//! ## Wire-shape lock +//! +//! The envelope discriminator is the *string* `"schema_version": "1"`, not +//! a numeric `1`. Downstream consumers (Postgres JSONB readers, log shippers) +//! rely on the discriminator type being stable; the `audit_event_envelope_*` +//! tests below regression-guard against the type drifting to an integer. + +use std::fmt; +use std::net::IpAddr; + +use async_trait::async_trait; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Sink for identity-emitted audit events. +/// +/// Implementations must be cheap to clone (or `Arc`-wrapped) so handlers +/// can call them on the hot request path. Identity rate-limits noisy +/// events (e.g. failed sign-in aggregation) before invoking the sink. +#[async_trait] +pub trait Auditor: Send + Sync + 'static { + /// Record an event. Implementations are best-effort; failure must not + /// propagate to the caller. + async fn record(&self, event: AuditEvent); +} + +/// Versioned audit-event envelope. +/// +/// [`AuditEventV1`] is the v0.1 wire shape. Future `AuditEventV2` lands as +/// a new variant; consumers that only know v1 should treat unknown +/// variants as opaque (the tenant-isolation layer owns the deserialisation strategy). +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "schema_version")] +#[non_exhaustive] +pub enum AuditEvent { + /// v1 event payload. + #[serde(rename = "1")] + V1(AuditEventV1), +} + +/// Maximum drift allowed between caller-supplied `AuditEventV1::occurred_at` +/// and host wall-clock. +/// +/// Five seconds matches the industry-standard tolerance for distributed +/// event emission; a wider window enables forensic-timeline forgery. +pub const AUDIT_OCCURRED_AT_TOLERANCE_SECS: i64 = 5; + +/// v1 audit-event payload. +/// +/// Fields are private to enforce construction invariants via +/// [`AuditEventV1::new`] / [`AuditEventV1::new_at`]. Read access goes through +/// accessor methods. Serde derive remains in place for cross-process replay +/// (e.g. JSONB column round-trip in the upcoming `PostgresAuditor`). +#[derive(Clone, Serialize, Deserialize)] +pub struct AuditEventV1 { + event_id: Uuid, + event_kind: AuditEventKind, + actor: AuditActor, + resource: AuditResource, + correlation_id: Uuid, + occurred_at: DateTime, + org_id: Uuid, + payload: AuditPayload, +} + +impl AuditEventV1 { + /// Construct a fresh v1 audit event, clamping `occurred_at` to + /// `Utc::now()`. This is the recommended constructor for production + /// code: it removes any caller-supplied wall-clock value entirely. + #[must_use] + pub fn new( + event_kind: AuditEventKind, + actor: AuditActor, + resource: AuditResource, + correlation_id: Uuid, + org_id: Uuid, + payload: AuditPayload, + ) -> Self { + Self { + event_id: Uuid::now_v7(), + event_kind, + actor, + resource, + correlation_id, + occurred_at: Utc::now(), + org_id, + payload, + } + } + + /// Construct a v1 audit event with a caller-supplied `occurred_at`, + /// rejecting timestamps that drift more than + /// [`AUDIT_OCCURRED_AT_TOLERANCE_SECS`] seconds from `Utc::now()`. Used + /// by tests + by integration paths that bridge an upstream clock + /// (e.g. an external `IdP` attestation timestamp the gateway did not + /// synthesise). + /// + /// # Errors + /// + /// Returns [`AuditEventError::OccurredAtSkew`] when the caller's + /// `occurred_at` is more than `AUDIT_OCCURRED_AT_TOLERANCE_SECS` + /// seconds away from `Utc::now()` in either direction. + #[allow(clippy::too_many_arguments)] + pub fn new_at( + event_id: Uuid, + event_kind: AuditEventKind, + actor: AuditActor, + resource: AuditResource, + correlation_id: Uuid, + occurred_at: DateTime, + org_id: Uuid, + payload: AuditPayload, + ) -> Result { + let now = Utc::now(); + let diff = (now - occurred_at).num_seconds().saturating_abs(); + if diff > AUDIT_OCCURRED_AT_TOLERANCE_SECS { + return Err(AuditEventError::OccurredAtSkew { + drift_secs: diff, + tolerance_secs: AUDIT_OCCURRED_AT_TOLERANCE_SECS, + }); + } + Ok(Self { + event_id, + event_kind, + actor, + resource, + correlation_id, + occurred_at, + org_id, + payload, + }) + } + + /// Construct an event without clock-skew validation. Restricted to + /// `#[cfg(test)]` so production code cannot instantiate audit events + /// with arbitrary timestamps. + #[cfg(test)] + #[must_use] + #[allow(clippy::too_many_arguments, clippy::missing_const_for_fn)] + pub(crate) fn new_for_testing( + event_id: Uuid, + event_kind: AuditEventKind, + actor: AuditActor, + resource: AuditResource, + correlation_id: Uuid, + occurred_at: DateTime, + org_id: Uuid, + payload: AuditPayload, + ) -> Self { + Self { + event_id, + event_kind, + actor, + resource, + correlation_id, + occurred_at, + org_id, + payload, + } + } + + /// Stable event identifier (UUID v7 recommended). + #[must_use] + pub const fn event_id(&self) -> Uuid { + self.event_id + } + + /// Discriminator across the event taxonomy. + #[must_use] + pub const fn event_kind(&self) -> AuditEventKind { + self.event_kind + } + + /// Who initiated the action. + #[must_use] + pub const fn actor(&self) -> &AuditActor { + &self.actor + } + + /// What the action targeted. + #[must_use] + pub const fn resource(&self) -> &AuditResource { + &self.resource + } + + /// Per-request correlation ID. + #[must_use] + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } + + /// Wall-clock time the event occurred. + #[must_use] + pub const fn occurred_at(&self) -> DateTime { + self.occurred_at + } + + /// Tenant scope of the event. + #[must_use] + pub const fn org_id(&self) -> Uuid { + self.org_id + } + + /// Free-form event-specific payload (PII-bearing — opaque on `Debug`). + #[must_use] + pub const fn payload(&self) -> &AuditPayload { + &self.payload + } +} + +impl fmt::Debug for AuditEventV1 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("AuditEventV1") + .field("event_id", &self.event_id) + .field("event_kind", &self.event_kind) + .field("actor", &self.actor) + .field("resource", &self.resource) + .field("correlation_id", &self.correlation_id) + .field("occurred_at", &self.occurred_at) + .field("org_id", &self.org_id) + .field("payload", &self.payload) + .finish() + } +} + +/// Errors raised by [`AuditEventV1::new_at`] and other validating +/// constructors when the caller-supplied data violates an invariant. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AuditEventError { + /// Caller-supplied `occurred_at` drifts too far from `Utc::now()`. + /// `drift_secs` is the absolute drift in seconds. + #[error("occurred_at drifts {drift_secs}s from Utc::now() (tolerance: {tolerance_secs}s)")] + OccurredAtSkew { + /// Absolute drift in seconds. + drift_secs: i64, + /// Configured tolerance in seconds. + tolerance_secs: i64, + }, +} + +/// Audit-event payload newtype. +/// +/// The inner `serde_json::Value` is intentionally kept opaque on the +/// outside: the [`fmt::Debug`] impl prints only the size in bytes so a +/// careless `tracing::debug!(?event)` never dumps emails / tokens / IPs / +/// password-reset URLs that producers may encode into the payload. Wire +/// serde is unchanged; consumers that legitimately need the inner value +/// call [`AuditPayload::as_value`]. +#[derive(Clone, Serialize, Deserialize)] +#[serde(transparent)] +pub struct AuditPayload(serde_json::Value); + +impl AuditPayload { + /// Wrap a `serde_json::Value` payload. + #[must_use] + pub const fn new(value: serde_json::Value) -> Self { + Self(value) + } + + /// Borrow the inner JSON value. Callers consuming the inner value are + /// the trust boundary for whether its contents land in plaintext logs. + #[must_use] + pub const fn as_value(&self) -> &serde_json::Value { + &self.0 + } + + /// Approximate byte-size of the serialised payload, used by the + /// redacting `Debug` impl. Returns `0` for payloads that fail to + /// serialise (which would be exceptional but must not panic). + #[must_use] + pub fn approx_byte_size(&self) -> usize { + serde_json::to_vec(&self.0).map(|v| v.len()).unwrap_or(0) + } +} + +impl fmt::Debug for AuditPayload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "<{} B redacted>", self.approx_byte_size()) + } +} + +impl From for AuditPayload { + fn from(value: serde_json::Value) -> Self { + Self(value) + } +} + +/// Logical service name validator. +/// +/// Wraps an ASCII slug `^[A-Za-z0-9](?:[A-Za-z0-9_-]{0,62}[A-Za-z0-9])?$` +/// so producers cannot smuggle ANSI escapes / log-injection / unbounded +/// strings into Postgres / Prometheus labels / log shippers via +/// [`AuditActor::Service`]. Cardinality is bounded by the validator +/// (max 64 chars), which protects label cardinality at the metrics layer. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(try_from = "String", into = "String")] +pub struct ServiceName(String); + +/// Maximum length of a [`ServiceName`] in bytes (= chars, ASCII only). +pub const SERVICE_NAME_MAX_LEN: usize = 64; + +impl ServiceName { + /// Parse a service name, validating the slug contract. + /// + /// # Errors + /// + /// Returns [`ServiceNameError`] when the input violates length, charset, + /// or boundary rules. + pub fn parse(input: impl Into) -> Result { + let raw = input.into(); + if raw.is_empty() { + return Err(ServiceNameError::Empty); + } + if raw.len() > SERVICE_NAME_MAX_LEN { + return Err(ServiceNameError::TooLong { + len: raw.len(), + max: SERVICE_NAME_MAX_LEN, + }); + } + let bytes = raw.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if !first.is_ascii_alphanumeric() || !last.is_ascii_alphanumeric() { + return Err(ServiceNameError::InvalidBoundary); + } + for &b in bytes { + if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'-') { + return Err(ServiceNameError::InvalidChar(char::from(b))); + } + } + Ok(Self(raw)) + } + + /// Borrow the validated slug. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ServiceName { + type Error = ServiceNameError; + + fn try_from(value: String) -> Result { + Self::parse(value) + } +} + +impl From for String { + fn from(value: ServiceName) -> Self { + value.0 + } +} + +impl fmt::Display for ServiceName { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +/// [`ServiceName`] validation failures. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +#[non_exhaustive] +pub enum ServiceNameError { + /// Empty input. + #[error("service name must not be empty")] + Empty, + /// Too long. + #[error("service name length {len} exceeds maximum {max}")] + TooLong { + /// Actual length in bytes. + len: usize, + /// Configured maximum. + max: usize, + }, + /// First or last character was not alphanumeric. + #[error("service name must start and end with an alphanumeric character")] + InvalidBoundary, + /// A character outside the slug alphabet `[A-Za-z0-9_-]` was found. + #[error("service name contains invalid character: `{0}`")] + InvalidChar(char), +} + +/// Discriminator across identity-emitted audit events. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AuditEventKind { + /// User signed up successfully. + SignupCreated, + /// Sign-up attempted with an email that already exists. Anti-enumeration + /// — payload carries IP only, never the email-existence answer. + SignupEmailCollisionAttempted, + /// Email verification confirmed. + EmailVerified, + /// Sign-in succeeded. + SigninSuccess, + /// Sign-in failed (rate-limited per minute window). + SigninFailed, + /// Session revoked (explicit / cascade / SCIM-deactivate). + SessionRevoked, + /// User changed their password. + PasswordChanged, + /// Password reset requested (only emitted for known emails). + PasswordResetRequested, + /// `IdP` configuration created. + IdpCreated, + /// `IdP` configuration updated. + IdpUpdated, + /// `IdP` configuration deleted. + IdpDeleted, + /// `IdP` domain claim created (POST /domains). The DNS challenge + /// is issued; verification has not yet happened. Distinct from + /// [`AuditEventKind::IdpDomainVerified`] so the audit timeline + /// records claim → verify (or claim → expire) transitions. + IdpDomainCreated, + /// `IdP` domain ownership verified via DNS TXT. + IdpDomainVerified, + /// `IdP` domain verification failed (DNSSEC / NX / mismatch). + IdpDomainFailed, + /// `IdP` domain claim soft-deleted. Recorded for verified rows so + /// the audit timeline shows when an org gives up a domain claim. + IdpDomainDeleted, + /// SCIM created a user. + ScimUserCreated, + /// SCIM updated a user. + ScimUserUpdated, + /// SCIM flipped a user `active=false`. + ScimUserDeactivated, + /// SCIM deleted a user. + ScimUserDeleted, + /// SCIM created a group. + ScimGroupCreated, + /// SCIM updated a group. + ScimGroupUpdated, + /// SCIM deleted a group. + ScimGroupDeleted, + /// SCIM PATCH/PUT received without `If-Match`. + ScimUnconditionalWrite, + /// OIDC callback used a `oidc_pending_auth` row that was already used. + OidcCallbackReplay, + /// OIDC callback's CSRF cookie did not match the pending row. + OidcStateMismatch, + /// OIDC refresh-token chain replay detected. + OidcRefreshReplay, + /// SAML ACS received a previously-seen `AssertionID`. + SamlAcsReplay, + /// SAML ACS rejected an XSW-style payload. + SamlXswRejected, + /// SAML ACS rejected an invalid signature. + SamlSignatureInvalid, + /// User switched their active org on the same session. + OrgSwitched, + /// Account locked out after rate-limit / lockout breach. + AccountLocked, + /// Admin unlocked a previously-locked account. + AccountUnlocked, + /// Worker / service token issued. + ServiceTokenCreated, + /// Worker / service token revoked. + ServiceTokenRevoked, + /// Personal access token issued by a user (`pat_*`). + ApiTokenCreated, + /// Personal access token revoked (explicit DELETE or cascade). + ApiTokenRevoked, + /// Token-replay heuristic fired (refresh chain or session reuse). + SuspectedTokenReplay, + /// GDPR hard-purge completed for a user. + GdprPurgeCompleted, +} + +/// Who initiated the audited action. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[non_exhaustive] +pub enum AuditActor { + /// Authenticated end user. + User { + /// Subject identifier. + user_id: Uuid, + /// Source IP, when known. + ip: Option, + }, + /// Worker / service-token bearer. + Service { + /// Logical service name (validated slug; see [`ServiceName`]). + service_name: ServiceName, + }, + /// Internal system (cron, migration, startup). + System, + /// Unauthenticated request (anti-enumeration audit only). + Anonymous { + /// Source IP, when known. + ip: Option, + }, +} + +/// What the audited action targeted. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[non_exhaustive] +pub enum AuditResource { + /// User row. + User { + /// User identifier. + user_id: Uuid, + }, + /// Org row. + Org { + /// Org identifier. + org_id: Uuid, + }, + /// Session row. + Session { + /// Session identifier. + session_id: Uuid, + }, + /// API token row. + ApiToken { + /// API token identifier. + token_id: Uuid, + }, + /// SCIM bearer-token row. + ScimToken { + /// SCIM token identifier. + token_id: Uuid, + }, + /// Worker / service-token row. + ServiceToken { + /// Service token identifier. + token_id: Uuid, + }, + /// `IdP` configuration row. + Idp { + /// `IdP` identifier. + idp_id: Uuid, + }, + /// `IdP`-domain claim row. + IdpDomain { + /// Domain row identifier. + domain_id: Uuid, + }, + /// Outbound email row. + Email { + /// Email outbox identifier. + email_id: Uuid, + }, + /// Resource not applicable (e.g. system events). + None, +} + +/// Default [`Auditor`] impl — drops events on the floor. +/// +/// The gateway / app composition root replaces this with the +/// `PostgresAuditor` once the tenant-isolation layer's audit crate lands. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAuditor; + +#[async_trait] +impl Auditor for NoopAuditor { + async fn record(&self, _event: AuditEvent) {} +} + +const _: ChronoDuration = ChronoDuration::seconds(AUDIT_OCCURRED_AT_TOLERANCE_SECS); + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + + assert_obj_safe!(Auditor); + assert_impl_all!(AuditEventV1: Send, Sync, Clone, serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(AuditActor: Send, Sync, Clone, serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(AuditResource: Send, Sync, Clone, serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(AuditPayload: Send, Sync, Clone, serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(ServiceName: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(AuditEventError: Send, Sync, std::error::Error); + assert_impl_all!(ServiceNameError: Send, Sync, std::error::Error); + const _: fn() = || { + fn require_static() {} + require_static::(); + require_static::(); + require_static::(); + }; + + fn distinguishable_uuid(byte: u8) -> Uuid { + Uuid::from_bytes([byte; 16]) + } + + fn fixture_event_v1() -> AuditEventV1 { + AuditEventV1::new_for_testing( + distinguishable_uuid(1), + AuditEventKind::SigninSuccess, + AuditActor::User { + user_id: distinguishable_uuid(2), + ip: Some( + "127.0.0.1" + .parse::() + .unwrap_or_else(|e| panic!("ip parse: {e}")), + ), + }, + AuditResource::Session { + session_id: distinguishable_uuid(3), + }, + distinguishable_uuid(4), + DateTime::::from_timestamp(0, 0).unwrap_or_else(|| panic!("epoch construct")), + distinguishable_uuid(5), + AuditPayload::new(serde_json::json!({})), + ) + } + + #[test] + fn audit_event_v1_round_trips_json() { + let original = fixture_event_v1(); + let json = serde_json::to_string(&original).unwrap_or_else(|e| panic!("serialise: {e}")); + let parsed: AuditEventV1 = + serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialise: {e}")); + assert_eq!(parsed.event_kind(), AuditEventKind::SigninSuccess); + } + + #[test] + fn audit_event_envelope_carries_string_schema_version_one() { + let envelope = AuditEvent::V1(fixture_event_v1()); + let v = + serde_json::to_value(&envelope).unwrap_or_else(|e| panic!("serialise envelope: {e}")); + // Wire-shape lock: discriminator MUST be a string `"1"`, never a + // number `1`. Downstream JSONB readers depend on this. + assert_eq!(v["schema_version"], serde_json::json!("1")); + // Internally tagged: payload fields are flat on the envelope, no + // nested `V1` wrapper. + let obj = v.as_object().unwrap_or_else(|| panic!("envelope object")); + assert!( + !obj.contains_key("V1"), + "AuditEvent must serialise as internally tagged, not externally tagged" + ); + assert_eq!(obj["event_kind"], serde_json::json!("signin_success")); + } + + #[test] + fn audit_event_envelope_rejects_numeric_schema_version() { + // Wire-shape lock regression: a numeric `1` discriminator must NOT + // deserialise as v1. Future envelope versions can bump the string + // (`"2"`, `"3"`) but the type itself must stay String forever. + let payload = serde_json::json!({ + "schema_version": 1, + "event_id": "00000000-0000-0000-0000-000000000001", + "event_kind": "signin_success", + "actor": { "kind": "system" }, + "resource": { "kind": "none" }, + "correlation_id": "00000000-0000-0000-0000-000000000004", + "occurred_at": "1970-01-01T00:00:00Z", + "org_id": "00000000-0000-0000-0000-000000000005", + "payload": {} + }); + let result: Result = serde_json::from_value(payload); + assert!( + result.is_err(), + "numeric schema_version must fail to deserialise" + ); + } + + #[test] + fn audit_event_kind_round_trips_every_variant() { + let variants = [ + AuditEventKind::SignupCreated, + AuditEventKind::SignupEmailCollisionAttempted, + AuditEventKind::EmailVerified, + AuditEventKind::SigninSuccess, + AuditEventKind::SigninFailed, + AuditEventKind::SessionRevoked, + AuditEventKind::PasswordChanged, + AuditEventKind::PasswordResetRequested, + AuditEventKind::IdpCreated, + AuditEventKind::IdpUpdated, + AuditEventKind::IdpDeleted, + AuditEventKind::IdpDomainCreated, + AuditEventKind::IdpDomainVerified, + AuditEventKind::IdpDomainFailed, + AuditEventKind::IdpDomainDeleted, + AuditEventKind::ScimUserCreated, + AuditEventKind::ScimUserUpdated, + AuditEventKind::ScimUserDeactivated, + AuditEventKind::ScimUserDeleted, + AuditEventKind::ScimGroupCreated, + AuditEventKind::ScimGroupUpdated, + AuditEventKind::ScimGroupDeleted, + AuditEventKind::ScimUnconditionalWrite, + AuditEventKind::OidcCallbackReplay, + AuditEventKind::OidcStateMismatch, + AuditEventKind::OidcRefreshReplay, + AuditEventKind::SamlAcsReplay, + AuditEventKind::SamlXswRejected, + AuditEventKind::SamlSignatureInvalid, + AuditEventKind::OrgSwitched, + AuditEventKind::AccountLocked, + AuditEventKind::AccountUnlocked, + AuditEventKind::ServiceTokenCreated, + AuditEventKind::ServiceTokenRevoked, + AuditEventKind::ApiTokenCreated, + AuditEventKind::ApiTokenRevoked, + AuditEventKind::SuspectedTokenReplay, + AuditEventKind::GdprPurgeCompleted, + ]; + for kind in variants { + // Drive the exhaustiveness check via match — ensures the + // variant array stays in lockstep with the enum. + match kind { + AuditEventKind::SignupCreated + | AuditEventKind::SignupEmailCollisionAttempted + | AuditEventKind::EmailVerified + | AuditEventKind::SigninSuccess + | AuditEventKind::SigninFailed + | AuditEventKind::SessionRevoked + | AuditEventKind::PasswordChanged + | AuditEventKind::PasswordResetRequested + | AuditEventKind::IdpCreated + | AuditEventKind::IdpUpdated + | AuditEventKind::IdpDeleted + | AuditEventKind::IdpDomainCreated + | AuditEventKind::IdpDomainVerified + | AuditEventKind::IdpDomainFailed + | AuditEventKind::IdpDomainDeleted + | AuditEventKind::ScimUserCreated + | AuditEventKind::ScimUserUpdated + | AuditEventKind::ScimUserDeactivated + | AuditEventKind::ScimUserDeleted + | AuditEventKind::ScimGroupCreated + | AuditEventKind::ScimGroupUpdated + | AuditEventKind::ScimGroupDeleted + | AuditEventKind::ScimUnconditionalWrite + | AuditEventKind::OidcCallbackReplay + | AuditEventKind::OidcStateMismatch + | AuditEventKind::OidcRefreshReplay + | AuditEventKind::SamlAcsReplay + | AuditEventKind::SamlXswRejected + | AuditEventKind::SamlSignatureInvalid + | AuditEventKind::OrgSwitched + | AuditEventKind::AccountLocked + | AuditEventKind::AccountUnlocked + | AuditEventKind::ServiceTokenCreated + | AuditEventKind::ServiceTokenRevoked + | AuditEventKind::ApiTokenCreated + | AuditEventKind::ApiTokenRevoked + | AuditEventKind::SuspectedTokenReplay + | AuditEventKind::GdprPurgeCompleted => {} + } + let json = serde_json::to_string(&kind).unwrap_or_else(|e| panic!("serialise: {e}")); + let parsed: AuditEventKind = + serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialise: {e}")); + assert_eq!(parsed, kind); + } + } + + #[test] + fn audit_event_v1_new_clamps_to_now() { + let payload = AuditPayload::new(serde_json::json!({"k": "v"})); + let actor = AuditActor::System; + let event = AuditEventV1::new( + AuditEventKind::SigninSuccess, + actor, + AuditResource::None, + distinguishable_uuid(4), + distinguishable_uuid(5), + payload, + ); + let drift = (Utc::now() - event.occurred_at()).num_seconds().abs(); + assert!( + drift <= 1, + "occurred_at must clamp to now() (drift={drift}s)" + ); + } + + #[test] + fn audit_event_v1_new_at_rejects_far_future() { + let future = Utc::now() + ChronoDuration::seconds(60); + let result = AuditEventV1::new_at( + distinguishable_uuid(1), + AuditEventKind::SigninSuccess, + AuditActor::System, + AuditResource::None, + distinguishable_uuid(4), + future, + distinguishable_uuid(5), + AuditPayload::new(serde_json::json!({})), + ); + let err = result.expect_err("60s drift must reject"); + assert!(matches!(err, AuditEventError::OccurredAtSkew { .. })); + } + + #[test] + fn audit_event_v1_new_at_rejects_far_past() { + let past = Utc::now() - ChronoDuration::seconds(60); + let result = AuditEventV1::new_at( + distinguishable_uuid(1), + AuditEventKind::SigninSuccess, + AuditActor::System, + AuditResource::None, + distinguishable_uuid(4), + past, + distinguishable_uuid(5), + AuditPayload::new(serde_json::json!({})), + ); + let err = result.expect_err("60s drift must reject"); + assert!(matches!(err, AuditEventError::OccurredAtSkew { .. })); + } + + #[test] + fn audit_event_v1_new_at_accepts_within_tolerance() { + let close = Utc::now() - ChronoDuration::seconds(2); + AuditEventV1::new_at( + distinguishable_uuid(1), + AuditEventKind::SigninSuccess, + AuditActor::System, + AuditResource::None, + distinguishable_uuid(4), + close, + distinguishable_uuid(5), + AuditPayload::new(serde_json::json!({})), + ) + .unwrap_or_else(|e| panic!("2s drift must pass: {e}")); + } + + #[test] + fn audit_payload_debug_redacts_contents() { + let payload = AuditPayload::new(serde_json::json!({ + "email": "alice@example.com", + "password_reset_token": "rst_secret_value" + })); + let rendered = format!("{payload:?}"); + assert!(!rendered.contains("alice")); + assert!(!rendered.contains("rst_secret_value")); + assert!(rendered.contains("redacted")); + assert!(rendered.contains(" B ")); + } + + #[test] + fn audit_event_v1_debug_does_not_leak_payload() { + let payload = AuditPayload::new(serde_json::json!({ + "email": "alice@example.com", + "ip": "10.0.0.5" + })); + let event = AuditEventV1::new( + AuditEventKind::SigninFailed, + AuditActor::Anonymous { + ip: Some( + "10.0.0.5" + .parse::() + .unwrap_or_else(|e| panic!("ip parse: {e}")), + ), + }, + AuditResource::None, + distinguishable_uuid(4), + distinguishable_uuid(5), + payload, + ); + let dbg = format!("{event:?}"); + // Top-level Debug includes the IP via AuditActor (intentional — + // operators do see audit-actor IPs). The payload, however, must + // be opaque. + assert!(!dbg.contains("alice")); + assert!(dbg.contains("redacted")); + } + + #[test] + fn service_name_accepts_valid_slugs() { + for input in [ + "email-worker", + "scim_worker", + "outbox-pump-1", + "Cron", + "x", + "z9", + ] { + ServiceName::parse(input).unwrap_or_else(|e| panic!("`{input}` should validate: {e}")); + } + } + + #[test] + fn service_name_rejects_empty() { + assert_eq!(ServiceName::parse("").unwrap_err(), ServiceNameError::Empty); + } + + #[test] + fn service_name_rejects_too_long() { + let long = "a".repeat(SERVICE_NAME_MAX_LEN + 1); + assert!(matches!( + ServiceName::parse(long).unwrap_err(), + ServiceNameError::TooLong { .. } + )); + } + + #[test] + fn service_name_rejects_leading_dash() { + assert_eq!( + ServiceName::parse("-worker").unwrap_err(), + ServiceNameError::InvalidBoundary + ); + } + + #[test] + fn service_name_rejects_trailing_underscore() { + assert_eq!( + ServiceName::parse("worker_").unwrap_err(), + ServiceNameError::InvalidBoundary + ); + } + + #[test] + fn service_name_rejects_log_injection_attempts() { + for input in ["alice\nadmin", "evil\x1b[31m", "a b", "a/b", ".."] { + assert!( + matches!( + ServiceName::parse(input).unwrap_err(), + ServiceNameError::InvalidChar(_) | ServiceNameError::InvalidBoundary + ), + "`{input}` should be rejected" + ); + } + } + + #[test] + fn service_name_round_trips_through_serde() { + let name = ServiceName::parse("email-worker").unwrap_or_else(|e| panic!("seed parse: {e}")); + let json = serde_json::to_string(&name).unwrap_or_else(|e| panic!("serialise: {e}")); + assert_eq!(json, "\"email-worker\""); + let parsed: ServiceName = + serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialise: {e}")); + assert_eq!(parsed, name); + } + + #[test] + fn service_name_serde_rejects_invalid_input() { + let bad: Result = serde_json::from_str("\"-bad\""); + assert!(bad.is_err(), "leading dash should fail at deserialise"); + } + + #[test] + fn audit_actor_service_uses_service_name() { + let name = ServiceName::parse("email-worker").unwrap_or_else(|e| panic!("parse: {e}")); + let actor = AuditActor::Service { service_name: name }; + let json = serde_json::to_string(&actor).unwrap_or_else(|e| panic!("serialise: {e}")); + let parsed: AuditActor = + serde_json::from_str(&json).unwrap_or_else(|e| panic!("deserialise: {e}")); + match parsed { + AuditActor::Service { service_name } => { + assert_eq!(service_name.as_str(), "email-worker"); + } + other => panic!("expected Service, got {other:?}"), + } + } + + #[tokio::test] + async fn noop_auditor_drops_events_silently() { + let auditor = NoopAuditor; + auditor.record(AuditEvent::V1(fixture_event_v1())).await; + } + + #[tokio::test] + async fn noop_auditor_default_drops_events_silently() { + // Type-checking exercise: confirm `NoopAuditor` derives `Default` + // so downstream `Default::default::()` works in + // generic call sites without naming `NoopAuditor` directly. + fn assert_default() {} + assert_default::(); + let auditor = NoopAuditor; + auditor.record(AuditEvent::V1(fixture_event_v1())).await; + } +} diff --git a/crates/zagrosi-core/src/auth_context.rs b/crates/zagrosi-core/src/auth_context.rs new file mode 100644 index 0000000..38648df --- /dev/null +++ b/crates/zagrosi-core/src/auth_context.rs @@ -0,0 +1,895 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Gateway-to-domain auth contract. +//! +//! Identity attaches [`AuthContext`] as an axum extension after token +//! resolution; downstream consumers (RBAC, handlers) read it. RBAC +//! roles and permissions stay out of this struct: those derive from +//! membership state and live in the tenant-isolation layer's +//! `zagrosi-rbac` crate. Bearer-token scopes (PAT / SCIM / service +//! token) ARE carried because they describe the credential's grant, +//! a property of the token itself rather than the user's role. +//! +//! # Construction invariants +//! +//! [`AuthContext`] and [`IdentityContext`] hold load-bearing identity data; +//! the only legitimate construction path for fresh values is the `new` +//! constructor on each, which enforces: +//! +//! - non-nil `subject_id`, `session_id`, `org_id`, `correlation_id`, +//! - at least one entry in `amr` (RFC 8176 requires every authenticated request +//! must carry the methods that produced it), +//! - `issued_at < expires_at` (no zombie / future-dated sessions). +//! +//! Deserialise paths exist for cross-process audit replay; production +//! gateway code MUST go through the constructor so invariants are checked +//! at the trust boundary. Field access is read-only via accessors. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Caller identity + active org + token metadata after authentication. +/// +/// Attached to a request by the api-gateway middleware via +/// [`crate::SessionIntrospector::resolve`]. Carries bearer-token +/// scopes when the credential is a PAT / SCIM / service token, but +/// not RBAC roles or permissions: the tenant-isolation layer expands +/// those on top of the membership graph rather than encoding them +/// here. +/// +/// Fields are private; construct via [`AuthContext::new`] and read via +/// the accessor methods. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthContext { + subject_id: Uuid, + session_id: Uuid, + org_id: Uuid, + auth_method: AuthMethod, + token_class: TokenClass, + amr: Vec, + acr: Option, + expires_at: DateTime, + issued_at: DateTime, + correlation_id: Uuid, + /// Authorisation scopes carried by bearer-token auth methods + /// (PAT, SCIM, service-token). Empty for session-based auth, + /// which derives capabilities from the role-based access layer + /// instead. Defaults to empty; populate via + /// [`AuthContext::with_scopes`] at the bearer-token resolve site. + /// + /// Serialisation skips this field when empty so session payloads + /// do not gain a `scopes: []` member, keeping the on-the-wire + /// envelope identical to the pre-bearer-scope shape. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + scopes: Vec, +} + +impl AuthContext { + /// Construct a fresh [`AuthContext`], enforcing every invariant the + /// gateway-to-domain contract requires. + /// + /// # Errors + /// + /// Returns an [`AuthContextError`] if any invariant is violated: + /// nil `subject_id` / `session_id` / `org_id` / `correlation_id`, + /// empty `amr`, or `issued_at >= expires_at`. + #[allow(clippy::too_many_arguments)] + pub fn new( + subject_id: Uuid, + session_id: Uuid, + org_id: Uuid, + auth_method: AuthMethod, + token_class: TokenClass, + amr: Vec, + acr: Option, + issued_at: DateTime, + expires_at: DateTime, + correlation_id: Uuid, + ) -> Result { + if subject_id.is_nil() { + return Err(AuthContextError::NilUuid("subject_id")); + } + if session_id.is_nil() { + return Err(AuthContextError::NilUuid("session_id")); + } + if org_id.is_nil() { + return Err(AuthContextError::NilUuid("org_id")); + } + if correlation_id.is_nil() { + return Err(AuthContextError::NilUuid("correlation_id")); + } + if amr.is_empty() { + return Err(AuthContextError::EmptyAmr); + } + if issued_at >= expires_at { + return Err(AuthContextError::InvalidTimeWindow); + } + Ok(Self { + subject_id, + session_id, + org_id, + auth_method, + token_class, + amr, + acr, + expires_at, + issued_at, + correlation_id, + scopes: Vec::new(), + }) + } + + /// Attach the bearer-token authorisation scopes to this context. + /// + /// Used by the personal-access-token / SCIM / service-token + /// resolvers to thread the persisted scope list onto the + /// resolved [`AuthContext`]. Session-based auth leaves scopes + /// empty (capabilities derive from the RBAC layer instead). + /// + /// Consumes `self` so the call site cannot accidentally drop + /// the scope list mid-pipeline. + #[must_use] + pub fn with_scopes(mut self, scopes: Vec) -> Self { + self.scopes = scopes; + self + } + + /// Authorisation scopes carried by this auth context (PAT / + /// SCIM / service-token only). Returns an empty slice for + /// session-based auth. + #[must_use] + pub fn scopes(&self) -> &[String] { + &self.scopes + } + + /// Returns `true` when `scope` is present in this context's + /// scope list. Always returns `false` for session-based auth + /// (scopes only apply to bearer tokens). + #[must_use] + pub fn has_scope(&self, scope: &str) -> bool { + self.scopes.iter().any(|s| s == scope) + } + + /// Subject (user) identifier. + #[must_use] + pub const fn subject_id(&self) -> Uuid { + self.subject_id + } + + /// Session identifier the bearer token resolves to. + #[must_use] + pub const fn session_id(&self) -> Uuid { + self.session_id + } + + /// Active organisation scope for this request. + #[must_use] + pub const fn org_id(&self) -> Uuid { + self.org_id + } + + /// How the caller authenticated. + #[must_use] + pub const fn auth_method(&self) -> AuthMethod { + self.auth_method + } + + /// Class of the bearer token used for this request. + #[must_use] + pub const fn token_class(&self) -> TokenClass { + self.token_class + } + + /// RFC 8176 Authentication Methods References (e.g. `["pwd"]`). + #[must_use] + pub fn amr(&self) -> &[String] { + &self.amr + } + + /// RFC 8176 Authentication Context Class Reference, when known. + #[must_use] + pub fn acr(&self) -> Option<&str> { + self.acr.as_deref() + } + + /// Wall-clock expiry of the underlying session/token. + #[must_use] + pub const fn expires_at(&self) -> DateTime { + self.expires_at + } + + /// Wall-clock issuance time of the underlying session/token. + #[must_use] + pub const fn issued_at(&self) -> DateTime { + self.issued_at + } + + /// Per-request correlation ID (propagated via the tracing layer). + #[must_use] + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } +} + +/// Subset of [`AuthContext`] usable by code that only needs the actor +/// identity (no token metadata). Cheap to clone. +/// +/// Fields are private; construct via [`IdentityContext::new`] and read +/// via accessor methods. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(clippy::struct_field_names)] +pub struct IdentityContext { + subject_id: Uuid, + org_id: Uuid, + correlation_id: Uuid, +} + +impl IdentityContext { + /// Construct a fresh [`IdentityContext`]. + /// + /// # Errors + /// + /// Returns [`AuthContextError::NilUuid`] if any of the three identifiers + /// is the nil UUID. + pub const fn new( + subject_id: Uuid, + org_id: Uuid, + correlation_id: Uuid, + ) -> Result { + if subject_id.is_nil() { + return Err(AuthContextError::NilUuid("subject_id")); + } + if org_id.is_nil() { + return Err(AuthContextError::NilUuid("org_id")); + } + if correlation_id.is_nil() { + return Err(AuthContextError::NilUuid("correlation_id")); + } + Ok(Self { + subject_id, + org_id, + correlation_id, + }) + } + + /// Subject (user) identifier. + #[must_use] + pub const fn subject_id(&self) -> Uuid { + self.subject_id + } + + /// Active organisation scope. + #[must_use] + pub const fn org_id(&self) -> Uuid { + self.org_id + } + + /// Per-request correlation ID. + #[must_use] + pub const fn correlation_id(&self) -> Uuid { + self.correlation_id + } +} + +/// How a caller authenticated for the current request. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum AuthMethod { + /// Password sign-in. + Password, + /// OIDC authorisation-code flow callback. + Oidc, + /// SAML 2.0 ACS. + Saml, + /// Personal access token bearer. + ApiToken, + /// SCIM bearer token. + ScimToken, + /// Worker / service token bearer. + ServiceToken, +} + +/// Class of the bearer token the gateway received. +/// +/// The class is encoded in the token's prefix; how the prefix +/// participates in any hashing is a concern of the consumer (see the +/// session module's introspector). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum TokenClass { + /// `sid_<43>`: session cookie or bearer. + Session, + /// `pat_<43>`: personal API token. + PersonalAccessToken, + /// `scim_<43>`: SCIM bearer. + Scim, + /// `svc_<43>`: worker service token. + Service, +} + +impl TokenClass { + /// Prefix as it appears in the raw token (`sid_`, `pat_`, `scim_`, `svc_`). + #[must_use] + pub const fn prefix(self) -> &'static str { + match self { + Self::Session => "sid_", + Self::PersonalAccessToken => "pat_", + Self::Scim => "scim_", + Self::Service => "svc_", + } + } + + /// Parse the prefix from a raw token; returns `None` if the prefix is + /// not one of the four documented classes. + /// + /// **Note:** this only inspects the prefix; the body length / charset + /// are not validated. Callers that need full validation must use + /// [`RawTokenStr::parse`] which returns `(TokenClass, body)` after + /// asserting the body is exactly 43 base64url characters, defending + /// the session-module introspector fast-fail path against malformed input. + #[must_use] + pub fn from_prefix(raw: &str) -> Option { + if raw.starts_with("sid_") { + Some(Self::Session) + } else if raw.starts_with("pat_") { + Some(Self::PersonalAccessToken) + } else if raw.starts_with("scim_") { + Some(Self::Scim) + } else if raw.starts_with("svc_") { + Some(Self::Service) + } else { + None + } + } +} + +/// Length of the body portion of every raw token (`sid_<43>`, `pat_<43>`, +/// `scim_<43>`, `svc_<43>`). 43 base64url characters encode 32 bytes of +/// entropy via the standard length formula `ceil(32 * 4 / 3)` rounded +/// down to remove the `=` padding character that base64url omits. +const TOKEN_BODY_LEN: usize = 43; + +/// Strictly-validated raw token reference. +/// +/// [`RawTokenStr::parse`] returns the parsed [`TokenClass`] and the +/// validated body slice. Validation rejects: +/// +/// - missing prefix, +/// - body length other than 43 chars, +/// - any character outside the base64url alphabet `[A-Za-z0-9_-]`. +/// +/// Session-module introspectors call this BEFORE touching the database so a +/// malformed prefix does not cost a DB round-trip per request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RawTokenStr<'a> { + class: TokenClass, + body: &'a str, +} + +impl<'a> RawTokenStr<'a> { + /// Parse a raw token string into [`(TokenClass, body)`]. + /// + /// # Errors + /// + /// Returns [`AuthError::MalformedPrefix`] when no prefix matches, the + /// body length differs from the 43-character token body size, or the body contains + /// a character outside the base64url alphabet. + pub fn parse(raw: &'a str) -> Result { + let class = TokenClass::from_prefix(raw).ok_or(AuthError::MalformedPrefix)?; + let body = raw + .get(class.prefix().len()..) + .ok_or(AuthError::MalformedPrefix)?; + if body.len() != TOKEN_BODY_LEN { + return Err(AuthError::MalformedPrefix); + } + if !body + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(AuthError::MalformedPrefix); + } + Ok(Self { class, body }) + } + + /// Token class. + #[must_use] + pub const fn class(self) -> TokenClass { + self.class + } + + /// Validated body (43 base64url chars, no prefix). + #[must_use] + pub const fn body(self) -> &'a str { + self.body + } +} + +/// Errors produced by [`AuthContext::new`] / [`IdentityContext::new`] +/// when the caller violates a construction invariant. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AuthContextError { + /// One of the load-bearing UUID fields was the nil UUID. The static + /// string names which field was rejected. + #[error("auth context field `{0}` must not be the nil UUID")] + NilUuid(&'static str), + /// `amr` was empty; RFC 8176 requires at least one method on every + /// authenticated request. + #[error("auth context `amr` must carry at least one entry")] + EmptyAmr, + /// `issued_at >= expires_at`. Sessions must close in the future. + #[error("auth context `issued_at` must be strictly before `expires_at`")] + InvalidTimeWindow, +} + +/// Errors a [`crate::SessionIntrospector`] may surface to the gateway. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum AuthError { + /// Token does not start with one of the four documented prefixes, + /// or the body length / charset failed [`RawTokenStr::parse`] checks. + #[error("malformed token prefix")] + MalformedPrefix, + /// Token does not resolve to a live session. + #[error("unauthorized")] + Unauthorized, + /// Session resolved but `expires_at` is in the past. + #[error("session expired")] + Expired, + /// Session resolved but `revoked_at` is set. + #[error("session revoked")] + Revoked, + /// Internal failure (DB / cache / health probe). Caller surfaces as 500. + /// + /// Carries the upstream error chain via `Box` so logging + /// callers can render the chain via `format!("{e:?}")` without losing + /// the root cause. The [`std::fmt::Display`] impl deliberately renders + /// only the static label `"internal"` so format strings ending up on a + /// client response do not leak DB hostnames / credentials embedded in + /// the underlying error. + #[error("internal")] + Internal(#[source] Box), + /// Per-token rate-limit budget exhausted. Surfaced to the gateway + /// so it can render `429 Too Many Requests` with the + /// `Retry-After` header populated from `retry_after`. Used by + /// the personal-access-token resolver and the SCIM / service + /// token resolvers when their per-token budget trips. + #[error("rate limited; retry after {retry_after:?}")] + RateLimited { + /// Wall-clock duration the caller should wait before retrying. + retry_after: std::time::Duration, + }, +} + +impl AuthError { + /// Wrap any `std::error::Error + Send + Sync + 'static` source as the + /// [`AuthError::Internal`] variant. Callers use this to lift sqlx / + /// reqwest / cache errors without losing the chain. + pub fn internal(err: E) -> Self + where + E: std::error::Error + Send + Sync + 'static, + { + Self::Internal(Box::new(err)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(AuthContext: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(AuthContext: serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(IdentityContext: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(AuthError: Send, Sync, std::error::Error); + assert_impl_all!(AuthContextError: Send, Sync, std::error::Error); + assert_impl_all!(RawTokenStr<'static>: Send, Sync, Copy, std::fmt::Debug); + const _: fn() = || { + // Every error type must satisfy `'static + Send + Sync`. Without a + // standalone helper `assert_impl_all!` cannot encode the `'static` + // bound on its own. + fn require_static() {} + require_static::(); + require_static::(); + }; + + fn valid_amr() -> Vec { + vec!["pwd".into()] + } + + fn ts(secs: i64) -> DateTime { + DateTime::::from_timestamp(secs, 0) + .unwrap_or_else(|| panic!("failed to build DateTime from {secs}")) + } + + fn valid_uuid(byte: u8) -> Uuid { + Uuid::from_bytes([byte; 16]) + } + + fn valid_auth_context() -> AuthContext { + AuthContext::new( + valid_uuid(1), + valid_uuid(2), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(0), + ts(3600), + valid_uuid(4), + ) + .unwrap_or_else(|e| panic!("valid AuthContext rejected: {e}")) + } + + #[test] + fn auth_context_new_rejects_nil_subject_id() { + let err = AuthContext::new( + Uuid::nil(), + valid_uuid(2), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(0), + ts(3600), + valid_uuid(4), + ) + .expect_err("nil subject_id must reject"); + assert!(matches!(err, AuthContextError::NilUuid("subject_id"))); + } + + #[test] + fn auth_context_new_rejects_nil_session_id() { + let err = AuthContext::new( + valid_uuid(1), + Uuid::nil(), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(0), + ts(3600), + valid_uuid(4), + ) + .expect_err("nil session_id must reject"); + assert!(matches!(err, AuthContextError::NilUuid("session_id"))); + } + + #[test] + fn auth_context_new_rejects_nil_org_id() { + let err = AuthContext::new( + valid_uuid(1), + valid_uuid(2), + Uuid::nil(), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(0), + ts(3600), + valid_uuid(4), + ) + .expect_err("nil org_id must reject"); + assert!(matches!(err, AuthContextError::NilUuid("org_id"))); + } + + #[test] + fn auth_context_new_rejects_empty_amr() { + let err = AuthContext::new( + valid_uuid(1), + valid_uuid(2), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + Vec::new(), + None, + ts(0), + ts(3600), + valid_uuid(4), + ) + .expect_err("empty amr must reject"); + assert!(matches!(err, AuthContextError::EmptyAmr)); + } + + #[test] + fn auth_context_new_rejects_inverted_time_window() { + let err = AuthContext::new( + valid_uuid(1), + valid_uuid(2), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(3600), + ts(0), + valid_uuid(4), + ) + .expect_err("inverted time window must reject"); + assert!(matches!(err, AuthContextError::InvalidTimeWindow)); + } + + #[test] + fn auth_context_new_rejects_zero_duration() { + let err = AuthContext::new( + valid_uuid(1), + valid_uuid(2), + valid_uuid(3), + AuthMethod::Password, + TokenClass::Session, + valid_amr(), + None, + ts(100), + ts(100), + valid_uuid(4), + ) + .expect_err("zero-duration sessions must reject"); + assert!(matches!(err, AuthContextError::InvalidTimeWindow)); + } + + #[test] + fn auth_context_accessors_match_constructor_inputs() { + let ctx = valid_auth_context(); + assert_eq!(ctx.subject_id(), valid_uuid(1)); + assert_eq!(ctx.session_id(), valid_uuid(2)); + assert_eq!(ctx.org_id(), valid_uuid(3)); + assert_eq!(ctx.auth_method(), AuthMethod::Password); + assert_eq!(ctx.token_class(), TokenClass::Session); + assert_eq!(ctx.amr(), &["pwd"]); + assert!(ctx.acr().is_none()); + assert_eq!(ctx.issued_at(), ts(0)); + assert_eq!(ctx.expires_at(), ts(3600)); + assert_eq!(ctx.correlation_id(), valid_uuid(4)); + } + + #[test] + fn identity_context_new_rejects_nil_uuids() { + assert!(matches!( + IdentityContext::new(Uuid::nil(), valid_uuid(2), valid_uuid(3)).unwrap_err(), + AuthContextError::NilUuid("subject_id") + )); + assert!(matches!( + IdentityContext::new(valid_uuid(1), Uuid::nil(), valid_uuid(3)).unwrap_err(), + AuthContextError::NilUuid("org_id") + )); + assert!(matches!( + IdentityContext::new(valid_uuid(1), valid_uuid(2), Uuid::nil()).unwrap_err(), + AuthContextError::NilUuid("correlation_id") + )); + } + + #[test] + fn token_class_prefix_round_trips() { + assert_eq!(TokenClass::Session.prefix(), "sid_"); + assert_eq!(TokenClass::PersonalAccessToken.prefix(), "pat_"); + assert_eq!(TokenClass::Scim.prefix(), "scim_"); + assert_eq!(TokenClass::Service.prefix(), "svc_"); + } + + #[test] + fn token_class_from_prefix_recognises_classes() { + assert_eq!( + TokenClass::from_prefix("sid_xyz"), + Some(TokenClass::Session) + ); + assert_eq!( + TokenClass::from_prefix("pat_xyz"), + Some(TokenClass::PersonalAccessToken) + ); + assert_eq!(TokenClass::from_prefix("scim_xyz"), Some(TokenClass::Scim)); + assert_eq!( + TokenClass::from_prefix("svc_xyz"), + Some(TokenClass::Service) + ); + } + + #[test] + fn token_class_from_prefix_rejects_malformed() { + assert!(TokenClass::from_prefix("abc_xxx").is_none()); + assert!(TokenClass::from_prefix("xyz").is_none()); + assert!(TokenClass::from_prefix("").is_none()); + } + + #[test] + fn raw_token_str_parses_each_class() { + // Body length 43, all base64url chars. + let body43 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"; + for prefix in ["sid_", "pat_", "scim_", "svc_"] { + let raw = format!("{prefix}{body43}"); + let parsed = RawTokenStr::parse(&raw) + .unwrap_or_else(|e| panic!("{prefix}+body should parse: {e}")); + assert_eq!(parsed.body(), body43); + } + } + + #[test] + fn raw_token_str_rejects_bare_prefix() { + for prefix in ["sid_", "pat_", "scim_", "svc_"] { + assert!(matches!( + RawTokenStr::parse(prefix).unwrap_err(), + AuthError::MalformedPrefix + )); + } + } + + #[test] + fn raw_token_str_rejects_short_body() { + assert!(matches!( + RawTokenStr::parse("sid_short").unwrap_err(), + AuthError::MalformedPrefix + )); + } + + #[test] + fn raw_token_str_rejects_long_body() { + let long = format!("sid_{}", "a".repeat(TOKEN_BODY_LEN + 1)); + assert!(matches!( + RawTokenStr::parse(&long).unwrap_err(), + AuthError::MalformedPrefix + )); + } + + #[test] + fn raw_token_str_rejects_non_base64url_chars() { + // 43 chars but with one '!' (outside base64url). + let mut body = "a".repeat(TOKEN_BODY_LEN); + body.replace_range(0..1, "!"); + let raw = format!("sid_{body}"); + assert!(matches!( + RawTokenStr::parse(&raw).unwrap_err(), + AuthError::MalformedPrefix + )); + } + + #[test] + fn raw_token_str_rejects_unknown_prefix() { + let body43 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ"; + let raw = format!("xyz_{body43}"); + assert!(matches!( + RawTokenStr::parse(&raw).unwrap_err(), + AuthError::MalformedPrefix + )); + } + + #[test] + fn auth_error_internal_carries_chain() { + use std::io; + let io_err = io::Error::new(io::ErrorKind::ConnectionRefused, "synthetic"); + let wrapped = AuthError::internal(io_err); + // `Display` only shows the static label. + assert_eq!(format!("{wrapped}"), "internal"); + // `Debug` (via thiserror's `#[source]`) keeps the chain. + let dbg = format!("{wrapped:?}"); + assert!(dbg.contains("ConnectionRefused")); + } + + #[test] + fn auth_error_display_does_not_leak_source() { + use std::io; + let leaky = io::Error::other("password=hunter2 host=10.0.0.5"); + let wrapped = AuthError::internal(leaky); + let rendered = format!("{wrapped}"); + assert!(!rendered.contains("hunter2")); + assert!(!rendered.contains("10.0.0.5")); + } + + #[test] + fn auth_method_round_trips_every_variant() { + // Closed-enum coverage: the exhaustive match below is a + // compile-time guarantee that every variant is accounted for. + // When a new variant lands the match becomes non-exhaustive and + // the test fails to build until the contributor adds it. + let variants = [ + AuthMethod::Password, + AuthMethod::Oidc, + AuthMethod::Saml, + AuthMethod::ApiToken, + AuthMethod::ScimToken, + AuthMethod::ServiceToken, + ]; + for variant in variants { + // Drive the exhaustiveness check via match. + match variant { + AuthMethod::Password + | AuthMethod::Oidc + | AuthMethod::Saml + | AuthMethod::ApiToken + | AuthMethod::ScimToken + | AuthMethod::ServiceToken => {} + } + let json = serde_json::to_string(&variant) + .unwrap_or_else(|e| panic!("serialise {variant:?}: {e}")); + let parsed: AuthMethod = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("deserialise {variant:?}: {e}")); + assert_eq!(parsed, variant); + } + } + + #[test] + fn token_class_round_trips_every_variant() { + let variants = [ + TokenClass::Session, + TokenClass::PersonalAccessToken, + TokenClass::Scim, + TokenClass::Service, + ]; + for variant in variants { + match variant { + TokenClass::Session + | TokenClass::PersonalAccessToken + | TokenClass::Scim + | TokenClass::Service => {} + } + let json = serde_json::to_string(&variant) + .unwrap_or_else(|e| panic!("serialise {variant:?}: {e}")); + let parsed: TokenClass = serde_json::from_str(&json) + .unwrap_or_else(|e| panic!("deserialise {variant:?}: {e}")); + assert_eq!(parsed, variant); + } + } + + #[test] + fn auth_context_serialises_only_documented_fields() { + let ctx = valid_auth_context(); + let v: serde_json::Value = + serde_json::to_value(&ctx).unwrap_or_else(|e| panic!("serialise AuthContext: {e}")); + let obj = v + .as_object() + .unwrap_or_else(|| panic!("AuthContext must serialise to a JSON object")); + let keys: std::collections::BTreeSet<_> = obj.keys().map(String::as_str).collect(); + // Session-based auth never populates `scopes`, so the + // `skip_serializing_if = Vec::is_empty` attribute keeps the + // session payload identical to the pre-bearer-scope shape. + let expected: std::collections::BTreeSet<_> = [ + "subject_id", + "session_id", + "org_id", + "auth_method", + "token_class", + "amr", + "acr", + "expires_at", + "issued_at", + "correlation_id", + ] + .into_iter() + .collect(); + assert_eq!(keys, expected, "AuthContext keys must not drift"); + // RBAC fields (roles / permissions) MUST stay out of + // AuthContext; those derive from membership state and live + // on the RBAC layer. Token-bound scopes (`scopes`) are + // permitted but only emitted when the bearer credential + // populates them (see `auth_context_emits_scopes_when_populated`). + for forbidden in ["role", "roles", "permissions"] { + assert!( + !obj.contains_key(forbidden), + "AuthContext must not carry RBAC field `{forbidden}`" + ); + } + assert!( + !obj.contains_key("scopes"), + "session AuthContext must not emit empty `scopes`", + ); + } + + #[test] + fn auth_context_emits_scopes_when_populated() { + let ctx = valid_auth_context().with_scopes(vec!["tokens:read".to_string()]); + let v: serde_json::Value = + serde_json::to_value(&ctx).unwrap_or_else(|e| panic!("serialise AuthContext: {e}")); + let obj = v + .as_object() + .unwrap_or_else(|| panic!("AuthContext must serialise to a JSON object")); + assert!( + obj.contains_key("scopes"), + "PAT-style AuthContext must emit `scopes` when populated", + ); + assert_eq!(obj["scopes"], serde_json::json!(["tokens:read"])); + } +} diff --git a/crates/zagrosi-core/src/breach_list_client.rs b/crates/zagrosi-core/src/breach_list_client.rs new file mode 100644 index 0000000..d371ada --- /dev/null +++ b/crates/zagrosi-core/src/breach_list_client.rs @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Breach-list lookup port. +//! +//! Identity's password-policy gate checks every new password +//! against a known-breached corpus. The HIBP-online k-anonymity client +//! ships as the default impl in `zagrosi-identity`; an offline-mirror +//! impl is reserved for air-gapped deploys. + +use async_trait::async_trait; + +/// Lookup port for known-breached passwords. +/// +/// Implementations MUST NOT transmit the raw password. HIBP uses +/// k-anonymity (SHA-1 prefix exchange); offline mirrors compare against a +/// local hash list. Other strategies that leak the password are forbidden. +#[async_trait] +pub trait BreachListClient: Send + Sync + 'static { + /// Check whether the given password is known-breached. + async fn check(&self, password: &str) -> Result; +} + +/// Outcome of a breach-list lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum BreachCheck { + /// Password not found in any consulted breach list. + Clean, + /// Password appears in at least one breach list. + Breached { + /// Number of times the password has been seen across breaches. + occurrences: u64, + }, + /// Lookup unavailable (mode `disabled` or upstream down). Caller + /// decides fail-open vs fail-closed; identity fail-closes when mode + /// is `online`. + Unavailable, +} + +/// Failure modes the lookup may surface. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum BreachListError { + /// Upstream lookup timed out. + #[error("upstream timeout")] + Timeout, + /// Upstream returned a non-success response. + #[error("upstream error: {0}")] + Upstream(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + + assert_obj_safe!(BreachListClient); + assert_impl_all!(BreachCheck: Send, Sync, Clone, Copy, PartialEq, Eq); +} diff --git a/crates/zagrosi-core/src/config.rs b/crates/zagrosi-core/src/config.rs index ef822df..01bb669 100644 --- a/crates/zagrosi-core/src/config.rs +++ b/crates/zagrosi-core/src/config.rs @@ -54,11 +54,11 @@ impl CoreConfig { /// file. Environment values take precedence; the file fills gaps. /// /// Unknown fields in the file are tolerated. Malformed env values or - /// malformed TOML surface as [`ZagrosiError::Config`]. + /// malformed TOML surface as [`crate::ZagrosiError::Config`]. /// /// # Errors /// - /// Returns [`ZagrosiError::Config`] when environment values or file + /// Returns [`crate::ZagrosiError::Config`] when environment values or file /// contents fail to deserialise into [`CoreConfig`]. pub fn load(opts: LoadOptions<'_>) -> Result { let mut figment = Figment::new(); diff --git a/crates/zagrosi-core/src/email_transport.rs b/crates/zagrosi-core/src/email_transport.rs new file mode 100644 index 0000000..c683681 --- /dev/null +++ b/crates/zagrosi-core/src/email_transport.rs @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Outbound-email transport port. +//! +//! Identity's email-outbox worker calls the active +//! [`EmailTransport`] impl after dequeuing a row. The default impl +//! (`LettreTransport`) ships in `zagrosi-identity`; per-tenant SMTP and +//! HTTP-API providers plug in via the same trait without touching identity. + +use async_trait::async_trait; + +/// Sink for outbound email messages. +#[async_trait] +pub trait EmailTransport: Send + Sync + 'static { + /// Deliver the message. Implementations distinguish transient + /// ([`EmailTransportError::Unavailable`]) from permanent + /// ([`EmailTransportError::Permanent`]) failures so the worker's + /// retry loop can treat them appropriately. + async fn send(&self, message: EmailMessage) -> Result<(), EmailTransportError>; +} + +/// Single outbound email value object. +/// +/// `idempotency_key` is computed by the identity producer +/// (`sha256(user_id || event_kind || correlation_id)` or equivalent) so +/// the worker dequeue scan is safe under at-least-once delivery. +/// +/// The `Debug` impl is custom: every PII-bearing field +/// (`from`, `to`, `subject`, `body_text`, `body_html`) is redacted. +/// Only the opaque `idempotency_key` survives debug output, so +/// `tracing::debug!(?msg)` cannot leak recipient identity or reset-token +/// URLs embedded in the body. +#[derive(Clone)] +pub struct EmailMessage { + /// `From:` envelope sender. + pub from: String, + /// `To:` envelope recipient. + pub to: String, + /// Subject line. + pub subject: String, + /// Plain-text body. + pub body_text: String, + /// Optional HTML body. When `None`, the worker sends text-only. + pub body_html: Option, + /// Idempotency key. Producers MUST regenerate the same key on retry. + pub idempotency_key: String, +} + +impl std::fmt::Debug for EmailMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailMessage") + .field("from", &"") + .field("to", &"") + .field("subject", &"") + .field("body_text", &"") + .field("body_html", &self.body_html.as_ref().map(|_| "")) + .field("idempotency_key", &self.idempotency_key) + .finish() + } +} + +/// Failure modes a transport may surface. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum EmailTransportError { + /// Transient failure (network blip, SMTP 4xx). Retry safe. + /// + /// The string carries operator-facing diagnostic context only; it + /// must NOT include recipient addresses or message body fragments. + /// Producers are expected to scrub before constructing this variant. + #[error("transport unavailable: {0}")] + Unavailable(String), + /// Permanent failure. The categorisation distinguishes 5xx-level + /// transport rejections (move row to `dead`) from address-level + /// faults (skip the row but keep the rest of the batch). + /// + /// Carries a typed [`EmailTransportFault`] rather than a raw `String` + /// so the retry loop can branch on the SMTP response class without + /// regex-parsing log strings — and so a recipient address embedded + /// in an SMTP response (e.g. `550 5.1.1 : User + /// unknown`) cannot leak to logs via `format!("{e}")`. + #[error("permanent failure: {fault}")] + Permanent { + /// Typed fault categorisation. + fault: EmailTransportFault, + }, +} + +/// Categorised permanent-failure detail. +/// +/// The [`std::fmt::Display`] impl never includes the redacted recipient or +/// the upstream message text — only the SMTP response class. Producers +/// can stash diagnostic strings in [`EmailTransportFault::redacted_detail`] +/// for log-side audit, but the field is `RedactedString` so its `Display` +/// renders `""` regardless. +#[derive(Debug, Clone)] +pub struct EmailTransportFault { + /// SMTP-style response category. + pub category: PermanentFaultCategory, + /// Numeric SMTP code, when known (e.g. `550`). + pub smtp_code: Option, + /// Operator-facing detail. Wraps a string in `RedactedString` so the + /// `Display` impl does not leak recipient identifiers or message + /// content even if the upstream transport echoes them back. + pub redacted_detail: RedactedString, +} + +impl std::fmt::Display for EmailTransportFault { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.smtp_code { + Some(code) => write!(f, "{} (smtp {})", self.category, code), + None => write!(f, "{}", self.category), + } + } +} + +/// Closed categorisation of permanent SMTP / HTTP-API failures. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PermanentFaultCategory { + /// Recipient address rejected (`5.1.x`). + InvalidRecipient, + /// Sender address rejected (`5.1.7`, `5.1.8`). + InvalidSender, + /// Message too large or content rejected (`5.3.x`). + ContentRejected, + /// Authentication failure to the upstream MTA (`5.7.x`). + AuthRejected, + /// Other permanent failure that does not fit a more specific bucket. + Other, +} + +impl std::fmt::Display for PermanentFaultCategory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let label = match self { + Self::InvalidRecipient => "invalid recipient", + Self::InvalidSender => "invalid sender", + Self::ContentRejected => "content rejected", + Self::AuthRejected => "auth rejected", + Self::Other => "permanent failure", + }; + f.write_str(label) + } +} + +/// String wrapper whose [`std::fmt::Display`] renders only ``. +/// +/// The inner value remains reachable to operator tooling that explicitly +/// asks for it via [`RedactedString::reveal`], but never via interpolation +/// or `format!("{ ... }")`. +#[derive(Clone)] +pub struct RedactedString(String); + +impl RedactedString { + /// Wrap a string. Callers should pass operator-facing diagnostic + /// detail; PII / secrets must not be passed to this type in the + /// first place — redaction here is defence-in-depth, not the only + /// hardening layer. + #[must_use] + pub const fn new(detail: String) -> Self { + Self(detail) + } + + /// Reveal the underlying string. Restricted call site (operator + /// tooling, debugger). + #[must_use] + pub fn reveal(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for RedactedString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("RedactedString()") + } +} + +impl std::fmt::Display for RedactedString { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + + assert_obj_safe!(EmailTransport); + assert_impl_all!(EmailMessage: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(EmailTransportError: Send, Sync, std::error::Error); + assert_impl_all!(EmailTransportFault: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(RedactedString: Send, Sync, Clone, std::fmt::Debug); + const _: fn() = || { + fn require_static() {} + require_static::(); + require_static::(); + require_static::(); + }; + + #[test] + fn debug_redacts_pii_fields() { + let msg = EmailMessage { + from: "alice@example.com".into(), + to: "bob@example.com".into(), + subject: "Reset your password".into(), + body_text: "Token rst_secret".into(), + body_html: Some("rst_secret".into()), + idempotency_key: "key-1".into(), + }; + let rendered = format!("{msg:?}"); + assert!(!rendered.contains("alice@example.com")); + assert!(!rendered.contains("bob@example.com")); + assert!(!rendered.contains("Reset your password")); + assert!(!rendered.contains("rst_secret")); + assert!(rendered.contains("key-1")); + assert!(rendered.contains("redacted")); + } + + #[test] + fn permanent_fault_display_redacts_detail() { + let fault = EmailTransportFault { + category: PermanentFaultCategory::InvalidRecipient, + smtp_code: Some(550), + redacted_detail: RedactedString::new("5.1.1 : User unknown".into()), + }; + let err = EmailTransportError::Permanent { fault }; + let rendered = format!("{err}"); + assert!(rendered.contains("invalid recipient")); + assert!(rendered.contains("550")); + assert!(!rendered.contains("bob@example.com")); + } + + #[test] + fn redacted_string_display_is_redacted() { + let secret = RedactedString::new("hunter2".into()); + let rendered = format!("{secret}"); + assert_eq!(rendered, ""); + assert!(!rendered.contains("hunter2")); + } + + #[test] + fn redacted_string_debug_is_redacted() { + let secret = RedactedString::new("hunter2".into()); + let rendered = format!("{secret:?}"); + assert!(!rendered.contains("hunter2")); + } + + #[test] + fn redacted_string_reveal_returns_inner() { + let secret = RedactedString::new("hunter2".into()); + assert_eq!(secret.reveal(), "hunter2"); + } + + /// Compile-only test: a per-tenant SMTP impl satisfies the trait + /// without breaking the public shape (forward-compat guard). + #[allow(dead_code)] + struct PerTenantSmtp; + + #[async_trait] + impl EmailTransport for PerTenantSmtp { + async fn send(&self, _message: EmailMessage) -> Result<(), EmailTransportError> { + Ok(()) + } + } +} diff --git a/crates/zagrosi-core/src/key_provider.rs b/crates/zagrosi-core/src/key_provider.rs new file mode 100644 index 0000000..2baf0a8 --- /dev/null +++ b/crates/zagrosi-core/src/key_provider.rs @@ -0,0 +1,151 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Signing-key provider port. +//! +//! Identity uses [`KeyProvider`] for JWT signing (today, in-process keys) +//! and SAML SP signing-key generation. The KMS layer will replace the default +//! impl with a KMS-backed provider; the trait shape is forward-compatible. + +use async_trait::async_trait; + +/// Signing-key provider. +/// +/// Implementations must scope keys by both `key_id` (rotation slot) and +/// `purpose` (e.g. `"session"`, `"saml-sp"`) so a single provider can +/// host multiple key materials without collision. +/// +/// Signature outputs carry their algorithm tag so verifiers can never +/// mismatch (RFC 8725 §2.1 — alg-confusion is a documented JWS class-A +/// vulnerability when the signed `alg` header is trusted blindly). +#[async_trait] +pub trait KeyProvider: Send + Sync + 'static { + /// Sign arbitrary bytes with the named key. The returned [`Signature`] + /// carries the algorithm + key id used so verifiers cannot confuse + /// outputs across rotation periods. + async fn sign(&self, key_id: &str, payload: &[u8]) -> Result; + + /// Return the active signing-key handle for the given purpose. Used + /// by metadata exporters + JWKS publishers to surface the public half. + async fn active_key(&self, purpose: &str) -> Result; +} + +/// Signed-output bundle returned by [`KeyProvider::sign`]. +/// +/// Bundling `algorithm` + `key_id` with the raw bytes prevents the +/// alg-confusion class of attacks: a verifier holding a [`KeyHandle`] +/// from a different rotation period checks the bundled algorithm +/// against the one it expects and rejects mismatches before computing +/// the verification. +#[derive(Debug, Clone)] +pub struct Signature { + /// Algorithm used to produce `bytes` (e.g. [`SignatureAlgorithm::Rs256`]). + pub algorithm: SignatureAlgorithm, + /// Stable key identifier (rotation slot) that produced this signature. + pub key_id: String, + /// Raw signature bytes in the algorithm's canonical encoding. + pub bytes: Vec, +} + +/// Closed enum of signing algorithms the platform supports. +/// +/// The lack of `#[non_exhaustive]` is intentional: every verifier MUST +/// exhaust every variant on every code path so a future algorithm cannot +/// be silently skipped via a wildcard arm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SignatureAlgorithm { + /// RSA-PKCS1v1.5 / SHA-256. Default JWS signing for v0.1. + Rs256, + /// ECDSA / P-256 / SHA-256. + Es256, + /// `EdDSA` / Ed25519. Preferred for new deployments per RFC 8037. + EdDsa, +} + +impl SignatureAlgorithm { + /// JWS / JWA registered name as it appears in the protected header. + #[must_use] + pub const fn jws_name(self) -> &'static str { + match self { + Self::Rs256 => "RS256", + Self::Es256 => "ES256", + Self::EdDsa => "EdDSA", + } + } +} + +/// Public-key descriptor returned by [`KeyProvider::active_key`]. +#[derive(Debug, Clone)] +pub struct KeyHandle { + /// Stable key identifier (rotation slot). + pub key_id: String, + /// Algorithm bound to this key. Verifiers MUST compare this against + /// [`Signature::algorithm`] before computing the verification. + pub algorithm: SignatureAlgorithm, + /// PEM-encoded public key. + pub public_key_pem: String, +} + +/// Failure modes the provider may surface. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum KeyProviderError { + /// `key_id` is not known to this provider. + #[error("unknown key id: {0}")] + UnknownKey(String), + /// `purpose` is not registered with this provider. + #[error("unknown purpose: {0}")] + UnknownPurpose(String), + /// Backend (HSM / KMS / on-disk) error. + #[error("provider error: {0}")] + Provider(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + + assert_obj_safe!(KeyProvider); + assert_impl_all!(KeyHandle: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(Signature: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!( + SignatureAlgorithm: Send, + Sync, + Copy, + Clone, + PartialEq, + Eq, + std::hash::Hash + ); + assert_impl_all!(KeyProviderError: Send, Sync, std::error::Error); + const _: fn() = || { + fn require_static() {} + require_static::(); + require_static::(); + require_static::(); + }; + + #[test] + fn jws_name_round_trips_every_algorithm() { + let variants = [ + SignatureAlgorithm::Rs256, + SignatureAlgorithm::Es256, + SignatureAlgorithm::EdDsa, + ]; + for variant in variants { + // Closed-enum coverage check: exhaustive match. + match variant { + SignatureAlgorithm::Rs256 + | SignatureAlgorithm::Es256 + | SignatureAlgorithm::EdDsa => {} + } + // JWS names are stable per RFC 7518. + let expected = match variant { + SignatureAlgorithm::Rs256 => "RS256", + SignatureAlgorithm::Es256 => "ES256", + SignatureAlgorithm::EdDsa => "EdDSA", + }; + assert_eq!(variant.jws_name(), expected); + } + } +} diff --git a/crates/zagrosi-core/src/lib.rs b/crates/zagrosi-core/src/lib.rs index 343901f..0f3eaf3 100644 --- a/crates/zagrosi-core/src/lib.rs +++ b/crates/zagrosi-core/src/lib.rs @@ -2,22 +2,53 @@ //! Foundation library for the Zagrosi platform. //! -//! Provides three primitives that every other Zagrosi crate consumes: +//! Provides the cross-crate primitives that every other Zagrosi crate +//! consumes: //! //! - Shared error types (`ZagrosiError`, `Result`); see [`error`]. -//! - A layered configuration loader (`CoreConfig`, `LoadOptions`); see [`config`]. -//! - An off-by-default observability guard wrapping `tracing`, OpenTelemetry, -//! and a Prometheus admin server; see [`observability`]. +//! - A layered configuration loader (`CoreConfig`, `LoadOptions`); see +//! [`config`]. +//! - An off-by-default observability guard wrapping `tracing`, +//! OpenTelemetry, and a Prometheus admin server; see [`observability`]. +//! - Cross-crate ports + value objects consumed by `zagrosi-identity` and +//! the future `zagrosi-rbac` / `zagrosi-audit` crates: [`auth_context`], +//! [`audit`], [`email_transport`], [`breach_list_client`], +//! [`key_provider`], [`rate_limiter`], [`mfa_policy`], +//! [`session_introspector`]. //! -//! See `documentation/governance.md` for the project-wide conventions this -//! crate enforces (DCO, Conventional Commits, lint policy). +//! See `documentation/governance.md` for the project-wide conventions +//! this crate enforces (DCO, Conventional Commits, lint policy). #![deny(missing_docs)] +pub mod audit; +pub mod auth_context; +pub mod breach_list_client; pub mod config; +pub mod email_transport; pub mod error; +pub mod key_provider; +pub mod mfa_policy; pub mod observability; +pub mod rate_limiter; +pub mod session_introspector; +pub use audit::{ + AuditActor, AuditEvent, AuditEventError, AuditEventKind, AuditEventV1, AuditPayload, + AuditResource, Auditor, NoopAuditor, ServiceName, ServiceNameError, +}; +pub use auth_context::{ + AuthContext, AuthContextError, AuthError, AuthMethod, IdentityContext, RawTokenStr, TokenClass, +}; +pub use breach_list_client::{BreachCheck, BreachListClient, BreachListError}; pub use config::{CoreConfig, LoadOptions, LogFormat}; +pub use email_transport::{ + EmailMessage, EmailTransport, EmailTransportError, EmailTransportFault, PermanentFaultCategory, + RedactedString, +}; pub use error::{Result, ZagrosiError}; +pub use key_provider::{KeyHandle, KeyProvider, KeyProviderError, Signature, SignatureAlgorithm}; +pub use mfa_policy::{AlwaysAllowMfaPolicy, AuthContinuation, Factor, MfaPolicy, Required}; pub use observability::Observability; +pub use rate_limiter::{RateLimitDecision, RateLimitKey, RateLimiter, RateLimiterError}; +pub use session_introspector::SessionIntrospector; diff --git a/crates/zagrosi-core/src/mfa_policy.rs b/crates/zagrosi-core/src/mfa_policy.rs new file mode 100644 index 0000000..1471563 --- /dev/null +++ b/crates/zagrosi-core/src/mfa_policy.rs @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! MFA policy port + auth-API continuation envelope. +//! +//! v0.1 ships [`AlwaysAllowMfaPolicy`] which always returns +//! [`Required::No`]. Future TOTP / `WebAuthn` impls plug in via the same +//! trait. The auth-API continuation envelope ([`AuthContinuation`]) is +//! generic over the session-view type so identity can plug its concrete +//! `SessionView` without `zagrosi-core` needing the type. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use crate::auth_context::IdentityContext; + +/// MFA policy decision sink. +#[async_trait] +pub trait MfaPolicy: Send + Sync + 'static { + /// v0.1 stub always returns [`Required::No`]. TOTP / `WebAuthn` lands + /// later without breaking the auth API. + async fn evaluate(&self, ctx: &IdentityContext) -> Required; +} + +/// Whether MFA is required for a given identity context. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Required { + /// No additional factor required. + No, + /// MFA required. Caller renders a challenge keyed on `challenge_id`. + Yes { + /// Acceptable factor types. + factors: Vec, + /// Stable identifier for this challenge instance. + challenge_id: uuid::Uuid, + }, +} + +/// MFA factor types (v0.1 ships none; reserved for future splits). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum Factor { + /// Time-based one-time password. + Totp, + /// `WebAuthn` (FIDO2) authenticator. + Webauthn, +} + +/// Auth-API response envelope. +/// +/// Wire shape: +/// +/// ```json +/// { "kind": "session", "session": { ... } } +/// { "kind": "mfa_required", "challenge_id": "...", "factors": ["totp"] } +/// ``` +/// +/// Generic over the session-view type so identity can return its concrete +/// `SessionView` without leaking the type into `zagrosi-core`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[non_exhaustive] +pub enum AuthContinuation { + /// Authentication complete; session attached. + Session { + /// Session view payload. + session: S, + }, + /// MFA required before session is issued. + MfaRequired { + /// Stable identifier for the issued challenge. + challenge_id: uuid::Uuid, + /// Acceptable factor types. + factors: Vec, + }, +} + +/// Default impl: never requires MFA. +#[derive(Debug, Default, Clone, Copy)] +pub struct AlwaysAllowMfaPolicy; + +#[async_trait] +impl MfaPolicy for AlwaysAllowMfaPolicy { + async fn evaluate(&self, _ctx: &IdentityContext) -> Required { + Required::No + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + use uuid::Uuid; + + assert_obj_safe!(MfaPolicy); + assert_impl_all!(Required: Send, Sync, Clone, PartialEq, Eq, serde::Serialize, serde::de::DeserializeOwned); + assert_impl_all!(AuthContinuation<()>: Send, Sync, Clone, std::fmt::Debug, serde::Serialize, serde::de::DeserializeOwned); + + fn fixture_ctx(byte: u8) -> IdentityContext { + IdentityContext::new( + Uuid::from_bytes([byte; 16]), + Uuid::from_bytes([byte.wrapping_add(1); 16]), + Uuid::from_bytes([byte.wrapping_add(2); 16]), + ) + .unwrap_or_else(|e| panic!("fixture build: {e}")) + } + + #[tokio::test] + async fn always_allow_returns_required_no_for_arbitrary_contexts() { + // The design notes demanded a property-style assertion across arbitrary + // identity contexts. We cycle the seed byte across non-zero values + // to produce 10 distinct contexts (no two share a (subject, org, + // correlation) triple) and verify the policy is constant. + let policy = AlwaysAllowMfaPolicy; + for seed in 1_u8..=10_u8 { + let decision = policy.evaluate(&fixture_ctx(seed)).await; + assert_eq!(decision, Required::No, "seed={seed}"); + } + } + + #[test] + fn auth_continuation_session_serialises_with_kind_session() { + let cont: AuthContinuation<()> = AuthContinuation::Session { session: () }; + let v = serde_json::to_value(&cont).expect("serialise"); + assert_eq!(v["kind"], serde_json::json!("session")); + } + + #[test] + fn auth_continuation_mfa_required_serialises_with_kind_mfa_required() { + let cont: AuthContinuation<()> = AuthContinuation::MfaRequired { + challenge_id: Uuid::nil(), + factors: vec![Factor::Totp], + }; + let v = serde_json::to_value(&cont).expect("serialise"); + assert_eq!(v["kind"], serde_json::json!("mfa_required")); + } +} diff --git a/crates/zagrosi-core/src/rate_limiter.rs b/crates/zagrosi-core/src/rate_limiter.rs new file mode 100644 index 0000000..43fddcf --- /dev/null +++ b/crates/zagrosi-core/src/rate_limiter.rs @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Rate-limit + lockout port. +//! +//! Identity's sign-in / password-reset / SCIM endpoints call +//! [`RateLimiter::check`] before the constant-time path. The Valkey-backed +//! sliding-window impl ships in `zagrosi-identity`. +//! +//! Keys distinguish per-IP token-bucket budgets from per-account +//! exponential lockouts and from per-token (PAT / SCIM / service) +//! budgets. The `scope` field lets a single backend host multiple buckets +//! without collision (e.g. sign-in vs password-reset both per-IP). + +use async_trait::async_trait; +use std::fmt; +use std::net::IpAddr; +use std::time::Duration; + +/// Sliding-window rate limiter + lockout. +#[async_trait] +pub trait RateLimiter: Send + Sync + 'static { + /// Probe + decrement. Returns the decision the caller must enforce. + async fn check(&self, key: &RateLimitKey) -> Result; + + /// Force-clear the lockout for a key (admin unlock path). + async fn unlock(&self, key: &RateLimitKey) -> Result<(), RateLimiterError>; +} + +/// Bucket key + scope. +/// +/// The [`fmt::Debug`] impl deliberately redacts the SHA-256 token hash on +/// [`RateLimitKey::PerToken`]: that hash IS the auth credential at the DB +/// layer (`sessions` / `api_tokens` / `scim_tokens` / `service_tokens` are +/// keyed on the same column), so a careless `tracing::debug!(?key)` after +/// a rate-limit decision would leak the credential to anyone with log read. +#[derive(Clone, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum RateLimitKey { + /// Per-source-IP bucket. + PerIp { + /// Caller IP address. + ip: IpAddr, + /// Scope tag (e.g. `"signin"`, `"password_reset"`). + scope: &'static str, + }, + /// Per-account bucket (used for exponential lockout). + PerAccount { + /// User identifier. + user_id: uuid::Uuid, + /// Scope tag. + scope: &'static str, + }, + /// Per-token bucket (PAT / SCIM / service token). + PerToken { + /// SHA-256 hash of the prefix-included raw token. + token_hash: [u8; 32], + /// Scope tag. + scope: &'static str, + }, +} + +impl fmt::Debug for RateLimitKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PerIp { ip, scope } => f + .debug_struct("PerIp") + .field("ip", ip) + .field("scope", scope) + .finish(), + Self::PerAccount { user_id, scope } => f + .debug_struct("PerAccount") + .field("user_id", user_id) + .field("scope", scope) + .finish(), + Self::PerToken { + token_hash: _, + scope, + } => f + .debug_struct("PerToken") + .field("token_hash", &"") + .field("scope", scope) + .finish(), + } + } +} + +/// Decision the caller must enforce. +/// +/// `PartialEq + Eq` are derived so rate-limit telemetry can compare +/// `RateLimitDecision` values directly (assertions like `assert_eq!(decision, +/// RateLimitDecision::Allow { .. })` are weaker than equality on the inner +/// fields). `Duration` is `PartialEq + Eq`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[must_use] +#[non_exhaustive] +pub enum RateLimitDecision { + /// Request allowed. `remaining` is the remaining budget for this + /// window; `reset_in` is wall-clock time until the window resets. + Allow { + /// Remaining budget for this window. + remaining: u32, + /// Wall-clock duration until the window resets. + reset_in: Duration, + }, + /// Request denied. `retry_after` populates the `Retry-After` header. + Deny { + /// Wall-clock duration the caller should wait before retrying. + retry_after: Duration, + }, + /// Account/token locked out (exponential breach). `attempts` is the + /// breach count for telemetry; `retry_after` populates `Retry-After`. + LockedOut { + /// Wall-clock duration until the lockout expires. + retry_after: Duration, + /// Breach count for telemetry. + attempts: u32, + }, +} + +/// Backend failure modes. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum RateLimiterError { + /// Backend unavailable (Valkey down). Caller fails closed. + #[error("backend unavailable: {0}")] + Backend(String), +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::{assert_impl_all, assert_obj_safe}; + + assert_obj_safe!(RateLimiter); + assert_impl_all!(RateLimitKey: Send, Sync, Clone, PartialEq, Eq, std::hash::Hash); + assert_impl_all!( + RateLimitDecision: Send, + Sync, + Clone, + PartialEq, + Eq, + std::fmt::Debug + ); + assert_impl_all!(RateLimiterError: Send, Sync, std::error::Error); + const _: fn() = || { + fn require_static() {} + require_static::(); + }; + + #[test] + fn per_token_debug_redacts_token_hash() { + let key = RateLimitKey::PerToken { + token_hash: [0xAA; 32], + scope: "signin", + }; + let rendered = format!("{key:?}"); + assert!(!rendered.contains("aa"), "raw hex must not appear"); + assert!(!rendered.contains("AA"), "raw hex must not appear"); + assert!(rendered.contains("redacted")); + assert!(rendered.contains("signin")); + } + + #[test] + fn per_ip_debug_keeps_ip_and_scope() { + let key = RateLimitKey::PerIp { + ip: "10.0.0.7" + .parse::() + .unwrap_or_else(|e| panic!("parse: {e}")), + scope: "signin", + }; + let rendered = format!("{key:?}"); + assert!(rendered.contains("10.0.0.7")); + assert!(rendered.contains("signin")); + } + + #[test] + fn per_account_debug_keeps_user_and_scope() { + let user_id = uuid::Uuid::from_bytes([7; 16]); + let key = RateLimitKey::PerAccount { + user_id, + scope: "lockout", + }; + let rendered = format!("{key:?}"); + assert!(rendered.contains(&user_id.to_string())); + assert!(rendered.contains("lockout")); + } + + #[test] + fn rate_limit_decision_compares_via_partial_eq() { + let allow_a = RateLimitDecision::Allow { + remaining: 5, + reset_in: Duration::from_secs(60), + }; + let allow_b = RateLimitDecision::Allow { + remaining: 5, + reset_in: Duration::from_secs(60), + }; + let allow_c = RateLimitDecision::Allow { + remaining: 4, + reset_in: Duration::from_secs(60), + }; + assert_eq!(allow_a, allow_b); + assert_ne!(allow_a, allow_c); + } +} diff --git a/crates/zagrosi-core/src/session_introspector.rs b/crates/zagrosi-core/src/session_introspector.rs new file mode 100644 index 0000000..6b84b88 --- /dev/null +++ b/crates/zagrosi-core/src/session_introspector.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Gateway-facing session-resolution port. +//! +//! Concrete impl in `zagrosi-identity`. Behavioural contract: +//! +//! - MUST validate the token-class prefix (`sid_` / `pat_` / `scim_` / +//! `svc_`) before any DB or cache touch; malformed prefix → +//! [`AuthError::MalformedPrefix`]. +//! - Cache-hit path MUST return without DB touch (latency budget — primary +//! acceptance gate, ≥ 10 000 ops/sec on the 32 vCPU reference). + +use async_trait::async_trait; + +use crate::auth_context::{AuthContext, AuthError}; + +/// Gateway-facing fast path for resolving a raw bearer / cookie token to +/// an [`AuthContext`]. +#[async_trait] +pub trait SessionIntrospector: Send + Sync + 'static { + /// Resolve a raw bearer or cookie token to an [`AuthContext`], or + /// surface the appropriate [`AuthError`]. + async fn resolve(&self, raw_token: &str) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_obj_safe; + + assert_obj_safe!(SessionIntrospector); +} diff --git a/crates/zagrosi-core/tests/audit_event_v1_schema.rs b/crates/zagrosi-core/tests/audit_event_v1_schema.rs new file mode 100644 index 0000000..bd70806 --- /dev/null +++ b/crates/zagrosi-core/tests/audit_event_v1_schema.rs @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Golden-file round-trip for the v1 audit-event wire format. +//! +//! Owned by the tenant-isolation layer's `zagrosi-audit` consumers; breaking changes here +//! are a hard breaking change to the audit storage schema. + +use zagrosi_core::AuditEvent; + +#[test] +fn audit_event_v1_fixture_round_trips() { + let raw = include_str!("fixtures/audit_event_v1.json"); + let event: AuditEvent = serde_json::from_str(raw).expect("fixture parses"); + let re = serde_json::to_value(&event).expect("re-serialise"); + let original: serde_json::Value = serde_json::from_str(raw).expect("original parses as value"); + assert_eq!(re, original, "AuditEvent round-trip is not lossless"); +} diff --git a/crates/zagrosi-core/tests/fixtures/audit_event_v1.json b/crates/zagrosi-core/tests/fixtures/audit_event_v1.json new file mode 100644 index 0000000..a105cc7 --- /dev/null +++ b/crates/zagrosi-core/tests/fixtures/audit_event_v1.json @@ -0,0 +1,18 @@ +{ + "schema_version": "1", + "event_id": "00000000-0000-0000-0000-000000000001", + "event_kind": "signin_success", + "actor": { + "kind": "user", + "user_id": "00000000-0000-0000-0000-000000000002", + "ip": "127.0.0.1" + }, + "resource": { + "kind": "session", + "session_id": "00000000-0000-0000-0000-000000000003" + }, + "correlation_id": "00000000-0000-0000-0000-000000000004", + "occurred_at": "2026-01-01T00:00:00Z", + "org_id": "00000000-0000-0000-0000-000000000005", + "payload": {} +} diff --git a/crates/zagrosi-identity/.sqlx/.gitkeep b/crates/zagrosi-identity/.sqlx/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/zagrosi-identity/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json b/crates/zagrosi-identity/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json new file mode 100644 index 0000000..8860c87 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\n SET email_verified_at = now(),\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL AND email_verified_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "02e27e7b0a6f21836e84bc87992cf792363eb188013d8f77dcc2c56aa2eed4e2" +} diff --git a/crates/zagrosi-identity/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json b/crates/zagrosi-identity/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json new file mode 100644 index 0000000..6be9fae --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT set_config('app.current_org_id', $1::text, true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "set_config", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "04bfdb6863fdb42ff560d1677ef2ca0f4a771ef2c54278387ff63746297a6361" +} diff --git a/crates/zagrosi-identity/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json b/crates/zagrosi-identity/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json new file mode 100644 index 0000000..acfb3f0 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "05ea3739e77665d5446b08977aa35fe2b0d3ff023b293ac62b3aa15458da4111" +} diff --git a/crates/zagrosi-identity/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json b/crates/zagrosi-identity/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json new file mode 100644 index 0000000..1c01e4c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oidc_refresh_tokens (\n id, session_id, token_hash, prev_id\n )\n VALUES ($1, $2, $3, $4)\n RETURNING id, session_id, token_hash, prev_id,\n issued_at, used_at, revoked_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "prev_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "issued_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "0641cc40b7560b524895a5ad5b1db117bf242c2238d8cde56a31804e5c1cfc26" +} diff --git a/crates/zagrosi-identity/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json b/crates/zagrosi-identity/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json new file mode 100644 index 0000000..3cba763 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET password_hash = $2,\n password_hash_version = $3,\n password_updated_at = $4,\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Int2", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "083dda87763efb3a64d3cbfa81702682a2f4c05c744b722c4c3caa2794b1ec58" +} diff --git a/crates/zagrosi-identity/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json b/crates/zagrosi-identity/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json new file mode 100644 index 0000000..6cc31fa --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE email_verifications\n SET used_at = now()\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "099a52f65ce1332be68313dd67e7e396b5ba2c632143bcfcd61ae6915f7d3e06" +} diff --git a/crates/zagrosi-identity/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json b/crates/zagrosi-identity/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json new file mode 100644 index 0000000..afbfbe0 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE federated_identities\n SET last_login_at = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "0e4898d468efe3f9ffdfbdf23839e67b59d6440dcc477a53ce91142160e247f7" +} diff --git a/crates/zagrosi-identity/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json b/crates/zagrosi-identity/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json new file mode 100644 index 0000000..813c863 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE sessions SET revoked_at = now()\n WHERE org_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1350cd9f09c087a7de34aec417945a9fd20592a1fffd6d9924f1685ba2d238c1" +} diff --git a/crates/zagrosi-identity/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json b/crates/zagrosi-identity/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json new file mode 100644 index 0000000..0861b19 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE scim_tokens SET deleted_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "14d6c089d7dfd76911000f5317a3c01164683c10d0280389b3297b3e6426e39c" +} diff --git a/crates/zagrosi-identity/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json b/crates/zagrosi-identity/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json new file mode 100644 index 0000000..0a827dc --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8.json @@ -0,0 +1,89 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n FROM org_idps\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "18c63bedcb9933efe5b8f2cc4445de412fb82718dbe9a64d927b5680d26223f8" +} diff --git a/crates/zagrosi-identity/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json b/crates/zagrosi-identity/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json new file mode 100644 index 0000000..57d5a8e --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_org_memberships\n SET deleted_at = now()\n WHERE user_id = $1 AND org_id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "19e5de0f649e45f6c8627e3cb42014fa8d73a6c089ed6d344633211ca11ad1ee" +} diff --git a/crates/zagrosi-identity/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json b/crates/zagrosi-identity/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json new file mode 100644 index 0000000..1b8a0b9 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE password_resets\n SET used_at = now()\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1aaf7d9f5fbd702d6ad56c11f167c6749c2d724a4402e8754d0896a3d3d84a9f" +} diff --git a/crates/zagrosi-identity/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json b/crates/zagrosi-identity/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json new file mode 100644 index 0000000..de953a5 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279.json @@ -0,0 +1,69 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_org_memberships (\n id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at, created_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "basic_role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "joined_via", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "jit_provisioned_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "1b5f2625dd980919f8db6c1047ff31dd98cc84b747f00340d6b7a1e587cb0279" +} diff --git a/crates/zagrosi-identity/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json b/crates/zagrosi-identity/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json new file mode 100644 index 0000000..631ffd1 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND user_id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "1e8664e520a6c86220c38611feb97fdf1449a14f56bce91fdb0e4302bc88f477" +} diff --git a/crates/zagrosi-identity/.sqlx/query-200fdcb46878290659a2129b97bee366f76f7e7b8ef54e04c48f3aa48e8ddfb2.json b/crates/zagrosi-identity/.sqlx/query-200fdcb46878290659a2129b97bee366f76f7e7b8ef54e04c48f3aa48e8ddfb2.json new file mode 100644 index 0000000..3347258 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-200fdcb46878290659a2129b97bee366f76f7e7b8ef54e04c48f3aa48e8ddfb2.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, session_id, token_hash, prev_id,\n issued_at, used_at, revoked_at\n FROM oidc_refresh_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "session_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "prev_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "issued_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "200fdcb46878290659a2129b97bee366f76f7e7b8ef54e04c48f3aa48e8ddfb2" +} diff --git a/crates/zagrosi-identity/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json b/crates/zagrosi-identity/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json new file mode 100644 index 0000000..2e42529 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE user_id = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "25a81ed20e9b9cf428a69bb42d52014759103556b04d52888a59094443f5b06b" +} diff --git a/crates/zagrosi-identity/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json b/crates/zagrosi-identity/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json new file mode 100644 index 0000000..abdbaa5 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO failed_signin_aggregates (\n id, org_id, user_id, ip, window_start, count,\n first_attempt_at, last_attempt_at\n )\n VALUES ($1, $2, $3, $4, $5, 1, $6, $6)\n ON CONFLICT (user_id, window_start) DO UPDATE\n SET count = failed_signin_aggregates.count + 1,\n last_attempt_at = EXCLUDED.last_attempt_at\n RETURNING count, (xmax = 0) AS \"first!: bool\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "first!: bool", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Inet", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "2c769682b82e0faa232ab8333f36e4af2365393e0e32c784e205a9bf6fafb1e8" +} diff --git a/crates/zagrosi-identity/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json b/crates/zagrosi-identity/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json new file mode 100644 index 0000000..e68e848 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n FROM service_tokens\n WHERE token_hash = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "2cb0bce4a162ee30f676961b79145ab16d6ad5f7c0ba548a8b51858e6ce248ed" +} diff --git a/crates/zagrosi-identity/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json b/crates/zagrosi-identity/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json new file mode 100644 index 0000000..ee60313 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO scim_tokens (\n id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, last_used_at,\n last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 6, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Bytea", + "TextArray", + "InetArray", + "Bool", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "32ae1a43fa8f156abc5f8ff218abc9cb9739d37c2687a55021be4bfb25e03ff9" +} diff --git a/crates/zagrosi-identity/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json b/crates/zagrosi-identity/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json new file mode 100644 index 0000000..3058e86 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_refresh_tokens\n SET revoked_at = now()\n WHERE session_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "350c705e739a2d77d1bdba8e16a1b73984bdc885319d64886c0dec41af6ed3e7" +} diff --git a/crates/zagrosi-identity/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json b/crates/zagrosi-identity/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json new file mode 100644 index 0000000..5dc4cdc --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_pending_auth\n SET used_at = $2\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "35ac862d84fca5ec67e0159556107b9e966342c5bf5895165c7401e0ef6d50d4" +} diff --git a/crates/zagrosi-identity/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json b/crates/zagrosi-identity/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json new file mode 100644 index 0000000..a4f3b89 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO api_tokens (\n id, token_hash, user_id, org_id, display_name,\n scopes, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid", + "Text", + "TextArray", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "365e96e22c66df0a80c2bf72dd2338198354da7fa96bfd614fbd9ecf2a6a7809" +} diff --git a/crates/zagrosi-identity/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json b/crates/zagrosi-identity/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json new file mode 100644 index 0000000..5e9194a --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE org_idp_domains SET deleted_at = now()\n WHERE org_idp_id IN (SELECT id FROM org_idps WHERE org_id = $1)\n AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "398e13c2ad4002412f03def06a4d9905c08a0e2c419e3c7374fa6809c0ac19d6" +} diff --git a/crates/zagrosi-identity/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json b/crates/zagrosi-identity/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json new file mode 100644 index 0000000..e72dcf5 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO password_resets (id, user_id, token_hash, expires_at)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "39fbfe7036f1c5b4e16479a41bb5b93e79589ddbef3c0ed4f65b06383171fdd1" +} diff --git a/crates/zagrosi-identity/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json b/crates/zagrosi-identity/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json new file mode 100644 index 0000000..649d70b --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3e02f38a8b8ae8032332bcc11fa9da7e8ee5fc6d3dc99926750cfff06637644d" +} diff --git a/crates/zagrosi-identity/.sqlx/query-4156ed35c22480c46beba3af7027f1402fc222a3903b7cc3724a16772b37f5e0.json b/crates/zagrosi-identity/.sqlx/query-4156ed35c22480c46beba3af7027f1402fc222a3903b7cc3724a16772b37f5e0.json new file mode 100644 index 0000000..4fd84f1 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-4156ed35c22480c46beba3af7027f1402fc222a3903b7cc3724a16772b37f5e0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE service_tokens\n SET revoked_at = now()\n WHERE id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "4156ed35c22480c46beba3af7027f1402fc222a3903b7cc3724a16772b37f5e0" +} diff --git a/crates/zagrosi-identity/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json b/crates/zagrosi-identity/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json new file mode 100644 index 0000000..cc4469d --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE org_id = $1\n AND user_id = $2\n AND revoked_at IS NULL\n ORDER BY created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "45a28f11f5c35dc6cea1aeba02bf94baf213c219a2a87dc5372c993c7488cb0c" +} diff --git a/crates/zagrosi-identity/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json b/crates/zagrosi-identity/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json new file mode 100644 index 0000000..cefc809 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, display_name,\n scopes, last_used_at, last_used_ip,\n created_at, expires_at, revoked_at\n FROM api_tokens\n WHERE org_id = $1\n AND token_hash = $2\n AND revoked_at IS NULL\n AND (expires_at IS NULL OR expires_at > now())\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 6, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 8, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "revoked_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true + ] + }, + "hash": "4d84691a96dd10aea3e773140c8f69775eaeb13ba40f4ff5016fd2002864e7bb" +} diff --git a/crates/zagrosi-identity/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json b/crates/zagrosi-identity/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json new file mode 100644 index 0000000..a36ff42 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_org_memberships SET deleted_at = now()\n WHERE user_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5635b866da40892134c80e37622a01745dbf377046950561b5e9c301b55b733c" +} diff --git a/crates/zagrosi-identity/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json b/crates/zagrosi-identity/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json new file mode 100644 index 0000000..12fbc0e --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1.json @@ -0,0 +1,61 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO orgs (id, slug, display_name, primary_domain)\n VALUES ($1, $2, $3, $4)\n RETURNING id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "57d84bcc1c1e7d353788adde62dad65ac5de4b2a08049933c6a11cc02a18cee1" +} diff --git a/crates/zagrosi-identity/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json b/crates/zagrosi-identity/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json new file mode 100644 index 0000000..c247458 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE federated_identities SET user_id = NULL\n WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "5860793cdfff7404fd9031af7bc70866da93b8667d2d517dd7d2af6491c04dae" +} diff --git a/crates/zagrosi-identity/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json b/crates/zagrosi-identity/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json new file mode 100644 index 0000000..fc91340 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44.json @@ -0,0 +1,66 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, protocol, issuer_or_entity_id, subject_or_nameid,\n org_idp_id, user_id, created_at, last_login_at\n FROM federated_identities\n WHERE protocol = $1\n AND issuer_or_entity_id = $2\n AND subject_or_nameid = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issuer_or_entity_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "subject_or_nameid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "686fa1aed022be092b8c1ecfc4142f1500e5a043021ef71e925a5a2427cbcb44" +} diff --git a/crates/zagrosi-identity/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json b/crates/zagrosi-identity/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json new file mode 100644 index 0000000..02f4876 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738.json @@ -0,0 +1,100 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n FROM sessions\n WHERE id = $1\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND expires_at > now()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "690737face0a9483c4852d353cb3b8b16b6cd2e3fd246a434ba43ea6c2f85738" +} diff --git a/crates/zagrosi-identity/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json b/crates/zagrosi-identity/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json new file mode 100644 index 0000000..5c5e239 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n FROM orgs\n WHERE slug = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "6c71a4518fc80341ae5b0cea9283521e9c1038a2ae122e671c820463594b8a37" +} diff --git a/crates/zagrosi-identity/.sqlx/query-6dae596eb74d934944208a9165c9d639ddac0f7ac2d34e667bee346a88b9b217.json b/crates/zagrosi-identity/.sqlx/query-6dae596eb74d934944208a9165c9d639ddac0f7ac2d34e667bee346a88b9b217.json new file mode 100644 index 0000000..21d455d --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-6dae596eb74d934944208a9165c9d639ddac0f7ac2d34e667bee346a88b9b217.json @@ -0,0 +1,93 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (\n id, email, display_name, password_hash,\n password_updated_at, password_hash_version\n )\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Timestamptz", + "Int2" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + true + ] + }, + "hash": "6dae596eb74d934944208a9165c9d639ddac0f7ac2d34e667bee346a88b9b217" +} diff --git a/crates/zagrosi-identity/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json b/crates/zagrosi-identity/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json new file mode 100644 index 0000000..283e67c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE federated_identities\n SET user_id = NULL\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7018dc02c44fc473f9ba390e53c25c05335c78d4d1355d3e61695a860542ffe3" +} diff --git a/crates/zagrosi-identity/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json b/crates/zagrosi-identity/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json new file mode 100644 index 0000000..eb47e16 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE orgs SET deleted_at = now(), updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "77f892c90d352a0d79f9e746aabcf928ad3f6edbd25e5f49e27f77d4528ff699" +} diff --git a/crates/zagrosi-identity/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json b/crates/zagrosi-identity/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json new file mode 100644 index 0000000..48c7785 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET deleted_at = now(), updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "80b2cb37fba75a98584cb70a058e2496760a54befc3cb9a7b2f9c521e1c5478c" +} diff --git a/crates/zagrosi-identity/.sqlx/query-8517dcb8c9cdf42394645c45a4c4a1cef504ff5f2c2c29093cf4036f6c635f37.json b/crates/zagrosi-identity/.sqlx/query-8517dcb8c9cdf42394645c45a4c4a1cef504ff5f2c2c29093cf4036f6c635f37.json new file mode 100644 index 0000000..b332799 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-8517dcb8c9cdf42394645c45a4c4a1cef504ff5f2c2c29093cf4036f6c635f37.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idps\n SET config = $3,\n config_version = $4,\n updated_at = now()\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n RETURNING config_version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "config_version", + "type_info": "Int2" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Jsonb", + "Int2" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8517dcb8c9cdf42394645c45a4c4a1cef504ff5f2c2c29093cf4036f6c635f37" +} diff --git a/crates/zagrosi-identity/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json b/crates/zagrosi-identity/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json new file mode 100644 index 0000000..e0375a0 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO email_verifications (id, user_id, email, token_hash, expires_at)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8820e0c6c9459cbb250bc9da49e307a03eac188c1964ba0449f3faa51cea4d87" +} diff --git a/crates/zagrosi-identity/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json b/crates/zagrosi-identity/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json new file mode 100644 index 0000000..370ae86 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET email_verified_at = $2,\n updated_at = now()\n WHERE id = $1\n AND deleted_at IS NULL\n AND email_verified_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "8ab43ef39beddfddb71fa9ef6a54fe46a4e973b138670f22cb4342fed1004cbe" +} diff --git a/crates/zagrosi-identity/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json b/crates/zagrosi-identity/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json new file mode 100644 index 0000000..cb63987 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE sessions SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8f7ef2249c862d3413d5e64ba8d2b8c7301ea7bb478505a70bcd458cb1b7e41a" +} diff --git a/crates/zagrosi-identity/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json b/crates/zagrosi-identity/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json new file mode 100644 index 0000000..c4e669c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f.json @@ -0,0 +1,108 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sessions (\n id, token_hash, user_id, org_id,\n user_agent, ip_addr, amr, acr, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING id, token_hash, user_id, org_id, user_agent,\n ip_addr, version, amr, acr,\n created_at, last_seen_at, expires_at,\n revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_agent", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "ip_addr", + "type_info": "Inet" + }, + { + "ordinal": 6, + "name": "version", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "amr", + "type_info": "TextArray" + }, + { + "ordinal": 8, + "name": "acr", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "last_seen_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 13, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea", + "Uuid", + "Uuid", + "Text", + "Inet", + "TextArray", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false, + true, + false, + false, + false, + true, + true + ] + }, + "hash": "900d4e399e908df29aeca2185b48c5522242bc373a3fe6f47e2d57fee507b76f" +} diff --git a/crates/zagrosi-identity/.sqlx/query-938df43d9ea37785750d513556cc7c3e3f65e8843e2256d0458979ad55cc7f18.json b/crates/zagrosi-identity/.sqlx/query-938df43d9ea37785750d513556cc7c3e3f65e8843e2256d0458979ad55cc7f18.json new file mode 100644 index 0000000..4bf315b --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-938df43d9ea37785750d513556cc7c3e3f65e8843e2256d0458979ad55cc7f18.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_idp_id, state_hash, nonce_hash, verifier_hash,\n csrf_cookie_hash, redirect_uri, created_at, expires_at, used_at\n FROM oidc_pending_auth\n WHERE state_hash = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "nonce_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "verifier_hash", + "type_info": "Bytea" + }, + { + "ordinal": 5, + "name": "csrf_cookie_hash", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "938df43d9ea37785750d513556cc7c3e3f65e8843e2256d0458979ad55cc7f18" +} diff --git a/crates/zagrosi-identity/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json b/crates/zagrosi-identity/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json new file mode 100644 index 0000000..45e9460 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, display_name, primary_domain,\n created_at, updated_at, deleted_at\n FROM orgs\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "primary_domain", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true + ] + }, + "hash": "98098901a2e5071f80b0c3291b3cf0916ebcd2d1860f74e9bbc1f395a4b1eeff" +} diff --git a/crates/zagrosi-identity/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json b/crates/zagrosi-identity/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json new file mode 100644 index 0000000..9827273 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9c239ad6c36d11dc3a1ebeaf0ad148ca110ae850451ea963614fbca19dcbcb45" +} diff --git a/crates/zagrosi-identity/.sqlx/query-9f3c30f1771f7998de35a278aa4a16dd4ed45669eed26e4c268c9c9a02a12b8c.json b/crates/zagrosi-identity/.sqlx/query-9f3c30f1771f7998de35a278aa4a16dd4ed45669eed26e4c268c9c9a02a12b8c.json new file mode 100644 index 0000000..3cf913a --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-9f3c30f1771f7998de35a278aa4a16dd4ed45669eed26e4c268c9c9a02a12b8c.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE api_tokens\n SET last_used_at = $3, last_used_ip = $4\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Timestamptz", + "Inet" + ] + }, + "nullable": [] + }, + "hash": "9f3c30f1771f7998de35a278aa4a16dd4ed45669eed26e4c268c9c9a02a12b8c" +} diff --git a/crates/zagrosi-identity/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json b/crates/zagrosi-identity/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json new file mode 100644 index 0000000..5da1009 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE user_org_memberships SET deleted_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "9fe9666ee6ef2983386f542130de77065e22e80c357fe17e9974152b5944f796" +} diff --git a/crates/zagrosi-identity/.sqlx/query-a07d3291607c4c65f0549e6679f9ef65d38ddbd45efc864c0470553902fbdbaf.json b/crates/zagrosi-identity/.sqlx/query-a07d3291607c4c65f0549e6679f9ef65d38ddbd45efc864c0470553902fbdbaf.json new file mode 100644 index 0000000..336bde0 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-a07d3291607c4c65f0549e6679f9ef65d38ddbd45efc864c0470553902fbdbaf.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, created_at, updated_at, deleted_at\n FROM users\n WHERE id = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + true + ] + }, + "hash": "a07d3291607c4c65f0549e6679f9ef65d38ddbd45efc864c0470553902fbdbaf" +} diff --git a/crates/zagrosi-identity/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json b/crates/zagrosi-identity/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json new file mode 100644 index 0000000..48bd60b --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\n SET password_hash = $2,\n password_hash_version = 1,\n password_updated_at = $3,\n updated_at = now()\n WHERE id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "a89ca3aab8b149afcc0a29d473f1335774b17a9ffb9fc7e1d14c55e3d3930859" +} diff --git a/crates/zagrosi-identity/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json b/crates/zagrosi-identity/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json new file mode 100644 index 0000000..1b425b3 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO federated_identities (\n id, protocol, issuer_or_entity_id, subject_or_nameid,\n org_idp_id, user_id, last_login_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n RETURNING id, protocol, issuer_or_entity_id,\n subject_or_nameid, org_idp_id, user_id,\n created_at, last_login_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "issuer_or_entity_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "subject_or_nameid", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 5, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_login_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Text", + "Text", + "Uuid", + "Uuid", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "ab7c2727c1c70b0fda4c5ca08f1469656531f661b2dd571a24de6fd80c117adc" +} diff --git a/crates/zagrosi-identity/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json b/crates/zagrosi-identity/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json new file mode 100644 index 0000000..688472c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO email_outbox (\n id, org_id, to_address, from_address, subject,\n body_text, body_html, template_key, locale,\n idempotency_key, state, attempts, next_attempt_at\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, 'en',\n $9, 'queued', 0, now()\n )\n ON CONFLICT (org_id, idempotency_key) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "b17055c6d997831b8d5dc45ba421136330a61c95cc60b6105ad2664adc82d80e" +} diff --git a/crates/zagrosi-identity/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json b/crates/zagrosi-identity/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json new file mode 100644 index 0000000..e821f33 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b28bfd092e36a052e99bf0d18858d277bea51f6edea9fb39f7daa1fe824ce212" +} diff --git a/crates/zagrosi-identity/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json b/crates/zagrosi-identity/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json new file mode 100644 index 0000000..5772399 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, expires_at, used_at\n FROM email_verifications\n WHERE token_hash = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "b4148107baae5bbe64c88b19d0ac7cea00a7c64821cb34a0b166250ec0383924" +} diff --git a/crates/zagrosi-identity/.sqlx/query-b92a174edbdd59c2b66e2e68f15c252eff27fef0ac665163e86dd865a9bf106f.json b/crates/zagrosi-identity/.sqlx/query-b92a174edbdd59c2b66e2e68f15c252eff27fef0ac665163e86dd865a9bf106f.json new file mode 100644 index 0000000..b5f6650 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-b92a174edbdd59c2b66e2e68f15c252eff27fef0ac665163e86dd865a9bf106f.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id, email, email_lower as \"email_lower!\",\n display_name, email_verified_at, password_hash,\n password_updated_at, password_hash_version,\n mfa_enrolled_at, created_at, updated_at, deleted_at\n FROM users\n WHERE email_lower = $1 AND deleted_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_lower!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "email_verified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "password_updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "password_hash_version", + "type_info": "Int2" + }, + { + "ordinal": 8, + "name": "mfa_enrolled_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + true + ] + }, + "hash": "b92a174edbdd59c2b66e2e68f15c252eff27fef0ac665163e86dd865a9bf106f" +} diff --git a/crates/zagrosi-identity/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json b/crates/zagrosi-identity/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json new file mode 100644 index 0000000..e7be83c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE scim_tokens\n SET revoked_at = now()\n WHERE org_id = $1 AND id = $2 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "c455e4f68aee458648207c42df0374c79e194fa6c135c0ceae7c5967311417e1" +} diff --git a/crates/zagrosi-identity/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json b/crates/zagrosi-identity/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json new file mode 100644 index 0000000..5188b07 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, expires_at, used_at\n FROM password_resets\n WHERE token_hash = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "c648fe87a050d371cbb9ccd71a94ccf4a015b5dbee257c0ec9b22679f1df2627" +} diff --git a/crates/zagrosi-identity/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json b/crates/zagrosi-identity/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json new file mode 100644 index 0000000..3ec201e --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE org_idps\n SET deleted_at = now(), updated_at = now()\n WHERE org_id = $1 AND id = $2 AND deleted_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cae04dd57e907c8c5eaba1f7c339e6a39bb7c943ba09e65561ffd69b7118501f" +} diff --git a/crates/zagrosi-identity/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json b/crates/zagrosi-identity/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json new file mode 100644 index 0000000..49d3874 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE api_tokens SET revoked_at = now()\n WHERE user_id = $1 AND revoked_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "d33985e68357bc05b9015ff3993c4400ebea029a0a87c3cd91b3036962db007e" +} diff --git a/crates/zagrosi-identity/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json b/crates/zagrosi-identity/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json new file mode 100644 index 0000000..e81a311 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET org_id = $2,\n version = version + 1,\n last_seen_at = now()\n WHERE id = $1\n AND version = $3\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n RETURNING version\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d4739b2fcc360fd8b2faf4008c08f3779f0a141cf7a5b3f5cb17b25416e148b7" +} diff --git a/crates/zagrosi-identity/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json b/crates/zagrosi-identity/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json new file mode 100644 index 0000000..8a84b3c --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oidc_refresh_tokens\n SET used_at = $2\n WHERE id = $1 AND used_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d6c04d2f082bf2ddd704e73dfad8572a85f0192b61d91c6f97c4c9c55a56ad98" +} diff --git a/crates/zagrosi-identity/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json b/crates/zagrosi-identity/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json new file mode 100644 index 0000000..9d2474b --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM saml_assertion_replay\n WHERE not_on_or_after < $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "dbc8d0aa561de917ac82eb42b7bd35ac0721e27d184988350fcbf0930046bc56" +} diff --git a/crates/zagrosi-identity/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json b/crates/zagrosi-identity/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json new file mode 100644 index 0000000..a5d06ce --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE org_idps SET deleted_at = now(), updated_at = now()\n WHERE org_id = $1 AND deleted_at IS NULL", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e2bd595ea63327791c52f7980d0a7c86c974ca03de30a2388e3dcf9fc323c402" +} diff --git a/crates/zagrosi-identity/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json b/crates/zagrosi-identity/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json new file mode 100644 index 0000000..8cf2083 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, display_name, token_hash, scopes,\n allowed_cidrs, tolerant_mode, last_used_at,\n last_used_ip, created_at, expires_at,\n revoked_at, deleted_at\n FROM scim_tokens\n WHERE org_id = $1\n AND token_hash = $2\n AND revoked_at IS NULL\n AND deleted_at IS NULL\n AND (expires_at IS NULL OR expires_at > now())\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "scopes", + "type_info": "TextArray" + }, + { + "ordinal": 5, + "name": "allowed_cidrs", + "type_info": "InetArray" + }, + { + "ordinal": 6, + "name": "tolerant_mode", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "last_used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "last_used_ip", + "type_info": "Inet" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Bytea" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + false, + true, + true, + true + ] + }, + "hash": "eb632bcdbabace480fe67e71e1aa723cfc6bbb66140ed256fd7f4f09f4d387a2" +} diff --git a/crates/zagrosi-identity/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json b/crates/zagrosi-identity/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json new file mode 100644 index 0000000..a0b900a --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO service_tokens (\n id, service_name, token_hash, allowed_subjects, display_name\n )\n VALUES ($1, $2, $3, $4, $5)\n RETURNING id, service_name, token_hash, allowed_subjects,\n display_name, created_at, revoked_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "service_name", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "allowed_subjects", + "type_info": "TextArray" + }, + { + "ordinal": 4, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "revoked_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Bytea", + "TextArray", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "ec679adf66eaa734c72bdae2aee2c76e07f177df58cb8ecbe6d83cc39e522215" +} diff --git a/crates/zagrosi-identity/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json b/crates/zagrosi-identity/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json new file mode 100644 index 0000000..e558a5a --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n FROM org_idps\n WHERE org_id = $1 AND deleted_at IS NULL\n ORDER BY display_name ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "ed1155cf346a24266105ea867a663d66b07783ba82d4a2afb157a01228831818" +} diff --git a/crates/zagrosi-identity/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json b/crates/zagrosi-identity/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json new file mode 100644 index 0000000..e61fb5b --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO saml_assertion_replay (\n org_idp_id, assertion_id, not_on_or_after\n )\n VALUES ($1, $2, $3)\n RETURNING org_idp_id, assertion_id, not_on_or_after, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "assertion_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "not_on_or_after", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "ef4f57a251766c32e7448c4a43586693d77a8b0f7205d7ecbf79318107fefb07" +} diff --git a/crates/zagrosi-identity/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json b/crates/zagrosi-identity/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json new file mode 100644 index 0000000..20d0ca6 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oidc_pending_auth (\n id, org_idp_id, state_hash, nonce_hash, verifier_hash,\n csrf_cookie_hash, redirect_uri, expires_at\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n RETURNING id, org_idp_id, state_hash, nonce_hash,\n verifier_hash, csrf_cookie_hash, redirect_uri,\n created_at, expires_at, used_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_idp_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "state_hash", + "type_info": "Bytea" + }, + { + "ordinal": 3, + "name": "nonce_hash", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "verifier_hash", + "type_info": "Bytea" + }, + { + "ordinal": 5, + "name": "csrf_cookie_hash", + "type_info": "Bytea" + }, + { + "ordinal": 6, + "name": "redirect_uri", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "used_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Bytea", + "Bytea", + "Bytea", + "Bytea", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f5cedf86c7e8971c78835d89206aa3ca137ec06d1cb5504e00b42a0b846853d1" +} diff --git a/crates/zagrosi-identity/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json b/crates/zagrosi-identity/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json new file mode 100644 index 0000000..78aa4b3 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, org_id, basic_role, joined_via,\n jit_provisioned_at, created_at, deleted_at\n FROM user_org_memberships\n WHERE user_id = $1 AND deleted_at IS NULL\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "basic_role", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "joined_via", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "jit_provisioned_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true + ] + }, + "hash": "f8633e7a109643ed5057053cb78d2267471f582f11f5296de10e6de0f4790081" +} diff --git a/crates/zagrosi-identity/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json b/crates/zagrosi-identity/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json new file mode 100644 index 0000000..689e8d7 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de.json @@ -0,0 +1,96 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO org_idps (\n id, org_id, protocol, display_name,\n config, config_version, jit_provisioning,\n is_default, enabled\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n RETURNING id, org_id, protocol, display_name, config,\n config_version, jit_provisioning, is_default,\n enabled, created_at, updated_at, deleted_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "org_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "protocol", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "config", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "config_version", + "type_info": "Int2" + }, + { + "ordinal": 6, + "name": "jit_provisioning", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "is_default", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text", + "Text", + "Jsonb", + "Int2", + "Bool", + "Bool", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "f9479efcde8f28df113a5d1a325ac9da09b7e01ac1ac7dd98d28c7c4244275de" +} diff --git a/crates/zagrosi-identity/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json b/crates/zagrosi-identity/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json new file mode 100644 index 0000000..be7d372 --- /dev/null +++ b/crates/zagrosi-identity/.sqlx/query-fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET revoked_at = now()\n WHERE org_id = $1 AND revoked_at IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "fd364829cbf618f4b5e5994fb8f1ec1cdaff594ccfd8c37ebb40eaa6c471151c" +} diff --git a/crates/zagrosi-identity/Cargo.toml b/crates/zagrosi-identity/Cargo.toml new file mode 100644 index 0000000..57b2f81 --- /dev/null +++ b/crates/zagrosi-identity/Cargo.toml @@ -0,0 +1,145 @@ +[package] +name = "zagrosi-identity" +version = "0.1.0" +description = "Identity, SSO, and SCIM foundation crate for the Zagrosi platform: users, organisations, sessions, password auth, OIDC, SAML 2.0, and SCIM 2.0." +keywords = ["zagrosi", "identity", "sso", "oidc", "scim"] +categories = ["authentication"] +publish = false +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +homepage.workspace = true +authors.workspace = true +readme.workspace = true + +[lints] +workspace = true + +[features] +default = [] +# Pull samael + libxmlsec1 + libxml2 + openssl C link. Reviewers MUST +# verify the default build's binary symbol table contains no +# `libssl` / `libcrypto` / `libxmlsec1` references after the saml +# feature gate is added. The `tests/saml_*` integration tests are +# gated on this feature; default `cargo test --workspace` does not +# exercise them. +saml = ["dep:samael", "dep:flate2", "dep:quick-xml", "dep:aws-lc-rs"] +# Gates the `*_for_fuzz` re-exports consumed by the cargo-fuzz +# harness in `fuzz/` (a separate workspace, see root `Cargo.toml` +# `[workspace] exclude`). Adds no dependencies and ships no code on +# the default build; `cargo +nightly fuzz build` turns it on so the +# libFuzzer targets can reach the otherwise network-coupled verify +# entry points (e.g. `oidc::verify_id_token_for_fuzz`). The +# equivalent SAML / SCIM entry points (`saml::acs::fuzz_entry`, +# `http::scim::filter::parse`) are already `pub` and need no gate. +fuzzing = [] + +[dependencies] +zagrosi-core = { path = "../zagrosi-core" } +serde = { workspace = true } +thiserror = { workspace = true } +figment = { workspace = true } +base64 = { workspace = true } +sqlx = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } +aes-gcm = { workspace = true } +zeroize = { workspace = true } +secrecy = { workspace = true } +rand_core = { workspace = true } +sha2 = { workspace = true } +subtle = { workspace = true } +# Password-auth. +argon2 = { workspace = true } +password-hash = { workspace = true } +# `sha1` use is restricted to the HIBP k-anonymity legacy-API path +# (src/password/breach.rs); all other hashing uses sha2 above. +sha1 = { workspace = true } +hex = { workspace = true } +fluent-templates = { workspace = true } +validator = { workspace = true } +num_cpus = { workspace = true } +reqwest = { workspace = true } +url = { workspace = true } +http = { workspace = true } +mime = { workspace = true } +async-trait = { workspace = true } +tracing = { workspace = true } +tokio = { workspace = true } +axum = { workspace = true } +# Async client for the Valkey-backed rate limiter + lockout. +fred = { workspace = true } +# Session resolver: in-process LRU + reverse-lookup + cookie builder + NATS pub/sub. +moka = { workspace = true } +dashmap = { workspace = true } +cookie = { workspace = true } +async-nats = { workspace = true } +futures = { workspace = true } +arc-swap = { workspace = true } +# Email-outbox worker: SMTP transport + cooperative shutdown token. +lettre = { workspace = true } +tokio-util = { workspace = true } +# Multi-IdP routing (section-13). DNSSEC-validating resolver, +# Mozilla Public Suffix List, and IDN punycoding. See workspace +# `Cargo.toml` for the pin rationale + feature-flag selection. +hickory-resolver = { workspace = true } +psl = { workspace = true } +idna = { workspace = true } +# `metrics` for the discover-decision counter +# (`zagrosi_identity_discover_total{decision=...}`); the workspace +# already pins the v0.24 facade plus the prometheus exporter at the +# observability layer. +metrics = { workspace = true } +# OIDC Authorization Code + PKCE flow (see `src/oidc/`). +openidconnect = { workspace = true } +# SAML 2.0 SP — feature-gated to keep default builds C-link free. +# See module docs in `src/saml/mod.rs` and the `saml` feature +# definition above. +samael = { workspace = true, optional = true } +flate2 = { workspace = true, optional = true } +quick-xml = { workspace = true, optional = true } +aws-lc-rs = { workspace = true, optional = true } + +[dev-dependencies] +proptest = { workspace = true } +tempfile = { workspace = true } +serial_test = { workspace = true } +static_assertions = { workspace = true } +serde_json = { workspace = true } +figment = { workspace = true, features = ["test"] } +tokio = { workspace = true } +testcontainers-modules = { workspace = true } +wiremock = { workspace = true } +tracing-test = { workspace = true } +trybuild = { workspace = true } +criterion = { workspace = true, features = ["async_tokio", "html_reports"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +async-trait = { workspace = true } + +[[bench]] +name = "argon2_calibration" +harness = false + +[[bench]] +name = "signin_password_bench" +harness = false + +[[bench]] +name = "signin_oidc_callback_bench" +harness = false + +[[bench]] +name = "signin_saml_acs_bench" +harness = false +required-features = ["saml"] + +[[bench]] +name = "session_resolve_bench" +harness = false + +[[bench]] +name = "session_resolve_bench_cold" +harness = false diff --git a/crates/zagrosi-identity/benches/argon2_calibration.rs b/crates/zagrosi-identity/benches/argon2_calibration.rs new file mode 100644 index 0000000..9f2c0f7 --- /dev/null +++ b/crates/zagrosi-identity/benches/argon2_calibration.rs @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, criterion_group, criterion_main}; +use zagrosi_identity::password::{Argon2idHasher, calibrate}; + +mod common; + +fn bench_argon2_calibration(c: &mut Criterion) { + let rt = common::criterion_runtime(); + let hasher = Argon2idHasher::new(&common::bench_argon2_config()).expect("argon2 config"); + + c.bench_function("argon2_calibration", |b| { + b.to_async(&rt).iter(|| calibrate(&hasher)); + }); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(10) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(250)); + targets = bench_argon2_calibration +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/benches/common/mod.rs b/crates/zagrosi-identity/benches/common/mod.rs new file mode 100644 index 0000000..aac7f9f --- /dev/null +++ b/crates/zagrosi-identity/benches/common/mod.rs @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow( + clippy::expect_used, + clippy::redundant_pub_crate, + clippy::unwrap_used, + dead_code +)] + +use std::env; +use std::time::Duration; + +use chrono::{TimeZone, Utc}; +use serde_json::Value; +use tokio::runtime::Runtime; +use uuid::Uuid; +use zagrosi_identity::config::Argon2Config; +use zagrosi_identity::domain::token_format::TokenHash; +use zagrosi_identity::session::{CachedSession, SessionCache}; + +pub(super) const BENCH_PASSWORD: &str = "bench-password-32-bytes-long-0001"; + +pub(super) fn criterion_runtime() -> Runtime { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .worker_threads(2) + .build() + .expect("build benchmark runtime") +} + +pub(super) fn bench_argon2_config() -> Argon2Config { + Argon2Config { + m_cost: env_u32("ZAGROSI_ARGON2_M_COST", 8), + t_cost: env_u32("ZAGROSI_ARGON2_T_COST", 1), + p_cost: env_u32("ZAGROSI_ARGON2_P_COST", 1), + max_concurrency: env_usize("ZAGROSI_ARGON2_MAX_CONCURRENCY", 1), + } +} + +pub(super) fn production_argon2_config() -> Argon2Config { + Argon2Config { + m_cost: env_u32("ZAGROSI_ARGON2_M_COST", 19_456), + t_cost: env_u32("ZAGROSI_ARGON2_T_COST", 2), + p_cost: env_u32("ZAGROSI_ARGON2_P_COST", 1), + max_concurrency: env_usize("ZAGROSI_ARGON2_MAX_CONCURRENCY", num_cpus::get()), + } +} + +pub(super) fn cached_session(seed: u8) -> (TokenHash, CachedSession) { + let mut hash = [seed; 32]; + hash[31] = hash[31].wrapping_add(17); + let created_at = Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(); + ( + TokenHash(hash), + CachedSession { + session_id: Uuid::from_bytes([seed; 16]), + user_id: Uuid::from_bytes([seed.wrapping_add(1); 16]), + org_id: Uuid::from_bytes([seed.wrapping_add(2); 16]), + expires_at: Utc.with_ymd_and_hms(2027, 1, 1, 0, 0, 0).unwrap(), + revoked_at: None, + version: i64::from(seed.max(1)), + password_updated_at_at_resolve: created_at, + amr: vec!["pwd".to_string()], + acr: Some("urn:zagrosi:bench".to_string()), + created_at, + }, + ) +} + +pub(super) async fn warm_session_cache(size: usize) -> (SessionCache, Vec) { + let capacity = u64::try_from(size) + .unwrap_or(u64::MAX - 16) + .saturating_add(16); + let cache = SessionCache::new(capacity, Duration::from_secs(300)); + let mut hashes = Vec::with_capacity(size); + for i in 0..size { + let seed = u8::try_from((i % 250) + 1).unwrap_or(1); + let (hash, value) = cached_session(seed); + cache.insert(hash, value).await; + hashes.push(hash); + } + (cache, hashes) +} + +pub(super) fn decode_oidc_fixture(bytes: &[u8]) -> Value { + let fixture: Value = serde_json::from_slice(bytes).expect("OIDC bench fixture must be JSON"); + let token = fixture + .get("id_token") + .and_then(Value::as_str) + .expect("OIDC bench fixture must carry id_token"); + let claims = fixture + .get("claims") + .and_then(Value::as_object) + .expect("OIDC bench fixture must carry claims"); + assert!( + token.split('.').count() == 3, + "id_token must be compact JWS" + ); + assert_eq!( + claims.get("iss").and_then(Value::as_str), + Some("https://authentik.test/application/o/zagrosi/") + ); + assert_eq!( + claims.get("aud").and_then(Value::as_str), + Some("zagrosi-bench") + ); + fixture +} + +pub(super) fn decode_saml_fixture(bytes: &[u8]) -> String { + let xml = std::str::from_utf8(bytes).expect("SAML bench fixture must be UTF-8"); + assert!( + xml.contains(" u32 { + env::var(name) + .ok() + .and_then(|raw| raw.parse().ok()) + .unwrap_or(default) +} + +fn env_usize(name: &str, default: usize) -> usize { + env::var(name) + .ok() + .and_then(|raw| raw.parse().ok()) + .unwrap_or(default) +} diff --git a/crates/zagrosi-identity/benches/session_resolve_bench.rs b/crates/zagrosi-identity/benches/session_resolve_bench.rs new file mode 100644 index 0000000..d993c54 --- /dev/null +++ b/crates/zagrosi-identity/benches/session_resolve_bench.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; + +mod common; + +fn bench_session_resolve_warm(c: &mut Criterion) { + let rt = common::criterion_runtime(); + let (cache, hashes) = rt.block_on(common::warm_session_cache(256)); + let mut index = 0usize; + + let mut group = c.benchmark_group("session_resolve_bench"); + group.throughput(Throughput::Elements(1)); + group.bench_function("warm_cache_get", |b| { + b.to_async(&rt).iter(|| { + let hash = hashes[index % hashes.len()]; + index = index.wrapping_add(1); + let cache = cache.clone(); + async move { + let got = cache.get(&hash).await.expect("warm cache hit"); + criterion::black_box(got); + } + }); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(20) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(300)); + targets = bench_session_resolve_warm +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/benches/session_resolve_bench_cold.rs b/crates/zagrosi-identity/benches/session_resolve_bench_cold.rs new file mode 100644 index 0000000..66ca3bc --- /dev/null +++ b/crates/zagrosi-identity/benches/session_resolve_bench_cold.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use zagrosi_identity::session::SessionCache; + +mod common; + +fn bench_session_resolve_cold(c: &mut Criterion) { + let rt = common::criterion_runtime(); + let mut seed = 1u8; + + let mut group = c.benchmark_group("session_resolve_bench_cold"); + group.throughput(Throughput::Elements(1)); + group.bench_function("insert_get_evict", |b| { + b.to_async(&rt).iter(|| { + let cache = SessionCache::new(64, Duration::from_secs(30)); + let (hash, value) = common::cached_session(seed); + seed = seed.wrapping_add(1).max(1); + async move { + cache.insert(hash, value.clone()).await; + let got = cache.get(&hash).await.expect("cold cache fill"); + let evicted = cache.evict_by_session_id(value.session_id).await; + criterion::black_box((got, evicted)); + } + }); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(20) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(300)); + targets = bench_session_resolve_cold +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/benches/signin_oidc_callback_bench.rs b/crates/zagrosi-identity/benches/signin_oidc_callback_bench.rs new file mode 100644 index 0000000..0a2daf3 --- /dev/null +++ b/crates/zagrosi-identity/benches/signin_oidc_callback_bench.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; + +mod common; + +const OIDC_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/bench/oidc_id_token.json"); + +fn bench_oidc_callback_fixture(c: &mut Criterion) { + let mut group = c.benchmark_group("signin_oidc_callback_bench"); + group.throughput(Throughput::Elements(1)); + group.bench_function("fixture_decode", |b| { + b.iter(|| { + let fixture = common::decode_oidc_fixture(OIDC_FIXTURE); + criterion::black_box(fixture); + }); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(20) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(300)); + targets = bench_oidc_callback_fixture +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/benches/signin_password_bench.rs b/crates/zagrosi-identity/benches/signin_password_bench.rs new file mode 100644 index 0000000..ef69311 --- /dev/null +++ b/crates/zagrosi-identity/benches/signin_password_bench.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; +use zagrosi_identity::password::Argon2idHasher; + +mod common; + +fn bench_password_verify(c: &mut Criterion) { + let rt = common::criterion_runtime(); + let hasher = Argon2idHasher::new(&common::bench_argon2_config()).expect("argon2 config"); + let phc = rt + .block_on(hasher.hash(common::BENCH_PASSWORD)) + .expect("hash bench password"); + + let mut group = c.benchmark_group("signin_password_bench"); + group.throughput(Throughput::Elements(1)); + group.bench_function("argon2_verify", |b| { + b.to_async(&rt).iter(|| async { + let ok = hasher + .verify(common::BENCH_PASSWORD, &phc) + .await + .expect("verify bench password"); + criterion::black_box(ok); + }); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(10) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(250)); + targets = bench_password_verify +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/benches/signin_saml_acs_bench.rs b/crates/zagrosi-identity/benches/signin_saml_acs_bench.rs new file mode 100644 index 0000000..96b7ba3 --- /dev/null +++ b/crates/zagrosi-identity/benches/signin_saml_acs_bench.rs @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![cfg(feature = "saml")] +#![allow(clippy::expect_used, missing_docs)] + +use std::time::Duration; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; + +mod common; + +const SAML_FIXTURE: &[u8] = include_bytes!("../tests/fixtures/bench/saml_assertion.xml"); + +fn bench_saml_acs_fixture(c: &mut Criterion) { + let mut group = c.benchmark_group("signin_saml_acs_bench"); + group.throughput(Throughput::Elements(1)); + group.bench_function("fixture_parse", |b| { + b.iter(|| { + let xml = common::decode_saml_fixture(SAML_FIXTURE); + zagrosi_identity::saml::acs::fuzz_entry(xml.as_bytes()); + criterion::black_box(xml); + }); + }); + group.finish(); +} + +criterion_group! { + name = benches; + config = Criterion::default() + .sample_size(20) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(300)); + targets = bench_saml_acs_fixture +} +criterion_main!(benches); diff --git a/crates/zagrosi-identity/fuzz/.gitignore b/crates/zagrosi-identity/fuzz/.gitignore new file mode 100644 index 0000000..86931b5 --- /dev/null +++ b/crates/zagrosi-identity/fuzz/.gitignore @@ -0,0 +1,8 @@ +# cargo-fuzz state lives outside the main workspace. Crash artifacts may embed +# test-only master keys + plaintexts captured during a fuzz run, so they must +# never reach the public branch. + +target/ +artifacts/ +corpus/*/ +!corpus/*/.gitkeep diff --git a/crates/zagrosi-identity/fuzz/Cargo.lock b/crates/zagrosi-identity/fuzz/Cargo.lock new file mode 100644 index 0000000..ffee53c --- /dev/null +++ b/crates/zagrosi-identity/fuzz/Cargo.lock @@ -0,0 +1,5449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-nats" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07d6f157065c3461096d51aacde0c326fa49f3f6e0199e204c566842cdaa5299" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-util", + "memchr", + "nkeys", + "nuid", + "pin-project", + "portable-atomic", + "rand 0.8.6", + "regex", + "ring", + "rustls-native-certs", + "rustls-pki-types", + "rustls-webpki", + "serde", + "serde_json", + "serde_nanos", + "serde_repr", + "thiserror 1.0.69", + "time", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "tokio-websockets", + "tracing", + "tryhard", + "url", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.10.5", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "aes-gcm", + "base64 0.22.1", + "hkdf", + "hmac", + "percent-encoding", + "rand 0.8.6", + "sha2", + "subtle", + "time", + "version_check", +] + +[[package]] +name = "cookie-factory" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396de984970346b0d9e93d1415082923c679e5ae5c3ee3dcbd104f5610af126b" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc16" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[package]] +name = "dashmap" +version = "6.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "signature", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email-encoding" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9298e6504d9b9e780ed3f7dfd43a61be8cd0e09eb07f7706a945b0072b6670b6" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "evmap" +version = "11.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8874945f036109c72242964c1174cf99434e30cfa45bf45fedc983f50046f8" +dependencies = [ + "hashbag", + "left-right", + "smallvec", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fluent-bundle" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01203cb8918f5711e73891b347816d932046f95f54207710bda99beaeb423bf4" +dependencies = [ + "fluent-langneg", + "fluent-syntax", + "intl-memoizer", + "intl_pluralrules", + "rustc-hash", + "self_cell", + "smallvec", + "unic-langid", +] + +[[package]] +name = "fluent-langneg" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "fluent-syntax" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54f0d287c53ffd184d04d8677f590f4ac5379785529e5e08b1c8083acdd5c198" +dependencies = [ + "memchr", + "thiserror 2.0.18", +] + +[[package]] +name = "fluent-template-macros" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "748050b3fb6fd97b566aedff8e9e021389c963e73d5afbeb92752c2b8d686c6c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "unic-langid", + "walkdir", +] + +[[package]] +name = "fluent-templates" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56264446a01f404469aef9cc5fd4a4d736f68a0f52482bf6d1a54d6e9bbd9476" +dependencies = [ + "fluent-bundle", + "fluent-langneg", + "fluent-syntax", + "fluent-template-macros", + "intl-memoizer", + "log", + "thiserror 2.0.18", + "unic-langid", + "walkdir", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fred" +version = "10.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a7b2fd0f08b23315c13b6156f971aeedb6f75fb16a29ac1872d2eabccc1490e" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "bytes-utils", + "float-cmp", + "fred-macros", + "futures", + "log", + "parking_lot", + "rand 0.8.6", + "redis-protocol", + "rustls", + "rustls-native-certs", + "semver", + "sha-1", + "socket2 0.5.10", + "tokio", + "tokio-rustls", + "tokio-stream", + "tokio-util", + "url", + "urlencoding", +] + +[[package]] +name = "fred-macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1458c6e22d36d61507034d5afecc64f105c1d39712b7ac6ec3b352c423f715cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbag" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7040a10f52cba493ddb09926e15d10a9d8a28043708a405931fe4c6f19fac064" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-proto" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +dependencies = [ + "async-trait", + "aws-lc-rs", + "bitflags", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna", + "ipnet", + "once_cell", + "rand 0.9.4", + "ring", + "rustls-pki-types", + "thiserror 2.0.18", + "time", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-proto", + "ipconfig", + "moka", + "once_cell", + "parking_lot", + "rand 0.9.4", + "resolv-conf", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "hostname" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if", + "libc", + "windows-link", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.7", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "intl-memoizer" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f" +dependencies = [ + "type-map", + "unic-langid", +] + +[[package]] +name = "intl_pluralrules" +version = "7.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972" +dependencies = [ + "unic-langid", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2 0.6.3", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "left-right" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0c21e4c8ff95f487fb34e6f9182875f42c84cef966d29216bf115d9bba835a" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + +[[package]] +name = "lettre" +version = "0.11.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0da65617f6cb926332d039cb578aad56178da86e128db6a1b09f4c94fa5b3349" +dependencies = [ + "async-trait", + "base64 0.22.1", + "email-encoding", + "email_address", + "fastrand", + "futures-io", + "futures-util", + "hostname", + "httpdate", + "idna", + "mime", + "nom 8.0.0", + "percent-encoding", + "quoted_printable", + "rustls", + "socket2 0.6.3", + "tokio", + "tokio-rustls", + "url", + "webpki-roots 1.0.7", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libxml" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fe73cdec2bcb36d25a9fe3f607ffcd44bb8907ca0100c4098d1aa342d1e7bec" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1db0d8f1fc9e62caebd0319e11eaec5822b0186c171568f0480b46a0137f9108" +dependencies = [ + "base64 0.22.1", + "evmap", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap 2.14.0", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "rustls", + "thiserror 2.0.18", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96f8722f8562635f92f8ed992f26df0532266eb03d5202607c20c0d7e9745e13" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.16.1", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro", + "rapidhash", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nkeys" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf" +dependencies = [ + "data-encoding", + "ed25519", + "ed25519-dalek", + "getrandom 0.2.17", + "log", + "rand 0.8.6", + "signatory", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "nuid" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83" +dependencies = [ + "rand 0.8.6", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.6", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.21.7", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools 0.10.5", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "opentelemetry" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest", + "thiserror 2.0.18", + "tracing", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry_sdk" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "rand 0.9.4", + "thiserror 2.0.18", + "tokio", + "tokio-stream", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "psl" +version = "2.1.208" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bde51f827dca976f8f9a8c91329a3193114dc076b8012a1ee3624f1588c3582" +dependencies = [ + "psl-types", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2 0.6.3", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "quoted_printable" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rapidhash" +version = "4.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +dependencies = [ + "rustversion", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redis-protocol" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdba59219406899220fc4cdfd17a95191ba9c9afb719b5fa5a083d63109a9f1" +dependencies = [ + "bytes", + "bytes-utils", + "cookie-factory", + "crc16", + "log", + "nom 7.1.3", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.7", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "samael" +version = "0.0.20" +source = "git+https://github.com/njaremko/samael.git?rev=f879f1942ec1b34b6d3027ce7e4724ad95d15dfa#f879f1942ec1b34b6d3027ce7e4724ad95d15dfa" +dependencies = [ + "base64 0.22.1", + "bindgen", + "chrono", + "data-encoding", + "derive_builder", + "flate2", + "lazy_static", + "libc", + "libxml", + "openssl", + "openssl-probe 0.1.6", + "openssl-sys", + "pkg-config", + "quick-xml 0.37.5", + "rand 0.9.4", + "serde", + "thiserror 2.0.18", + "url", + "uuid", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secrecy" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" +dependencies = [ + "zeroize", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_nanos" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha-1" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signatory" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31" +dependencies = [ + "pkcs8", + "rand_core 0.6.4", + "signature", + "zeroize", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap 2.14.0", + "ipnetwork", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "ipnetwork", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-websockets" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-sink", + "http", + "httparse", + "rand 0.8.6", + "ring", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tokio-util", + "webpki-roots 0.26.11", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http", + "http-body", + "http-body-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio-stream", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-opentelemetry" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac28f2d093c6c477eaa76b23525478f38de514fa9aeb1285738d4b97a9552fc" +dependencies = [ + "js-sys", + "opentelemetry", + "smallvec", + "tracing", + "tracing-core", + "tracing-log", + "tracing-subscriber", + "web-time", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tryhard" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", + "unic-langid-macros", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + +[[package]] +name = "unic-langid-macros" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5957eb82e346d7add14182a3315a7e298f04e1ba4baac36f7f0dbfedba5fc25" +dependencies = [ + "proc-macro-hack", + "tinystr", + "unic-langid-impl", + "unic-langid-macros-impl", +] + +[[package]] +name = "unic-langid-macros-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1249a628de3ad34b821ecb1001355bca3940bcb2f88558f1a8bd82e977f75b5" +dependencies = [ + "proc-macro-hack", + "quote", + "syn", + "unic-langid-impl", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.7", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zagrosi-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "chrono", + "figment", + "metrics-exporter-prometheus", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry_sdk", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "zagrosi-identity" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "arc-swap", + "argon2", + "async-nats", + "async-trait", + "aws-lc-rs", + "axum", + "base64 0.22.1", + "chrono", + "cookie", + "dashmap", + "figment", + "flate2", + "fluent-templates", + "fred", + "futures", + "hex", + "hickory-resolver", + "http", + "idna", + "lettre", + "metrics", + "mime", + "moka", + "num_cpus", + "openidconnect", + "password-hash", + "psl", + "quick-xml 0.39.4", + "rand_core 0.6.4", + "reqwest", + "samael", + "secrecy", + "serde", + "serde_json", + "sha1", + "sha2", + "sqlx", + "subtle", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "url", + "uuid", + "validator", + "zagrosi-core", + "zeroize", +] + +[[package]] +name = "zagrosi-identity-fuzz" +version = "0.0.0" +dependencies = [ + "libfuzzer-sys", + "serde_json", + "zagrosi-identity", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/crates/zagrosi-identity/fuzz/Cargo.toml b/crates/zagrosi-identity/fuzz/Cargo.toml new file mode 100644 index 0000000..d5f002e --- /dev/null +++ b/crates/zagrosi-identity/fuzz/Cargo.toml @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# +# `cargo-fuzz` workspace member for the secrets shim. Lives outside the +# root workspace (see `[workspace] exclude` in the root `Cargo.toml`) +# because cargo-fuzz pins its own toolchain settings and requires nightly +# Rust for the libFuzzer instrumentation. +# +# The integration-test compose layer wires the +# `cargo +nightly fuzz run -- -max_total_time=60` smoke into +# the `rust / fuzz-smoke` CI job for every target below +# (secrets_open, saml_assertion, scim_filter, oidc_id_token). +# +# `features = ["saml", "fuzzing"]` on the path dep below: `saml` +# brings the SAML ACS parser into scope for `saml_assertion`; +# `fuzzing` gates the `*_for_fuzz` re-exports that wrap +# network-coupled verify paths (currently `oidc_id_token`). The SCIM +# filter parser and SAML `acs::fuzz_entry` are already `pub` and need +# no feature gate. + +[package] +name = "zagrosi-identity-fuzz" +version = "0.0.0" +publish = false +edition = "2024" +rust-version = "1.91" +license = "AGPL-3.0-or-later" + +[package.metadata] +cargo-fuzz = true + +[workspace] + +[dependencies] +libfuzzer-sys = "0.4" +serde_json = "1" + +[dependencies.zagrosi-identity] +path = ".." +features = ["saml", "fuzzing"] + +[[bin]] +name = "secrets_open" +path = "fuzz_targets/secrets_open.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "saml_assertion" +path = "fuzz_targets/saml_assertion.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "scim_filter" +path = "fuzz_targets/scim_filter.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "oidc_id_token" +path = "fuzz_targets/oidc_id_token.rs" +test = false +doc = false +bench = false diff --git a/crates/zagrosi-identity/fuzz/corpus/oidc_id_token/id_token_no_sig.json b/crates/zagrosi-identity/fuzz/corpus/oidc_id_token/id_token_no_sig.json new file mode 100644 index 0000000..002bf4e --- /dev/null +++ b/crates/zagrosi-identity/fuzz/corpus/oidc_id_token/id_token_no_sig.json @@ -0,0 +1 @@ +eyJhbGciOiJub25lIn0.eyJzdWIiOiJhbGljZSJ9. diff --git a/crates/zagrosi-identity/fuzz/corpus/saml_assertion/.gitkeep b/crates/zagrosi-identity/fuzz/corpus/saml_assertion/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/zagrosi-identity/fuzz/corpus/saml_assertion/xsw_a.xml b/crates/zagrosi-identity/fuzz/corpus/saml_assertion/xsw_a.xml new file mode 100644 index 0000000..ece058d --- /dev/null +++ b/crates/zagrosi-identity/fuzz/corpus/saml_assertion/xsw_a.xml @@ -0,0 +1 @@ +attacker diff --git a/crates/zagrosi-identity/fuzz/corpus/scim_filter/filter_invalid_001.txt b/crates/zagrosi-identity/fuzz/corpus/scim_filter/filter_invalid_001.txt new file mode 100644 index 0000000..fefe7bc --- /dev/null +++ b/crates/zagrosi-identity/fuzz/corpus/scim_filter/filter_invalid_001.txt @@ -0,0 +1 @@ +userName eq "unterminated diff --git a/crates/zagrosi-identity/fuzz/corpus/secrets_open/.gitkeep b/crates/zagrosi-identity/fuzz/corpus/secrets_open/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/zagrosi-identity/fuzz/fuzz_targets/oidc_id_token.rs b/crates/zagrosi-identity/fuzz/fuzz_targets/oidc_id_token.rs new file mode 100644 index 0000000..9407230 --- /dev/null +++ b/crates/zagrosi-identity/fuzz/fuzz_targets/oidc_id_token.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// libFuzzer harness for the offline slice of the OIDC ID-token +// validation chain. +// +// Drives [`zagrosi_identity::oidc::verify_id_token_for_fuzz`] which +// exercises the attacker-controlled, network-free portion of +// `OidcClient::exchange_and_verify`: +// +// 1. UTF-8 validation of the raw token bytes. +// 2. Side-band `acr` / `amr` extraction from the JWT body. +// 3. Compact-JWS segmentation + base64url body decode. +// 4. JSON claim deserialisation into `openidconnect`'s +// `CoreIdTokenClaims` (the same type the live verifier yields). +// 5. The explicit `iat`-skew / `azp`-shape post-checks the lib does +// not enforce by default. +// +// The signature-verification + token-endpoint round-trip are NOT in +// scope here (they require a live JWKS + token endpoint); the +// `fuzzing` feature gate exposes the offline entry point precisely so +// this surface is reachable without a network. +// +// # Invariants the harness enforces +// +// - No panic on any input. +// - No network access (the entry point is network-free by +// construction; the harness would hang/fail CI otherwise). +// - No `Ok`-shaped result a caller could mistake for a verified +// token (the entry point returns `()`). +// +// The integration-test compose lights up the `rust / fuzz-smoke` CI +// job with `cargo +nightly fuzz run oidc_id_token -- -max_total_time=60`. +// Locally, install cargo-fuzz (`cargo install cargo-fuzz`) on a +// nightly toolchain and run the same command. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zagrosi_identity::oidc::verify_id_token_for_fuzz; + +fuzz_target!(|data: &[u8]| { + verify_id_token_for_fuzz(data); +}); diff --git a/crates/zagrosi-identity/fuzz/fuzz_targets/saml_assertion.rs b/crates/zagrosi-identity/fuzz/fuzz_targets/saml_assertion.rs new file mode 100644 index 0000000..505386b --- /dev/null +++ b/crates/zagrosi-identity/fuzz/fuzz_targets/saml_assertion.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// libFuzzer harness for the SAML ACS XML pre-flight + parser surface. +// +// Drives [`zagrosi_identity::saml::acs::fuzz_entry`] which exercises: +// +// 1. Base64 decode of the SAMLResponse form field (fail-soft). +// 2. UTF-8 validation. +// 3. DTD / external-entity pre-flight rejection. +// 4. samael's `parse_xml_response_with_mode` libxml2 + xmlsec XML +// decoder + reducer (signature verification disabled by passing +// an empty IdP metadata so the fuzz surface focuses on the XML +// parser hardening, which is the XSW + XXE attack class.) +// +// # Invariants the harness enforces +// +// - No panic on any input. +// - No use-after-free / out-of-bounds (libxml2 wrapper safety). +// - No unbounded allocation. +// +// The integration-test compose lights up the `rust / fuzz-smoke` CI +// job with `cargo +nightly fuzz run saml_assertion -- -max_total_time=60`. +// Locally, install cargo-fuzz (`cargo install cargo-fuzz`) on a +// nightly toolchain and run the same command. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zagrosi_identity::saml::acs::fuzz_entry; + +fuzz_target!(|data: &[u8]| { + fuzz_entry(data); +}); diff --git a/crates/zagrosi-identity/fuzz/fuzz_targets/scim_filter.rs b/crates/zagrosi-identity/fuzz/fuzz_targets/scim_filter.rs new file mode 100644 index 0000000..1254f8a --- /dev/null +++ b/crates/zagrosi-identity/fuzz/fuzz_targets/scim_filter.rs @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! `cargo-fuzz` smoke harness for the SCIM 2.0 filter parser. +//! +//! The CI `rust / fuzz-smoke` job runs this for 60 seconds against +//! arbitrary byte sequences. The parser MUST never panic on +//! adversarial input — every parse failure should surface as +//! `ScimError::InvalidFilter` rather than an unwound stack. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zagrosi_identity::http::scim::filter; + +fuzz_target!(|data: &[u8]| { + if let Ok(s) = std::str::from_utf8(data) { + let _ = filter::parse(s); + } +}); diff --git a/crates/zagrosi-identity/fuzz/fuzz_targets/secrets_open.rs b/crates/zagrosi-identity/fuzz/fuzz_targets/secrets_open.rs new file mode 100644 index 0000000..d658f3a --- /dev/null +++ b/crates/zagrosi-identity/fuzz/fuzz_targets/secrets_open.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// libFuzzer harness for `Secrets::open`. +// +// The target feeds arbitrary bytes as a candidate `Envelope` JSON payload +// under a fixed test key, then asserts: +// +// - `Secrets::open` never panics — the workspace `unwrap_used = deny` and +// `panic = warn` lint posture is verified empirically by the harness. +// - `Secrets::open` never returns `Ok(_)` — the input space has negligible +// probability of matching a valid AEAD authentication tag, so any `Ok` +// indicates a real bug (constant-time check elision, mis-routed key_id, +// etc.). +// +// The integration-test compose lights up the `rust / fuzz-smoke` CI job with +// `cargo +nightly fuzz run secrets_open -- -max_total_time=60`. Locally, +// the same command works once `cargo-fuzz` is installed +// (`cargo install cargo-fuzz`) on a nightly toolchain. + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zagrosi_identity::{Envelope, Secrets}; + +const FUZZ_KEY: [u8; 32] = [0x42; 32]; + +fuzz_target!(|data: &[u8]| { + let secrets = Secrets::from_key(Box::new(FUZZ_KEY)); + if let Ok(envelope) = serde_json::from_slice::(data) { + match secrets.open(&envelope) { + Ok(_) => panic!( + "fuzzer produced an envelope that authenticated under the fixed key", + ), + Err(_) => { + // Any typed error variant is acceptable — the contract is + // "no Ok, no panic". The harness exits with status 0 here + // and libFuzzer continues mutating. + } + } + } +}); diff --git a/crates/zagrosi-identity/migrations/20260508120000_001_roles_and_extensions.sql b/crates/zagrosi-identity/migrations/20260508120000_001_roles_and_extensions.sql new file mode 100644 index 0000000..e39d780 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120000_001_roles_and_extensions.sql @@ -0,0 +1,21 @@ +-- 001 — Roles + extensions. +-- +-- Enables `pgcrypto` for `gen_random_bytes` (used for token-prefix +-- entropy generation in the API-token surface and the service-token surface). UUID v7 IDs are +-- generated app-side via `uuid::Uuid::now_v7()`, so `gen_random_uuid` +-- is intentionally not used. +-- +-- Includes a placeholder no-op block for the upcoming tenant-isolation +-- RLS roles (`zagrosi_app NOBYPASSRLS`, `zagrosi_migrate`). Today this +-- migration must run cleanly on the dev superuser path; the +-- tenant-isolation layer replaces the placeholder with real `CREATE ROLE` +-- statements. + +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + -- Placeholder for the tenant-isolation RLS roles: zagrosi_app, zagrosi_migrate. + -- No-op on the dev superuser path; the tenant-isolation layer lights up real CREATE ROLE. + RAISE NOTICE 'identity: rls roles placeholder (tenant-isolation layer will add zagrosi_app/zagrosi_migrate)'; +END $$; diff --git a/crates/zagrosi-identity/migrations/20260508120100_002_orgs.sql b/crates/zagrosi-identity/migrations/20260508120100_002_orgs.sql new file mode 100644 index 0000000..e5b91df --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120100_002_orgs.sql @@ -0,0 +1,21 @@ +-- 002 — orgs. +-- +-- Tenant root. UUID v7 ID generated app-side. Soft-delete via +-- `deleted_at`; uniqueness on `slug` is a partial unique index that +-- only constrains live rows. + +CREATE TABLE orgs ( + id UUID PRIMARY KEY, + slug TEXT NOT NULL, + display_name TEXT NOT NULL, + primary_domain TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX orgs_slug_unique_live + ON orgs (slug) + WHERE deleted_at IS NULL; + +CREATE INDEX orgs_deleted_at_idx ON orgs (deleted_at); diff --git a/crates/zagrosi-identity/migrations/20260508120200_003_users.sql b/crates/zagrosi-identity/migrations/20260508120200_003_users.sql new file mode 100644 index 0000000..83cdac7 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120200_003_users.sql @@ -0,0 +1,30 @@ +-- 003 — users. +-- +-- Canonical user table. UUID v7 ID generated app-side. Email is stored +-- case-preserving in `email`; `email_lower` is a generated column that +-- always equals `lower(email)` and is the column uniqueness + lookup +-- indices target. `password_hash` is NULLable to support SSO-only +-- accounts. `password_hash_version` tracks the Argon2id profile +-- version. `password_updated_at` is the password-reset revocation +-- invariant consumed by sessions. + +CREATE TABLE users ( + id UUID PRIMARY KEY, + email TEXT NOT NULL, + email_lower TEXT GENERATED ALWAYS AS (lower(email)) STORED, + email_verified_at TIMESTAMPTZ NULL, + display_name TEXT NOT NULL, + password_hash TEXT NULL, + password_updated_at TIMESTAMPTZ NULL, + password_hash_version SMALLINT NOT NULL DEFAULT 1, + mfa_enrolled_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX users_email_lower_unique_live + ON users (email_lower) + WHERE deleted_at IS NULL; + +CREATE INDEX users_deleted_at_idx ON users (deleted_at); diff --git a/crates/zagrosi-identity/migrations/20260508120300_004_user_org_memberships.sql b/crates/zagrosi-identity/migrations/20260508120300_004_user_org_memberships.sql new file mode 100644 index 0000000..b82412c --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120300_004_user_org_memberships.sql @@ -0,0 +1,27 @@ +-- 004 — user_org_memberships. +-- +-- Many-to-many link between users and orgs with a per-membership role +-- placeholder (full RBAC arrives in the tenant-isolation layer). `joined_via` records +-- the auth path that minted the membership; `jit_provisioned_at` is +-- non-null for memberships created by SSO/SCIM JIT flows. +-- +-- FK to users/orgs is declared without ON DELETE CASCADE because the +-- application layer (the persistence layer) handles soft-delete cascade. + +CREATE TABLE user_org_memberships ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id), + org_id UUID NOT NULL REFERENCES orgs (id), + basic_role TEXT NOT NULL DEFAULT 'member', + joined_via TEXT NOT NULL CHECK (joined_via IN ('password','oidc','saml','scim','manual')), + jit_provisioned_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX user_org_memberships_user_org_unique_live + ON user_org_memberships (user_id, org_id) + WHERE deleted_at IS NULL; + +CREATE INDEX user_org_memberships_org_id_idx ON user_org_memberships (org_id); +CREATE INDEX user_org_memberships_user_id_idx ON user_org_memberships (user_id); diff --git a/crates/zagrosi-identity/migrations/20260508120400_005_sessions.sql b/crates/zagrosi-identity/migrations/20260508120400_005_sessions.sql new file mode 100644 index 0000000..df70f20 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120400_005_sessions.sql @@ -0,0 +1,37 @@ +-- 005 — sessions. +-- +-- Browser session cookies (`sid_*`). `token_hash` is SHA-256 of the +-- raw cookie value; the raw value never lands in the database. +-- `version` is a monotonically increasing counter consumed by +-- optimistic locking when the active org switches. +-- `amr` (RFC 8176) and `acr` (RFC 6711 / OIDC Core) record the +-- authentication methods + assurance level for downstream policy. + +CREATE TABLE sessions ( + id UUID PRIMARY KEY, + token_hash BYTEA NOT NULL, + user_id UUID NOT NULL REFERENCES users (id), + org_id UUID NULL REFERENCES orgs (id), + user_agent TEXT NULL, + ip_addr INET NULL, + version BIGINT NOT NULL DEFAULT 1, + amr TEXT[] NOT NULL DEFAULT '{}', + acr TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL, + CONSTRAINT sessions_revoked_after_created + CHECK (revoked_at IS NULL OR revoked_at >= created_at) +); + +CREATE UNIQUE INDEX sessions_token_hash_unique_live + ON sessions (token_hash) + WHERE revoked_at IS NULL AND deleted_at IS NULL; + +CREATE INDEX sessions_user_expires_active_idx + ON sessions (user_id, expires_at) + WHERE revoked_at IS NULL; + +CREATE INDEX sessions_user_created_idx ON sessions (user_id, created_at); diff --git a/crates/zagrosi-identity/migrations/20260508120500_006_api_tokens.sql b/crates/zagrosi-identity/migrations/20260508120500_006_api_tokens.sql new file mode 100644 index 0000000..8cdfd81 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120500_006_api_tokens.sql @@ -0,0 +1,26 @@ +-- 006 — api_tokens. +-- +-- User-issued personal access tokens (`pat_*`). `token_hash` is +-- SHA-256(token); the prefix is validated app-side. `scopes` is a +-- TEXT array of authorisation scope strings consumed by future +-- policy code. `last_used_*` columns are best-effort observability. + +CREATE TABLE api_tokens ( + id UUID PRIMARY KEY, + token_hash BYTEA NOT NULL, + user_id UUID NOT NULL REFERENCES users (id), + org_id UUID NOT NULL REFERENCES orgs (id), + display_name TEXT NOT NULL, + scopes TEXT[] NOT NULL DEFAULT '{}', + last_used_at TIMESTAMPTZ NULL, + last_used_ip INET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX api_tokens_token_hash_unique_live + ON api_tokens (token_hash) + WHERE revoked_at IS NULL; + +CREATE INDEX api_tokens_user_org_idx ON api_tokens (user_id, org_id); diff --git a/crates/zagrosi-identity/migrations/20260508120600_007_password_resets_and_email_verifications.sql b/crates/zagrosi-identity/migrations/20260508120600_007_password_resets_and_email_verifications.sql new file mode 100644 index 0000000..4087a27 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120600_007_password_resets_and_email_verifications.sql @@ -0,0 +1,45 @@ +-- 007 — password_resets + email_verifications (paired migration). +-- +-- Both tables follow the same shape: a token-hash row that is +-- single-use (enforced by a partial unique index on `token_hash` +-- where `used_at IS NULL`). Once consumed, the row stays for audit +-- but no longer occupies the unique slot. +-- +-- `password_resets` token prefix `rst_*`; `email_verifications` +-- token prefix `vrf_*`. Prefix validation is enforced app-side +-- (the persistence layer + the password-auth surface). + +CREATE TABLE password_resets ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id), + token_hash BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + CONSTRAINT password_resets_expires_after_created + CHECK (expires_at > created_at) +); + +CREATE UNIQUE INDEX password_resets_token_hash_unique_unused + ON password_resets (token_hash) + WHERE used_at IS NULL; + +CREATE INDEX password_resets_user_id_idx ON password_resets (user_id); + +CREATE TABLE email_verifications ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users (id), + email TEXT NOT NULL, + token_hash BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + CONSTRAINT email_verifications_expires_after_created + CHECK (expires_at > created_at) +); + +CREATE UNIQUE INDEX email_verifications_token_hash_unique_unused + ON email_verifications (token_hash) + WHERE used_at IS NULL; + +CREATE INDEX email_verifications_user_id_idx ON email_verifications (user_id); diff --git a/crates/zagrosi-identity/migrations/20260508120700_008_org_idps_and_org_idp_domains.sql b/crates/zagrosi-identity/migrations/20260508120700_008_org_idps_and_org_idp_domains.sql new file mode 100644 index 0000000..c240c15 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120700_008_org_idps_and_org_idp_domains.sql @@ -0,0 +1,52 @@ +-- 008 — org_idps + org_idp_domains (paired migration). +-- +-- `org_idps` is the per-org OIDC/SAML IdP configuration. `config` is +-- a JSONB blob versioned by `config_version` so the schema can evolve +-- (`OidcConfigV1` / `SamlConfigV1` ports live in zagrosi-core). +-- `is_default` flags the IdP that handles unrouted traffic for the +-- org; `enabled` is a kill-switch. +-- +-- `org_idp_domains` carries the verified-domain → IdP mapping that +-- the multi-IdP routing layer consults when a user signs in. The +-- partial unique index on `(lower(domain), org_idp_id)` only counts +-- *verified* live rows, so unverified placeholders never block a +-- different IdP from claiming the same domain. + +CREATE TABLE org_idps ( + id UUID PRIMARY KEY, + org_id UUID NOT NULL REFERENCES orgs (id), + protocol TEXT NOT NULL CHECK (protocol IN ('oidc','saml')), + display_name TEXT NOT NULL, + config JSONB NOT NULL, + config_version SMALLINT NOT NULL DEFAULT 1, + jit_provisioning BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX org_idps_org_id_idx ON org_idps (org_id); + +CREATE TABLE org_idp_domains ( + id UUID PRIMARY KEY, + org_idp_id UUID NOT NULL REFERENCES org_idps (id), + domain TEXT NOT NULL, + verified_at TIMESTAMPTZ NULL, + last_verified_via TEXT NULL, + priority INT NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX org_idp_domains_lower_domain_unique_verified + ON org_idp_domains (lower(domain), org_idp_id) + WHERE verified_at IS NOT NULL AND deleted_at IS NULL; + +-- Routing lookup (the multi-IdP routing layer) only ever considers verified, non-soft-deleted +-- rows; making the index partial keeps it small and aligned with the +-- partial-uniqueness above. +CREATE INDEX org_idp_domains_routing_idx + ON org_idp_domains (lower(domain), priority) + WHERE verified_at IS NOT NULL AND deleted_at IS NULL; diff --git a/crates/zagrosi-identity/migrations/20260508120800_009_scim_tokens.sql b/crates/zagrosi-identity/migrations/20260508120800_009_scim_tokens.sql new file mode 100644 index 0000000..681eaf6 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120800_009_scim_tokens.sql @@ -0,0 +1,29 @@ +-- 009 — scim_tokens. +-- +-- Per-org SCIM bearer tokens (`scim_*`). `scopes` is the SCIM scope +-- set (`users:read`, `users:write`, `groups:read`, `groups:write`). +-- `allowed_cidrs` restricts SCIM connections by source IP; an empty +-- array means unrestricted. `tolerant_mode` toggles workarounds for +-- Entra ID PATCH deviations (the SCIM server). + +CREATE TABLE scim_tokens ( + id UUID PRIMARY KEY, + org_id UUID NOT NULL REFERENCES orgs (id), + display_name TEXT NOT NULL, + token_hash BYTEA NOT NULL, + scopes TEXT[] NOT NULL DEFAULT ARRAY['users:read','users:write','groups:read','groups:write']::TEXT[], + allowed_cidrs INET[] NOT NULL DEFAULT '{}', + tolerant_mode BOOLEAN NOT NULL DEFAULT FALSE, + last_used_at TIMESTAMPTZ NULL, + last_used_ip INET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX scim_tokens_token_hash_unique_live + ON scim_tokens (token_hash) + WHERE revoked_at IS NULL AND deleted_at IS NULL; + +CREATE INDEX scim_tokens_org_id_idx ON scim_tokens (org_id); diff --git a/crates/zagrosi-identity/migrations/20260508120900_010_email_outbox.sql b/crates/zagrosi-identity/migrations/20260508120900_010_email_outbox.sql new file mode 100644 index 0000000..92ebb21 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508120900_010_email_outbox.sql @@ -0,0 +1,36 @@ +-- 010 — email_outbox. +-- +-- Durable outgoing-mail queue consumed by the email-outbox worker. `org_id` is +-- nullable for system mail (e.g. `account_already_exists` to a known +-- email when sign-up should leak nothing). `idempotency_key` enforces +-- exactly-once enqueue; the worker dequeue path uses +-- `(state, next_attempt_at)` with `FOR UPDATE SKIP LOCKED`. + +CREATE TABLE email_outbox ( + id UUID PRIMARY KEY, + org_id UUID NULL REFERENCES orgs (id), + to_address TEXT NOT NULL, + from_address TEXT NOT NULL, + subject TEXT NOT NULL, + body_text TEXT NOT NULL, + body_html TEXT NULL, + template_key TEXT NOT NULL, + locale TEXT NOT NULL DEFAULT 'en', + idempotency_key TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('queued','sending','sent','failed','dead')), + attempts INT NOT NULL DEFAULT 0, + next_attempt_at TIMESTAMPTZ NULL, + last_error TEXT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + sent_at TIMESTAMPTZ NULL +); + +-- Per-tenant idempotency. `NULLS NOT DISTINCT` lets system mail +-- (org_id NULL) collapse into a single global slot per key, while +-- tenant mail dedupes within each org. +CREATE UNIQUE INDEX email_outbox_org_idempotency_unique + ON email_outbox (org_id, idempotency_key) + NULLS NOT DISTINCT; + +CREATE INDEX email_outbox_dispatch_idx + ON email_outbox (state, next_attempt_at); diff --git a/crates/zagrosi-identity/migrations/20260508121000_011_oidc_pending_auth.sql b/crates/zagrosi-identity/migrations/20260508121000_011_oidc_pending_auth.sql new file mode 100644 index 0000000..3584a90 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121000_011_oidc_pending_auth.sql @@ -0,0 +1,36 @@ +-- 011 — oidc_pending_auth. +-- +-- Pending OIDC authorisation requests waiting for the IdP callback. +-- `state` and `nonce` carry 128-bit entropy values issued by the +-- gateway; `verifier_hash` is SHA-256 of the PKCE code verifier (the +-- raw verifier never persists). `csrf_cookie_value` is bound to the +-- `__Host-zagrosi_oidc_csrf` cookie. The partial unique on `state` +-- (where `used_at IS NULL`) enforces single-use redemption. + +-- Every secret column on this table is stored as a SHA-256 hash, mirroring +-- the BYTEA-hash pattern used across sessions / api_tokens / scim_tokens / +-- oidc_refresh_tokens / password_resets / email_verifications. The OIDC +-- gateway computes the hashes when issuing the auth-request and again on +-- the callback, comparing only hashes. Raw `state`, `nonce`, and the CSRF +-- cookie value never persist. +CREATE TABLE oidc_pending_auth ( + id UUID PRIMARY KEY, + org_idp_id UUID NOT NULL REFERENCES org_idps (id), + state_hash BYTEA NOT NULL, + nonce_hash BYTEA NOT NULL, + verifier_hash BYTEA NOT NULL, + csrf_cookie_hash BYTEA NOT NULL, + redirect_uri TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ NULL, + CONSTRAINT oidc_pending_auth_expires_after_created + CHECK (expires_at > created_at) +); + +CREATE UNIQUE INDEX oidc_pending_auth_state_hash_unique_unused + ON oidc_pending_auth (state_hash) + WHERE used_at IS NULL; + +CREATE INDEX oidc_pending_auth_expires_at_idx + ON oidc_pending_auth (expires_at); diff --git a/crates/zagrosi-identity/migrations/20260508121100_012_oidc_refresh_tokens.sql b/crates/zagrosi-identity/migrations/20260508121100_012_oidc_refresh_tokens.sql new file mode 100644 index 0000000..7b2e5d7 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121100_012_oidc_refresh_tokens.sql @@ -0,0 +1,22 @@ +-- 012 — oidc_refresh_tokens. +-- +-- Refresh-token chain for OIDC sessions. `prev_id` self-references +-- the previous refresh token in the chain so replay-detection can +-- revoke the entire chain when a re-use is observed (the OIDC client). +-- `token_hash` is SHA-256 of the raw refresh-token value. + +CREATE TABLE oidc_refresh_tokens ( + id UUID PRIMARY KEY, + session_id UUID NOT NULL REFERENCES sessions (id), + token_hash BYTEA NOT NULL, + prev_id UUID NULL REFERENCES oidc_refresh_tokens (id), + issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + used_at TIMESTAMPTZ NULL, + revoked_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX oidc_refresh_tokens_token_hash_unique_live + ON oidc_refresh_tokens (token_hash) + WHERE revoked_at IS NULL; + +CREATE INDEX oidc_refresh_tokens_session_idx ON oidc_refresh_tokens (session_id); diff --git a/crates/zagrosi-identity/migrations/20260508121200_013_saml_assertion_replay.sql b/crates/zagrosi-identity/migrations/20260508121200_013_saml_assertion_replay.sql new file mode 100644 index 0000000..c08832a --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121200_013_saml_assertion_replay.sql @@ -0,0 +1,18 @@ +-- 013 — saml_assertion_replay. +-- +-- SAML assertion replay-protection ledger. Composite PK on +-- `(org_idp_id, assertion_id)` IS the replay-rejection mechanism: a +-- duplicate insert raises a unique violation, which the SAML SP +-- translates into an authentication failure. Cleanup +-- sweeps prune rows past `not_on_or_after`. + +CREATE TABLE saml_assertion_replay ( + org_idp_id UUID NOT NULL REFERENCES org_idps (id), + assertion_id TEXT NOT NULL, + not_on_or_after TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (org_idp_id, assertion_id) +); + +CREATE INDEX saml_assertion_replay_not_on_or_after_idx + ON saml_assertion_replay (not_on_or_after); diff --git a/crates/zagrosi-identity/migrations/20260508121300_014_federated_identities.sql b/crates/zagrosi-identity/migrations/20260508121300_014_federated_identities.sql new file mode 100644 index 0000000..58224d9 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121300_014_federated_identities.sql @@ -0,0 +1,25 @@ +-- 014 — federated_identities. +-- +-- Canonical SSO anchor: `(protocol, issuer_or_entity_id, +-- subject_or_nameid)` is the unique key. Email is intentionally not +-- an SSO key. `user_id` is nullable to support tombstoning when the +-- linked user is soft-deleted (the persistence-layer cascade rules); the +-- tombstone still occupies the unique slot to prevent silent +-- re-attachment without an explicit admin merge. + +CREATE TABLE federated_identities ( + id UUID PRIMARY KEY, + protocol TEXT NOT NULL CHECK (protocol IN ('oidc','saml')), + issuer_or_entity_id TEXT NOT NULL, + subject_or_nameid TEXT NOT NULL, + org_idp_id UUID NOT NULL REFERENCES org_idps (id), + user_id UUID NULL REFERENCES users (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_login_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX federated_identities_anchor_unique + ON federated_identities (protocol, issuer_or_entity_id, subject_or_nameid); + +CREATE INDEX federated_identities_user_id_idx ON federated_identities (user_id); +CREATE INDEX federated_identities_org_idp_id_idx ON federated_identities (org_idp_id); diff --git a/crates/zagrosi-identity/migrations/20260508121400_015_failed_signin_aggregates.sql b/crates/zagrosi-identity/migrations/20260508121400_015_failed_signin_aggregates.sql new file mode 100644 index 0000000..2cbb333 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121400_015_failed_signin_aggregates.sql @@ -0,0 +1,41 @@ +-- 015 — failed_signin_aggregates. +-- +-- Per-window aggregate of failed sign-in attempts. Used by +-- the rate-limit module for backoff + by audit. `user_id` is +-- nullable because the unknown-email path (no user matches) still +-- aggregates by IP. The `(user_id, window_start)` UNIQUE uses +-- `NULLS NOT DISTINCT` (PG 17+) so the upsert key works for the +-- unknown-email path; the `(ip, window_start)` UNIQUE supports +-- IP-pivot audits. + +-- `org_id` is nullable because the unknown-email path (no matching user) +-- aggregates by IP only and has no tenant anchor. The tenant-isolation layer's RLS will use +-- `(org_id IS NULL OR org_id = current_setting('app.org_id'))` so the +-- IP-only path remains visible to system-level reads while tenant-scoped +-- rows stay isolated. +-- +-- The `(ip, window_start)` index is *not* unique: shared NAT / CGN traffic +-- regularly routes many users through one IPv4 address inside one minute, +-- so a unique constraint there would reject genuine concurrent failures +-- from different users. +CREATE TABLE failed_signin_aggregates ( + id UUID PRIMARY KEY, + org_id UUID NULL REFERENCES orgs (id), + user_id UUID NULL REFERENCES users (id), + ip INET NOT NULL, + window_start TIMESTAMPTZ NOT NULL, + count INT NOT NULL DEFAULT 0, + first_attempt_at TIMESTAMPTZ NOT NULL, + last_attempt_at TIMESTAMPTZ NOT NULL +); + +CREATE UNIQUE INDEX failed_signin_aggregates_user_window_unique + ON failed_signin_aggregates (user_id, window_start) + NULLS NOT DISTINCT; + +CREATE INDEX failed_signin_aggregates_ip_window_idx + ON failed_signin_aggregates (ip, window_start); + +CREATE INDEX failed_signin_aggregates_org_id_idx + ON failed_signin_aggregates (org_id) + WHERE org_id IS NOT NULL; diff --git a/crates/zagrosi-identity/migrations/20260508121500_016_service_tokens.sql b/crates/zagrosi-identity/migrations/20260508121500_016_service_tokens.sql new file mode 100644 index 0000000..55ba0c6 --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260508121500_016_service_tokens.sql @@ -0,0 +1,29 @@ +-- 016 — service_tokens. +-- +-- Internal service-to-service bearer tokens (`svc_*`) consumed by +-- the service-token surface. `service_name` identifies the caller (e.g. +-- `email-worker`); `allowed_subjects` is a NATS subject allowlist +-- (e.g. `identity.>`, `email.outbox.queue`) the worker is permitted +-- to publish to / subscribe on. +-- +-- This table is intentionally org-agnostic: service tokens authorise +-- platform-wide internal callers. The tenant-isolation layer's RLS must whitelist this +-- table for the service / migration roles rather than gating it by +-- tenant, since there is no `org_id` to scope on. + +CREATE TABLE service_tokens ( + id UUID PRIMARY KEY, + service_name TEXT NOT NULL, + token_hash BYTEA NOT NULL, + allowed_subjects TEXT[] NOT NULL DEFAULT '{}', + display_name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + revoked_at TIMESTAMPTZ NULL, + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX service_tokens_token_hash_unique_live + ON service_tokens (token_hash) + WHERE revoked_at IS NULL AND deleted_at IS NULL; + +CREATE INDEX service_tokens_service_name_idx ON service_tokens (service_name); diff --git a/crates/zagrosi-identity/migrations/20260509191612_017_saml_pending_auth.sql b/crates/zagrosi-identity/migrations/20260509191612_017_saml_pending_auth.sql new file mode 100644 index 0000000..4bb4fce --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260509191612_017_saml_pending_auth.sql @@ -0,0 +1,33 @@ +-- 017 — saml_pending_auth. +-- +-- SP-initiated AuthnRequest tracking. Mirrors the oidc_pending_auth +-- pattern from migration 011: persists the AuthnRequest ID and a +-- 256-bit RelayState alongside the resolving org_idp so the ACS +-- handler can correlate the IdP response to the original start +-- request. The partial unique index on `(org_idp_id, request_id)` +-- WHERE used_at IS NULL gives the ACS handler the same single-use +-- guarantee oidc_pending_auth provides — re-presentation of a used +-- AuthnRequest ID is rejected before the ACS strict-order pipeline +-- even fires. +-- +-- The 10-minute TTL is enforced by the SAML SP at use time +-- (`expires_at < now() => reject`). A lightweight cleanup sweep +-- prunes both expired and used rows on a periodic worker. The +-- expiry index supports the sweep without scanning the whole table. + +CREATE TABLE saml_pending_auth ( + id UUID PRIMARY KEY, + request_id TEXT NOT NULL, + relay_state TEXT NOT NULL, + org_idp_id UUID NOT NULL REFERENCES org_idps (id), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + used_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX saml_pending_auth_request_id_unused + ON saml_pending_auth (org_idp_id, request_id) + WHERE used_at IS NULL; + +CREATE INDEX saml_pending_auth_expires_at + ON saml_pending_auth (expires_at); diff --git a/crates/zagrosi-identity/migrations/20260510000100_018_users_scim_columns.sql b/crates/zagrosi-identity/migrations/20260510000100_018_users_scim_columns.sql new file mode 100644 index 0000000..708cf8a --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260510000100_018_users_scim_columns.sql @@ -0,0 +1,30 @@ +-- 018 — users SCIM columns. +-- +-- SCIM 2.0 (RFC 7643) requires every `User` resource to expose +-- `active` (boolean), `externalId` (opaque IdP-assigned identifier), +-- and `meta.version` (an opaque ETag). The first two map onto new +-- columns on `users`; `meta.version` is derived from `updated_at` +-- combined with a per-row monotonic `row_version` counter that +-- increments on every PATCH/PUT (the row's logical mutation count +-- is independent of `updated_at`'s wall-clock value, so the ETag +-- distinguishes back-to-back writes that land within the same +-- timestamp granularity). +-- +-- All three columns are nullable / defaulted so the migration is +-- safe against existing rows. `active` defaults to TRUE so legacy +-- rows behave as before. `external_id` is unique per `(org_id, +-- external_id)` only when both `external_id IS NOT NULL` and the +-- user is live (`deleted_at IS NULL`) AND has a matching live +-- membership in the org — uniqueness is therefore enforced via a +-- partial unique index on `user_org_memberships.scim_external_id` +-- (added in migration 019 alongside the SCIM external-id column on +-- the membership row), not on `users` directly. `users.external_id` +-- here is the SCIM v0.1 placeholder for the *primary* IdP-assigned +-- identifier; per-org SCIM external IDs live on the membership row. + +ALTER TABLE users + ADD COLUMN active BOOLEAN NOT NULL DEFAULT TRUE, + ADD COLUMN external_id TEXT NULL, + ADD COLUMN row_version BIGINT NOT NULL DEFAULT 0; + +CREATE INDEX users_active_idx ON users (active) WHERE deleted_at IS NULL; diff --git a/crates/zagrosi-identity/migrations/20260510000200_019_scim_groups.sql b/crates/zagrosi-identity/migrations/20260510000200_019_scim_groups.sql new file mode 100644 index 0000000..ea3be1a --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260510000200_019_scim_groups.sql @@ -0,0 +1,65 @@ +-- 019 — SCIM groups + group memberships + per-membership external_id. +-- +-- SCIM 2.0 (RFC 7643 §4.2) `Group` resources land here. Groups are +-- per-org (multi-tenant): every query is hard-anchored on `org_id` +-- via the `OrgScoped` wrapper at the repo layer, mirroring +-- the SCIM tenant-isolation invariant set in section-05. +-- +-- `display_name` is the SCIM `displayName`. `external_id` is the +-- SCIM `externalId` (IdP-assigned opaque identifier; unique per +-- `(org_id, external_id)` for live rows so cross-org IdP imports +-- can reuse the same external id without colliding). +-- `row_version` is the per-row monotonic mutation counter consumed +-- by the SCIM ETag derivation (`http::scim::etag::meta_version`). +-- +-- `group_memberships` is the many-to-many join. The pair +-- `(group_id, user_id)` is unique while live. Soft-delete tombstones +-- the row instead of deleting it so audit / forensic queries can +-- walk historical membership. +-- +-- `user_org_memberships.scim_external_id` adds an optional per-membership +-- external id used by SCIM Users surface to disambiguate when the same +-- underlying `users` row is provisioned from multiple IdPs. + +CREATE TABLE groups ( + id UUID PRIMARY KEY, + org_id UUID NOT NULL REFERENCES orgs (id), + display_name TEXT NOT NULL, + external_id TEXT NULL, + row_version BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE INDEX groups_org_id_idx ON groups (org_id) WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX groups_org_display_name_unique_live + ON groups (org_id, lower(display_name)) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX groups_org_external_id_unique_live + ON groups (org_id, external_id) + WHERE deleted_at IS NULL AND external_id IS NOT NULL; + +CREATE TABLE group_memberships ( + id UUID PRIMARY KEY, + group_id UUID NOT NULL REFERENCES groups (id), + user_id UUID NOT NULL REFERENCES users (id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ NULL +); + +CREATE UNIQUE INDEX group_memberships_group_user_unique_live + ON group_memberships (group_id, user_id) + WHERE deleted_at IS NULL; + +CREATE INDEX group_memberships_user_id_idx + ON group_memberships (user_id) WHERE deleted_at IS NULL; + +ALTER TABLE user_org_memberships + ADD COLUMN scim_external_id TEXT NULL; + +CREATE UNIQUE INDEX user_org_memberships_org_scim_external_unique_live + ON user_org_memberships (org_id, scim_external_id) + WHERE deleted_at IS NULL AND scim_external_id IS NOT NULL; diff --git a/crates/zagrosi-identity/migrations/20260510000300_020_org_idp_domains_challenge_token.sql b/crates/zagrosi-identity/migrations/20260510000300_020_org_idp_domains_challenge_token.sql new file mode 100644 index 0000000..318e63c --- /dev/null +++ b/crates/zagrosi-identity/migrations/20260510000300_020_org_idp_domains_challenge_token.sql @@ -0,0 +1,16 @@ +-- 020 — org_idp_domains.challenge_token (section-13 domain-ownership flow). +-- +-- The multi-IdP routing layer issues a per-domain DNS TXT challenge +-- (`vrf_<43-char-base64url>`) when an admin claims a domain. The +-- token is published as `_zagrosi-verify. IN TXT ""` +-- and matched against the value the verify endpoint resolves through +-- the dual-resolver DNSSEC path (1.1.1.1 + 9.9.9.9). +-- +-- The column is `NOT NULL DEFAULT ''` so existing pre-section-13 rows +-- (claimed under section-08's earlier scaffolding without a TXT +-- challenge) keep loading. The application layer always writes a +-- real `vrf_*` token at insert time; the empty-string default is the +-- migration-only escape hatch and is rejected by the verify endpoint. + +ALTER TABLE org_idp_domains + ADD COLUMN challenge_token TEXT NOT NULL DEFAULT ''; diff --git a/crates/zagrosi-identity/src/api_tokens/cache.rs b/crates/zagrosi-identity/src/api_tokens/cache.rs new file mode 100644 index 0000000..dc373e4 --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/cache.rs @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! In-process LRU cache for the personal-access-token resolver. +//! +//! Mirrors the [`crate::session::SessionCache`] shape but stores +//! [`CachedApiToken`] keyed by [`TokenHash`] rather than session +//! entries. The two caches are deliberately separate so a session +//! eviction cannot touch a PAT entry and vice-versa, and so the +//! healthy / fail-closed TTL flips can move at independent cadence +//! per token class. +//! +//! ## Reverse index +//! +//! `by_token_id` resolves a token-id-keyed eviction (administrative +//! revocation, future NATS broadcast) back to the primary +//! [`TokenHash`] in O(1). The moka eviction listener mirrors removal +//! into this index so the reverse lookup never accumulates stale +//! entries. +//! +//! ## TTL flip +//! +//! [`ApiTokenCache::rebuild_with_ttl`] swaps the underlying moka +//! cache atomically; in-flight reads land on the pre-swap cache +//! while subsequent reads see the post-swap one. The reverse index +//! is cleared at the same time because every entry it pointed at is +//! unreachable through the new cache. +//! +//! ## Stale-write guard (revocation generation counter) +//! +//! Concurrent revoke + resolve can leave a stale cache entry: the +//! resolver reads a live row, the revoker bumps `revoked_at` and +//! evicts a (possibly absent) cache slot, then the resolver inserts +//! a `CachedApiToken { revoked_at: None }` snapshot. Subsequent +//! cache hits would serve the stale entry until TTL expiry. +//! +//! [`ApiTokenCache::current_generation`] returns a per-token-id +//! counter that [`ApiTokenCache::evict_by_token_id`] increments on +//! every revoke. Resolvers capture the generation BEFORE the DB +//! read and call [`ApiTokenCache::insert_with_guard`]; the insert +//! is dropped if the live generation no longer matches the snapshot. +//! The caller still serves the in-flight request because the row +//! was observed live at read time, but no stale entry is primed for +//! future hits. + +use arc_swap::ArcSwap; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use moka::future::Cache; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; +use uuid::Uuid; + +use crate::domain::token_format::TokenHash; + +/// Cached fast-path entry. Captures every field +/// [`super::resolver::ApiTokenResolver`] needs to satisfy a resolve +/// without a DB touch. +#[derive(Debug, Clone)] +pub struct CachedApiToken { + /// Token row primary key. + pub token_id: Uuid, + /// Owning user. + pub user_id: Uuid, + /// Owning org. + pub org_id: Uuid, + /// Persisted scope list. + pub scopes: Vec, + /// Optional hard expiry. Resolver re-checks `> now()` on every + /// cache hit because the cached value may have aged past expiry + /// since insertion. + pub expires_at: Option>, + /// Soft revocation timestamp. Resolver returns + /// `AuthError::Revoked` when this is `Some(_)`. + pub revoked_at: Option>, + /// Issued-at timestamp; populates the resolved + /// `AuthContext::issued_at`. + pub created_at: DateTime, +} + +/// Two-tier cache (primary + reverse-lookup) plus the active TTL. +/// +/// Cheap to clone; every field wraps an `Arc` internally. +#[derive(Clone)] +pub struct ApiTokenCache { + inner: Arc, +} + +struct ApiTokenCacheInner { + entries: ArcSwap>, + by_token_id: Arc>, + /// Per-token revocation generation counter. Incremented by + /// every [`ApiTokenCache::evict_by_token_id`] call so an + /// in-flight resolve that snapshotted the prior value cannot + /// prime a stale cache entry after revocation. + revocations: Arc>, + capacity: u64, + current_ttl_secs: AtomicU64, +} + +impl ApiTokenCache { + /// Build a cache sized to `capacity` with the initial TTL `ttl`. + /// Eviction listener mirrors removal into the reverse-lookup + /// index so it cannot accumulate stale entries. + #[must_use] + pub fn new(capacity: u64, ttl: Duration) -> Self { + let by_token_id: Arc> = Arc::new(DashMap::new()); + let revocations: Arc> = Arc::new(DashMap::new()); + let cache = build_cache(capacity, ttl, by_token_id.clone()); + let inner = Arc::new(ApiTokenCacheInner { + entries: ArcSwap::new(Arc::new(cache)), + by_token_id, + revocations, + capacity, + current_ttl_secs: AtomicU64::new(ttl.as_secs()), + }); + Self { inner } + } + + /// Snapshot the current revocation generation for `token_id`. + /// + /// Resolvers call this BEFORE the DB read so a subsequent + /// [`Self::evict_by_token_id`] (driven by a concurrent revoke) + /// will bump the counter past the snapshot, causing the + /// follow-up [`Self::insert_with_guard`] to drop the would-be + /// stale entry. + #[must_use] + pub fn current_generation(&self, token_id: Uuid) -> u64 { + self.inner + .revocations + .get(&token_id) + .map_or(0, |g| *g.value()) + } + + /// Currently active TTL in seconds. The health-tick task reads + /// this to detect when a flip is required. + #[must_use] + pub fn ttl_secs(&self) -> u64 { + self.inner.current_ttl_secs.load(Ordering::Relaxed) + } + + /// Atomically swap the underlying moka cache for one built with + /// `ttl`. Idempotent: if the requested TTL already matches the + /// live one this is a no-op. Clears the reverse-lookup index + /// because the previous cache's entries are unreachable through + /// the new cache. + pub fn rebuild_with_ttl(&self, ttl: Duration) { + let new_secs = ttl.as_secs(); + if self + .inner + .current_ttl_secs + .swap(new_secs, Ordering::Relaxed) + == new_secs + { + return; + } + let new_cache = build_cache(self.inner.capacity, ttl, self.inner.by_token_id.clone()); + self.inner.by_token_id.clear(); + self.inner.entries.store(Arc::new(new_cache)); + } + + /// Probe the cache. Returns `Some(...)` on hit; `None` on miss. + pub async fn get(&self, hash: &TokenHash) -> Option { + let cache = self.inner.entries.load_full(); + cache.get(hash).await + } + + /// Insert (or refresh) a cache entry without the stale-write + /// guard. Use [`Self::insert_with_guard`] from any code path + /// where a concurrent revoke could race the insert. + pub async fn insert(&self, hash: TokenHash, value: CachedApiToken) { + let cache = self.inner.entries.load_full(); + let token_id = value.token_id; + cache.insert(hash, value).await; + self.inner.by_token_id.insert(token_id, hash); + } + + /// Insert a cache entry only when the revocation generation + /// captured by the caller still matches the live counter. + /// + /// Returns `true` when the entry landed; `false` when a + /// concurrent revoke bumped the generation past `snapshot`, + /// causing the entry to be dropped to prevent stale cache + /// reads. Callers MUST capture `snapshot` via + /// [`Self::current_generation`] BEFORE the DB read that + /// produced `value`. + pub async fn insert_with_guard( + &self, + hash: TokenHash, + value: CachedApiToken, + snapshot: u64, + ) -> bool { + if self.current_generation(value.token_id) != snapshot { + return false; + } + self.insert(hash, value).await; + true + } + + /// Evict by token-id. Returns `true` if a matching reverse-index + /// entry existed at call time. + /// + /// ALWAYS bumps the per-token revocation-generation counter, + /// even when no reverse-index entry was found, so a concurrent + /// resolve that has already snapshotted the prior generation + /// and is about to insert a now-stale entry will be rejected by + /// [`Self::insert_with_guard`]. + pub async fn evict_by_token_id(&self, token_id: Uuid) -> bool { + self.bump_generation(token_id); + let Some(hash) = self.inner.by_token_id.get(&token_id).map(|r| *r.value()) else { + return false; + }; + let cache = self.inner.entries.load_full(); + cache.invalidate(&hash).await; + self.inner + .by_token_id + .remove_if(&token_id, |_, h| *h == hash); + true + } + + /// Increment the per-token revocation generation. Public so the + /// service layer can flag "this token will be revoked" before + /// the DB UPDATE lands, closing the race where the UPDATE + /// returns success but the resolver's snapshot was taken + /// between the read and the UPDATE. + pub fn bump_generation(&self, token_id: Uuid) { + self.inner + .revocations + .entry(token_id) + .and_modify(|g| *g = g.saturating_add(1)) + .or_insert(1); + } + + /// Evict every entry. Used during a healthy → fail-closed mode + /// flip when the caller wants to drain in-place rather than + /// rebuild the cache. + pub fn invalidate_all(&self) { + let cache = self.inner.entries.load_full(); + cache.invalidate_all(); + self.inner.by_token_id.clear(); + } + + /// Live entry count. + /// + /// Used by integration tests + the admin observability surface + /// to surface cache pressure. The number is approximate (moka + /// reports the size after pending eviction work has settled). + #[must_use] + pub fn entry_count(&self) -> u64 { + let cache = self.inner.entries.load_full(); + cache.entry_count() + } +} + +fn build_cache( + capacity: u64, + ttl: Duration, + by_token_id: Arc>, +) -> Cache { + Cache::builder() + .max_capacity(capacity) + .time_to_live(ttl) + .async_eviction_listener(move |_key: Arc, value: CachedApiToken, _cause| { + let by_token_id = by_token_id.clone(); + Box::pin(async move { + by_token_id.remove(&value.token_id); + }) + }) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn fixture_token(byte: u8) -> (TokenHash, CachedApiToken) { + let hash = TokenHash([byte; 32]); + let value = CachedApiToken { + token_id: Uuid::from_bytes([byte; 16]), + user_id: Uuid::from_bytes([0xAA; 16]), + org_id: Uuid::from_bytes([0xBB; 16]), + scopes: vec!["tokens:read".to_string()], + expires_at: Some(Utc.with_ymd_and_hms(2026, 12, 31, 23, 59, 59).unwrap()), + revoked_at: None, + created_at: Utc.with_ymd_and_hms(2026, 5, 1, 0, 0, 0).unwrap(), + }; + (hash, value) + } + + #[tokio::test] + async fn insert_then_get_round_trips() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + let (hash, value) = fixture_token(1); + cache.insert(hash, value.clone()).await; + let got = cache.get(&hash).await.expect("hit"); + assert_eq!(got.token_id, value.token_id); + assert_eq!(got.scopes, vec!["tokens:read".to_string()]); + } + + #[tokio::test] + async fn evict_by_token_id_removes_primary_entry() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + let (hash, value) = fixture_token(2); + cache.insert(hash, value.clone()).await; + let removed = cache.evict_by_token_id(value.token_id).await; + assert!(removed); + assert!(cache.get(&hash).await.is_none()); + } + + #[tokio::test] + async fn invalidate_all_clears_both_tiers() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + let (hash_a, value_a) = fixture_token(1); + let (hash_b, value_b) = fixture_token(2); + cache.insert(hash_a, value_a).await; + cache.insert(hash_b, value_b).await; + cache.invalidate_all(); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(cache.get(&hash_a).await.is_none()); + assert!(cache.get(&hash_b).await.is_none()); + } + + #[test] + fn ttl_round_trips_through_atomic() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + assert_eq!(cache.ttl_secs(), 30); + cache.rebuild_with_ttl(Duration::from_secs(1)); + assert_eq!(cache.ttl_secs(), 1); + } + + #[tokio::test] + async fn rebuild_with_ttl_clears_existing_entries() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + let (hash, value) = fixture_token(3); + cache.insert(hash, value.clone()).await; + assert!(cache.get(&hash).await.is_some()); + cache.rebuild_with_ttl(Duration::from_secs(1)); + assert!(cache.get(&hash).await.is_none()); + } + + #[tokio::test] + async fn rebuild_with_ttl_idempotent_when_unchanged() { + let cache = ApiTokenCache::new(8, Duration::from_secs(30)); + let (hash, value) = fixture_token(4); + cache.insert(hash, value.clone()).await; + cache.rebuild_with_ttl(Duration::from_secs(30)); + assert!(cache.get(&hash).await.is_some()); + } +} diff --git a/crates/zagrosi-identity/src/api_tokens/mod.rs b/crates/zagrosi-identity/src/api_tokens/mod.rs new file mode 100644 index 0000000..7f8b7bb --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/mod.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Personal access token (`pat_*`) surface. +//! +//! Five tightly-scoped sub-modules ship the public surface: +//! +//! - [`model`] holds the request / response DTOs the HTTP handlers +//! serialise. `IssuedApiTokenResponse` is the only response shape +//! that ever carries the raw token string and is returned at most +//! once, on `POST /v1/api-tokens`. +//! - [`cache`] is the in-process LRU keyed on the token hash. It +//! mirrors the [`crate::session::SessionCache`] shape (atomic-swap +//! moka backend with healthy / fail-closed TTL flips) but stores +//! `CachedApiToken` rather than `CachedSession` so the resolver +//! does not re-key the session cache on PAT lookups. +//! - [`write_behind`] is the bounded mpsc channel that batches +//! `last_used_*` updates off the resolve hot path. Coalesces +//! updates per `(token_id)` within a 60-second window so a hot +//! PAT issues at most one DB UPDATE per minute even under bursty +//! load. +//! - [`resolver`] is the `pat_*` branch of the gateway-facing +//! introspector pipeline. Validates the prefix + length pre-DB, +//! probes the cache, falls back to a `find_by_token_hash` query, +//! re-checks `(revoked_at IS NULL AND expires_at > now())` even on +//! cache hits, and fires a write-behind event. +//! - [`service`] is the CRUD entry point: issue (mint + persist + +//! audit), list, get, revoke. +//! +//! ## Scope catalogue v0.1 +//! +//! See [`SCOPE_CATALOGUE_V0_1`]. The catalogue is extended in the +//! upcoming RBAC layer; this module's responsibility ends at +//! string-match validation against the constant set. + +pub mod cache; +pub mod model; +pub mod resolver; +pub mod service; +pub mod write_behind; + +pub use cache::{ApiTokenCache, CachedApiToken}; +pub use model::{ApiTokenView, CreateApiTokenRequest, IssuedApiToken, IssuedApiTokenResponse}; +pub use resolver::{ApiTokenResolver, PAT_RESOLVE_SCOPE}; +pub use service::{ApiTokenService, IssueApiTokenInput}; +pub use write_behind::{ + ApiTokenLastUsedReceiver, ApiTokenLastUsedSender, ApiTokenLastUsedUpdate, + channel as api_token_last_used_channel, +}; + +/// Authorisation scopes accepted on PAT issuance in v0.1. +/// +/// Every PAT-creation request whose `scopes` list contains a string +/// outside this set is rejected with +/// [`crate::error::IdentityError::InvalidScope`]. Catalogue extension +/// lands in the upcoming RBAC layer; until then the three-string set +/// here is the source of truth. +pub const SCOPE_CATALOGUE_V0_1: &[&str] = &["tokens:read", "tokens:write", "me:read"]; + +/// Required scope to mint or revoke personal access tokens. +pub const SCOPE_TOKENS_WRITE: &str = "tokens:write"; + +/// Required scope to list / get personal access tokens. +pub const SCOPE_TOKENS_READ: &str = "tokens:read"; + +/// Maximum length of the human-set `display_name` field in characters. +pub const DISPLAY_NAME_MAX_LEN: usize = 100; + +/// Returns `true` when `scope` is a recognised v0.1 catalogue entry. +#[must_use] +pub fn is_known_scope(scope: &str) -> bool { + SCOPE_CATALOGUE_V0_1.contains(&scope) +} diff --git a/crates/zagrosi-identity/src/api_tokens/model.rs b/crates/zagrosi-identity/src/api_tokens/model.rs new file mode 100644 index 0000000..c9a6d0f --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/model.rs @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Request / response DTOs for the personal-access-token surface. +//! +//! `IssuedApiTokenResponse` is the only shape that carries the raw +//! `pat_*` token. It is emitted exactly once, on `POST /v1/api-tokens`, +//! and never returned again. Listing or getting an existing token +//! yields [`ApiTokenView`] which omits the secret entirely. + +use std::net::IpAddr; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::ApiToken; + +/// Request body for `POST /v1/api-tokens`. +#[derive(Debug, Clone, Deserialize)] +pub struct CreateApiTokenRequest { + /// Human-set label shown on the token-management UI. Required; + /// trimmed and validated for length 1..=`DISPLAY_NAME_MAX_LEN`. + pub display_name: String, + /// Authorisation scopes. Each string must be present in + /// [`super::SCOPE_CATALOGUE_V0_1`]. + pub scopes: Vec, + /// Optional hard-expiry timestamp. `None` mints a no-expiry PAT. + /// When present, must be at least one minute in the future. + pub expires_at: Option>, +} + +/// Response body for `POST /v1/api-tokens`. +/// +/// The `token` field is the raw `pat_*` value. It is only present in +/// this response shape; subsequent `GET` requests for the same token +/// id yield [`ApiTokenView`] with no `token` field. The raw value +/// MUST NOT be logged or persisted by the server. +#[derive(Debug, Clone, Serialize)] +pub struct IssuedApiTokenResponse { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Display name as persisted (trimmed input). + pub display_name: String, + /// Persisted scope list. + pub scopes: Vec, + /// Optional hard-expiry timestamp. + pub expires_at: Option>, + /// Row creation timestamp. + pub created_at: DateTime, + /// Raw `pat_<43>` token. Returned exactly once. + pub token: String, +} + +/// Internal carrier returned by [`super::service::ApiTokenService::issue`]. +/// +/// Pairs the persisted [`ApiToken`] aggregate with the raw token +/// string so the HTTP layer can build [`IssuedApiTokenResponse`] +/// without having to re-mint or re-hash. +#[derive(Debug, Clone)] +pub struct IssuedApiToken { + /// Persisted aggregate. + pub token: ApiToken, + /// Raw `pat_<43>` token string. Caller copies this into the + /// response body and drops the local binding. + pub raw_token: String, +} + +/// View of an existing PAT. +/// +/// Returned by `GET /v1/api-tokens` (list) and +/// `GET /v1/api-tokens/{id}` (single). The raw token is never +/// exposed via this shape; only metadata. +#[derive(Debug, Clone, Serialize)] +pub struct ApiTokenView { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Display name. + pub display_name: String, + /// Persisted scope list. + pub scopes: Vec, + /// Last-used timestamp; `None` until the resolver write-behind + /// has fired against this token. + pub last_used_at: Option>, + /// Last source IP that introspected the token. + pub last_used_ip: Option, + /// Optional hard expiry. + pub expires_at: Option>, + /// Row creation timestamp. + pub created_at: DateTime, + /// Revocation timestamp; `None` for live tokens. + pub revoked_at: Option>, +} + +impl From for ApiTokenView { + fn from(value: ApiToken) -> Self { + Self { + id: value.id, + display_name: value.display_name, + scopes: value.scopes, + last_used_at: value.last_used_at, + last_used_ip: value.last_used_ip, + expires_at: value.expires_at, + created_at: value.created_at, + revoked_at: value.revoked_at, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(CreateApiTokenRequest: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(IssuedApiTokenResponse: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(IssuedApiToken: Send, Sync, Clone, std::fmt::Debug); + assert_impl_all!(ApiTokenView: Send, Sync, Clone, std::fmt::Debug); + + #[test] + fn create_request_round_trips_minimal_body() { + let body = serde_json::json!({ + "display_name": "ci-bot", + "scopes": ["tokens:read"], + }); + let parsed: CreateApiTokenRequest = + serde_json::from_value(body).expect("parse minimal body"); + assert_eq!(parsed.display_name, "ci-bot"); + assert_eq!(parsed.scopes, vec!["tokens:read".to_string()]); + assert!(parsed.expires_at.is_none()); + } + + #[test] + fn create_request_round_trips_with_expiry() { + let body = serde_json::json!({ + "display_name": "ci-bot", + "scopes": ["tokens:read", "tokens:write"], + "expires_at": "2027-01-01T00:00:00Z", + }); + let parsed: CreateApiTokenRequest = + serde_json::from_value(body).expect("parse with expiry"); + assert_eq!(parsed.scopes.len(), 2); + assert!(parsed.expires_at.is_some()); + } + + #[test] + fn issued_response_serialises_token_field() { + let resp = IssuedApiTokenResponse { + id: Uuid::nil(), + display_name: "ci".into(), + scopes: vec!["tokens:read".into()], + expires_at: None, + created_at: Utc::now(), + token: "pat_dummybody".into(), + }; + let v = serde_json::to_value(&resp).expect("serialise"); + assert!( + v.get("token").is_some(), + "issuance response must carry token" + ); + assert_eq!(v["token"], "pat_dummybody"); + } + + #[test] + fn view_does_not_carry_token_field() { + let view = ApiTokenView { + id: Uuid::nil(), + display_name: "ci".into(), + scopes: vec![], + last_used_at: None, + last_used_ip: None, + expires_at: None, + created_at: Utc::now(), + revoked_at: None, + }; + let v = serde_json::to_value(&view).expect("serialise"); + assert!( + v.get("token").is_none(), + "view shape MUST NOT carry the raw token" + ); + } +} diff --git a/crates/zagrosi-identity/src/api_tokens/resolver.rs b/crates/zagrosi-identity/src/api_tokens/resolver.rs new file mode 100644 index 0000000..ce7c895 --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/resolver.rs @@ -0,0 +1,312 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Personal-access-token branch of the gateway-facing introspector. +//! +//! The session introspector +//! ([`crate::session::IdentitySessionIntrospector`]) dispatches by +//! token class: `sid_*` runs the existing session-table path, +//! `pat_*` calls into [`ApiTokenResolver`] here. Both branches share +//! the same return shape (`Result`) so the +//! gateway's bearer middleware does not need to know which kind of +//! token it just resolved. +//! +//! ## Resolve flow +//! +//! 1. **Prefix + body shape**. Re-validates via +//! [`crate::domain::token_format::parse_raw`]. Cheap defensive +//! re-check; the dispatcher already validated once. +//! 2. **Hash**. Canonical [`crate::domain::token_format::hash_token`] +//! chokepoint (prefix included). +//! 3. **Cache probe**. `O(1)` lookup against +//! [`super::cache::ApiTokenCache`]. +//! 4. **DB fallback**. `find_live_by_token_hash` against the +//! `api_tokens` table. The partial-unique index guarantees ≤ 1 +//! live row per hash so cross-org collision is impossible. +//! 5. **Validate**. `revoked_at IS NULL` and +//! `(expires_at IS NULL OR expires_at > now())`. Fail-closed on +//! either invariant. +//! 6. **Per-token rate limit**. `RateLimitKey::PerToken` with the +//! SHA-256 hash + [`PAT_RESOLVE_SCOPE`]. Trips before +//! cache-insert / write-behind so a denied resolve does not move +//! the `last_used_*` counters. +//! 7. **Cache insert + write-behind fire**. +//! 8. **Build `AuthContext`** carrying [`AuthMethod::ApiToken`] and +//! [`TokenClass::PersonalAccessToken`]. + +use std::net::IpAddr; +use std::sync::Arc; + +use async_trait::async_trait; +use chrono::Utc; +use uuid::Uuid; +use zagrosi_core::{ + AuthContext, AuthError, AuthMethod, RateLimitDecision, RateLimitKey, RateLimiter, + SessionIntrospector, TokenClass, +}; + +use crate::api_tokens::cache::{ApiTokenCache, CachedApiToken}; +use crate::api_tokens::write_behind::{ApiTokenLastUsedSender, ApiTokenLastUsedUpdate}; +use crate::domain::token_format::{TokenHash, TokenPrefix, hash_token, parse_raw}; +use crate::repo::ApiTokenRepo; + +/// Stable bucket scope for per-PAT rate-limit keys. +/// +/// Lives as a `&'static str` so the Valkey limiter formats its +/// storage key (`rl:pat_resolve:token:`) without an extra +/// allocation; same pattern as `crate::service::signin::SIGNIN_SCOPE`. +pub const PAT_RESOLVE_SCOPE: &str = "pat_resolve"; + +/// Concrete PAT resolver. Cheap to clone; every dependency is an +/// `Arc`-flavoured handle. +#[derive(Clone)] +pub struct ApiTokenResolver { + repo: ApiTokenRepo, + cache: ApiTokenCache, + last_used: Arc, + rate_limiter: Arc, +} + +impl ApiTokenResolver { + /// Wire dependencies. The `last_used` sender is held inside an + /// `Arc` so the resolver clones cheaply across axum state without + /// producing a new mpsc producer per clone. + #[must_use] + pub fn new( + repo: ApiTokenRepo, + cache: ApiTokenCache, + last_used: ApiTokenLastUsedSender, + rate_limiter: Arc, + ) -> Self { + Self { + repo, + cache, + last_used: Arc::new(last_used), + rate_limiter, + } + } + + /// Cache accessor for the NATS subscriber + admin path. + #[must_use] + pub const fn cache(&self) -> &ApiTokenCache { + &self.cache + } + + /// Resolve a raw `pat_*` token. The caller IP is unknown; the + /// resolver fires the write-behind with `ip = None` so the + /// `last_used_ip` column stays unset on this path. + /// + /// Most production callers reach the resolver via + /// [`SessionIntrospector::resolve`] which has no IP context to + /// surface; the gateway's bearer-IP capture middleware uses + /// [`Self::resolve_with_observation`] below. + pub async fn resolve(&self, raw_token: &str) -> Result { + self.resolve_with_observation(raw_token, None).await + } + + /// Resolve a raw `pat_*` token, recording the caller IP for the + /// `last_used_ip` write-behind. + pub async fn resolve_with_observation( + &self, + raw_token: &str, + ip: Option, + ) -> Result { + // 1 & 2. Prefix validation + hash. + let (prefix, _body) = parse_raw(raw_token).map_err(|_| AuthError::MalformedPrefix)?; + if prefix != TokenPrefix::Pat { + return Err(AuthError::MalformedPrefix); + } + let hash = hash_token(raw_token); + + // 3. Cache probe. + if let Some(entry) = self.cache.get(&hash).await { + return self.finalise_cached(entry, hash, ip).await; + } + + // 4. DB fallback. + let row = self + .repo + .find_live_by_token_hash(&hash.0) + .await + .map_err(AuthError::internal)? + .ok_or(AuthError::Unauthorized)?; + + // 5. Defense-in-depth constant-time hash compare. The B-tree + // probe already narrowed by hash, but a future call site + // bypassing the index would otherwise leak a non-CT compare; + // the `subtle::ConstantTimeEq` chokepoint is the documented + // PAT branch invariant per the section spec. + let row_hash = TokenHash(row.token_hash); + if !hash.ct_eq(&row_hash) { + return Err(AuthError::Unauthorized); + } + + // 6. Snapshot the revocation generation AFTER the DB read + // (we now know `row.id`) but BEFORE rate-limit work and + // the cache insert. Any concurrent revoke that beats us + // into `insert_with_guard` will bump the generation past + // this snapshot, so the would-be stale cache entry gets + // dropped. The DB-read-itself race (revoke commits while + // we read) is owned by Postgres MVCC: a revoke landing + // after our snapshot's read will surface as `revoked_at` + // set on the next resolve. + let post_read_generation = self.cache.current_generation(row.id); + + // 7. Validate. + if row.revoked_at.is_some() { + return Err(AuthError::Revoked); + } + let now = Utc::now(); + if let Some(exp) = row.expires_at + && exp <= now + { + return Err(AuthError::Expired); + } + + // 8. Rate limit on first-touch path. + self.enforce_rate_limit(&hash).await?; + + let cached = CachedApiToken { + token_id: row.id, + user_id: row.user_id, + org_id: row.org_id, + scopes: row.scopes.clone(), + expires_at: row.expires_at, + revoked_at: row.revoked_at, + created_at: row.created_at, + }; + + // 9. Cache insert (guarded against a concurrent revoke) + + // write-behind. If the guard rejects the insert, the + // in-flight request still serves the AuthContext we just + // validated; future requests miss cache and re-read DB. + let _ = self + .cache + .insert_with_guard(hash, cached.clone(), post_read_generation) + .await; + self.fire_last_used(cached.org_id, cached.token_id, ip); + + // 10. Build AuthContext. + Self::context_from_cached(&cached) + } + + async fn finalise_cached( + &self, + cached: CachedApiToken, + hash: TokenHash, + ip: Option, + ) -> Result { + // Re-validate revocation / expiry at every cache hit. The + // entry could have aged across an expiry boundary since + // insertion, and the NATS subscriber may not have raced ahead + // of us with an evict for a freshly-revoked token. + if cached.revoked_at.is_some() { + return Err(AuthError::Revoked); + } + let now = Utc::now(); + if let Some(exp) = cached.expires_at + && exp <= now + { + return Err(AuthError::Expired); + } + // Per-request rate limit. Runs on cache hits too because the + // budget is per-request, not per-DB-lookup. + self.enforce_rate_limit(&hash).await?; + + self.fire_last_used(cached.org_id, cached.token_id, ip); + Self::context_from_cached(&cached) + } + + fn context_from_cached(cached: &CachedApiToken) -> Result { + // PATs do not carry the AMR / ACR claim shape sessions do. + // We tag a single AMR string `pat` so the sign-in audit trail + // can attribute the call without reaching for the + // `auth_method` field. RFC 8176 explicitly leaves room for + // application-defined values. + let amr = vec!["pat".to_string()]; + // Long-lived PATs may have `expires_at = NULL`. The + // `AuthContext::new` invariant requires `issued_at < expires_at`, + // so we synthesise an effective expiry far enough in the + // future that it is indistinguishable from "no expiry" while + // still satisfying the constructor. `checked_add_signed` + // guards against the (otherwise unreachable) overflow at the + // far end of `DateTime`'s range so a fuzz / future + // migration cannot panic the resolver. + let effective_expires_at = match cached.expires_at { + Some(exp) => exp, + None => cached + .created_at + .checked_add_signed(chrono::Duration::days(36_500)) + .ok_or_else(|| { + AuthError::internal(std::io::Error::other( + "synthesised PAT expiry overflows DateTime", + )) + })?, + }; + let ctx = AuthContext::new( + cached.user_id, + cached.token_id, + cached.org_id, + AuthMethod::ApiToken, + TokenClass::PersonalAccessToken, + amr, + None, + cached.created_at, + effective_expires_at, + Uuid::now_v7(), + ) + .map_err(AuthError::internal)?; + Ok(ctx.with_scopes(cached.scopes.clone())) + } + + async fn enforce_rate_limit(&self, hash: &TokenHash) -> Result<(), AuthError> { + let key = RateLimitKey::PerToken { + token_hash: hash.0, + scope: PAT_RESOLVE_SCOPE, + }; + match self.rate_limiter.check(&key).await { + Ok(RateLimitDecision::Allow { .. }) => Ok(()), + Ok( + RateLimitDecision::Deny { retry_after } + | RateLimitDecision::LockedOut { retry_after, .. }, + ) => Err(AuthError::RateLimited { retry_after }), + Ok(_) => Err(AuthError::RateLimited { + retry_after: std::time::Duration::from_secs(60), + }), + Err(err) => Err(AuthError::internal(err)), + } + } + + fn fire_last_used(&self, org_id: Uuid, token_id: Uuid, ip: Option) { + let _ = self.last_used.try_send(ApiTokenLastUsedUpdate { + org_id, + token_id, + ip, + seen_at: Utc::now(), + }); + } +} + +#[async_trait] +impl SessionIntrospector for ApiTokenResolver { + async fn resolve(&self, raw_token: &str) -> Result { + self.resolve_with_observation(raw_token, None).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(ApiTokenResolver: Send, Sync, Clone); + + #[test] + fn pat_resolve_scope_constant_is_stable() { + // The scope string is part of the Valkey storage-key format + // (`rl:pat_resolve:token:`). Renaming requires a + // coordinated migration of in-flight rate-limit state in + // production; guard against accidental drift. + assert_eq!(PAT_RESOLVE_SCOPE, "pat_resolve"); + } +} diff --git a/crates/zagrosi-identity/src/api_tokens/service.rs b/crates/zagrosi-identity/src/api_tokens/service.rs new file mode 100644 index 0000000..2cc30da --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/service.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Personal-access-token service: issue / list / get / revoke. +//! +//! Every method is `(caller_user_id, caller_org_id)`-scoped so the +//! tenant-isolation invariant holds at the API surface. +//! Cross-user / cross-org reads return `TokenNotFound` rather than +//! `Forbidden` (matches the section-08 / section-12 anti-enumeration +//! contract). + +use std::sync::Arc; + +use chrono::{Duration, Utc}; +use uuid::Uuid; +use zagrosi_core::{ + AuditActor, AuditEvent, AuditEventKind, AuditEventV1, AuditPayload, AuditResource, Auditor, +}; + +use crate::api_tokens::cache::ApiTokenCache; +use crate::api_tokens::model::{ApiTokenView, CreateApiTokenRequest, IssuedApiToken}; +use crate::api_tokens::{DISPLAY_NAME_MAX_LEN, is_known_scope}; +use crate::domain::token_format::{TokenPrefix, hash_token, mint}; +use crate::error::{IdentityError, Result}; +use crate::repo::{ApiTokenRepo, NewApiToken, OrgScoped}; + +/// Argument bundle for [`ApiTokenService::issue`]. Carries the +/// caller's identity (so the service can stamp `user_id` / `org_id` +/// into the row) plus the validated request body. +#[derive(Debug, Clone)] +pub struct IssueApiTokenInput { + /// Caller (PAT owner). + pub caller_user_id: Uuid, + /// Caller's active org. The PAT will be scoped to this org. + pub caller_org_id: Uuid, + /// Caller-supplied request body. Validated by the service. + pub request: CreateApiTokenRequest, + /// Caller-controlled correlation id for audit trace continuity. + pub correlation_id: Uuid, +} + +/// Composed service for the PAT surface. Cheap to clone; every +/// dependency is an `Arc`-flavoured handle. +#[derive(Clone)] +pub struct ApiTokenService { + repo: ApiTokenRepo, + cache: ApiTokenCache, + auditor: Arc, +} + +impl ApiTokenService { + /// Wire dependencies. + #[must_use] + pub fn new(repo: ApiTokenRepo, cache: ApiTokenCache, auditor: Arc) -> Self { + Self { + repo, + cache, + auditor, + } + } + + /// Mint a fresh PAT, persist it, and return `(row, raw_token)`. + /// + /// # Errors + /// + /// - [`IdentityError::InvalidApiTokenRequest`] for empty / + /// over-long display name or `expires_at` in the past. + /// - [`IdentityError::InvalidScope`] for any scope string outside + /// [`super::SCOPE_CATALOGUE_V0_1`]. + /// - [`IdentityError::Database`] for any underlying sqlx failure. + pub async fn issue(&self, input: IssueApiTokenInput) -> Result { + let req = input.request; + let display_name = req.display_name.trim(); + if display_name.is_empty() { + return Err(IdentityError::InvalidApiTokenRequest { + reason: "display_name must not be empty".into(), + }); + } + if display_name.chars().count() > DISPLAY_NAME_MAX_LEN { + return Err(IdentityError::InvalidApiTokenRequest { + reason: format!("display_name exceeds {DISPLAY_NAME_MAX_LEN} characters",), + }); + } + for scope in &req.scopes { + if !is_known_scope(scope) { + return Err(IdentityError::InvalidScope { + scope: scope.clone(), + }); + } + } + if let Some(exp) = req.expires_at { + let earliest = Utc::now() + Duration::minutes(1); + if exp < earliest { + return Err(IdentityError::InvalidApiTokenRequest { + reason: "expires_at must be at least one minute in the future".into(), + }); + } + } + + let raw_token = mint(TokenPrefix::Pat); + let hash = hash_token(&raw_token); + let id = Uuid::now_v7(); + let scope_strs: Vec<&str> = req.scopes.iter().map(String::as_str).collect(); + + let scoped = OrgScoped::new(&self.repo, input.caller_org_id); + let persisted = scoped + .create(NewApiToken { + id, + token_hash: hash.as_slice(), + user_id: input.caller_user_id, + display_name, + scopes: &scope_strs, + expires_at: req.expires_at, + }) + .await?; + + self.auditor + .record(AuditEvent::V1(AuditEventV1::new( + AuditEventKind::ApiTokenCreated, + AuditActor::User { + user_id: input.caller_user_id, + ip: None, + }, + AuditResource::ApiToken { token_id: id }, + input.correlation_id, + input.caller_org_id, + AuditPayload::new(serde_json::json!({ + "display_name": display_name, + "scopes": req.scopes, + "expires_at": req.expires_at, + })), + ))) + .await; + + Ok(IssuedApiToken { + token: persisted, + raw_token, + }) + } + + /// List live PATs owned by `caller_user_id` in `caller_org_id`. + pub async fn list( + &self, + caller_user_id: Uuid, + caller_org_id: Uuid, + ) -> Result> { + let scoped = OrgScoped::new(&self.repo, caller_org_id); + let rows = scoped.list_for_user(caller_user_id).await?; + Ok(rows.into_iter().map(ApiTokenView::from).collect()) + } + + /// Fetch one PAT by id, scoped to `(caller_user_id, caller_org_id)`. + /// + /// Returns the row regardless of `revoked_at` so the + /// owner-visible token-management UI can surface the revocation + /// timestamp on previously-revoked tokens (audit trail). + /// [`IdentityError::TokenNotFound`] when the row does not exist + /// or belongs to a different user / org. The error envelope is + /// identical for both shapes so the route does not double as an + /// existence oracle. + pub async fn get( + &self, + caller_user_id: Uuid, + caller_org_id: Uuid, + token_id: Uuid, + ) -> Result { + let scoped = OrgScoped::new(&self.repo, caller_org_id); + scoped + .find_by_id_for_user(caller_user_id, token_id) + .await? + .map(ApiTokenView::from) + .ok_or(IdentityError::TokenNotFound) + } + + /// Revoke a PAT scoped to `(caller_user_id, caller_org_id)`. + /// + /// Returns [`IdentityError::TokenNotFound`] when the row is + /// missing, already revoked, or belongs to another user / org. + /// Concurrent-safe: the cache generation is bumped BEFORE the + /// `UPDATE ... WHERE revoked_at IS NULL` so an in-flight + /// resolver that snapshotted the prior generation cannot land a + /// stale cache entry. The audit event is emitted only when the + /// UPDATE actually mutated a row, preventing duplicate + /// `ApiTokenRevoked` emissions under concurrent revoke races. + pub async fn revoke( + &self, + caller_user_id: Uuid, + caller_org_id: Uuid, + token_id: Uuid, + correlation_id: Uuid, + ) -> Result<()> { + let scoped = OrgScoped::new(&self.repo, caller_org_id); + // Ownership + existence pre-check (404 vs 200 disambiguation). + // The follow-up UPDATE's WHERE clause re-applies the live-row + // predicate, so a race that revokes the row between this + // read and the UPDATE collapses cleanly to a `rows == 0` + // outcome below. + let target = scoped + .find_by_id_for_user(caller_user_id, token_id) + .await? + .ok_or(IdentityError::TokenNotFound)?; + if target.revoked_at.is_some() { + return Err(IdentityError::TokenNotFound); + } + + // Bump the cache generation BEFORE the UPDATE so any + // in-flight resolver that snapshotted the prior generation + // gets its `insert_with_guard` rejected. Eviction itself + // happens on the resolver's next miss; the bump is the + // race-safe primitive. + self.cache.bump_generation(token_id); + + let rows_affected = scoped.revoke(token_id).await?; + if rows_affected == 0 { + // Concurrent revoker won the race; no audit emission. + return Err(IdentityError::TokenNotFound); + } + + // Cache eviction (synchronous moka invalidate) so subsequent + // resolves re-read the DB and surface the revoked state. + let _ = self.cache.evict_by_token_id(token_id).await; + + self.auditor + .record(AuditEvent::V1(AuditEventV1::new( + AuditEventKind::ApiTokenRevoked, + AuditActor::User { + user_id: caller_user_id, + ip: None, + }, + AuditResource::ApiToken { token_id }, + correlation_id, + caller_org_id, + AuditPayload::new(serde_json::json!({ + "owner_user_id": target.user_id, + })), + ))) + .await; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(ApiTokenService: Send, Sync, Clone); + + #[test] + fn issue_input_round_trips_correlation_id() { + let input = IssueApiTokenInput { + caller_user_id: Uuid::nil(), + caller_org_id: Uuid::nil(), + request: CreateApiTokenRequest { + display_name: "x".into(), + scopes: vec![], + expires_at: None, + }, + correlation_id: Uuid::from_bytes([7; 16]), + }; + let cloned = input.clone(); + assert_eq!(cloned.correlation_id, input.correlation_id); + } +} diff --git a/crates/zagrosi-identity/src/api_tokens/write_behind.rs b/crates/zagrosi-identity/src/api_tokens/write_behind.rs new file mode 100644 index 0000000..6542ea3 --- /dev/null +++ b/crates/zagrosi-identity/src/api_tokens/write_behind.rs @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Bounded write-behind channel for personal-access-token +//! `last_used_*` updates. +//! +//! Mirrors [`crate::session::write_behind`] but for the `api_tokens` +//! table. Each successful resolve emits an +//! [`ApiTokenLastUsedUpdate`] onto the bounded channel; a background +//! drain task batches the updates and issues coalesced +//! `UPDATE api_tokens SET last_used_at = ..., last_used_ip = ...` +//! statements at most once per token per minute. +//! +//! Channel-full silently drops the update; `last_used_*` is +//! best-effort observability, not a security primitive. The dropped +//! update is acceptable because the next resolve produces another +//! event within the cache TTL window. + +use chrono::{DateTime, Utc}; +use std::collections::HashMap; +use std::net::IpAddr; +use tokio::sync::mpsc; +use tracing::{debug, warn}; +use uuid::Uuid; + +use crate::repo::{ApiTokenRepo, OrgScoped}; + +/// Coalescing window: at most one DB update per `(org_id, token_id)` +/// per `COALESCE_WINDOW`. Identical cadence to the session +/// write-behind so the two drain tasks share a single timer. +pub const COALESCE_WINDOW: chrono::TimeDelta = chrono::TimeDelta::seconds(60); + +/// Single `last_used_*` update event. +#[derive(Debug, Clone, Copy)] +pub struct ApiTokenLastUsedUpdate { + /// Owning org. The drain task wraps the repo in + /// [`OrgScoped`] using this id, so a cross-org probe never + /// reaches the row through this channel. + pub org_id: Uuid, + /// Token row whose last-used columns to bump. + pub token_id: Uuid, + /// Source IP that introspected the token (when known). + pub ip: Option, + /// Wall-clock time the resolver observed the token. + pub seen_at: DateTime, +} + +/// Sender half. Cheap to clone; cloning shares the underlying queue. +#[derive(Debug, Clone)] +pub struct ApiTokenLastUsedSender { + inner: mpsc::Sender, +} + +impl ApiTokenLastUsedSender { + /// Try to enqueue an update without blocking. Channel-full + /// silently drops the event (best-effort metadata semantic). + /// Returns `true` if the event landed on the queue. + pub fn try_send(&self, event: ApiTokenLastUsedUpdate) -> bool { + match self.inner.try_send(event) { + Ok(()) => true, + Err(mpsc::error::TrySendError::Full(_)) => { + debug!(token_id = %event.token_id, "api-token last_used write-behind queue full; dropping update"); + false + } + Err(mpsc::error::TrySendError::Closed(_)) => { + warn!("api-token last_used write-behind channel closed"); + false + } + } + } +} + +/// Receiver half. +pub struct ApiTokenLastUsedReceiver { + inner: mpsc::Receiver, + coalesce: HashMap<(Uuid, Uuid), ApiTokenLastUsedUpdate>, +} + +impl ApiTokenLastUsedReceiver { + /// Drain pending events into the coalescing map. Returns the + /// number of post-coalesce events admitted (≤ events received). + pub fn drain_pending(&mut self, max: usize) -> usize { + let mut consumed = 0; + for _ in 0..max { + match self.inner.try_recv() { + Ok(event) => { + let key = (event.org_id, event.token_id); + let prior = self.coalesce.get(&key).map(|p| p.seen_at); + let should_update = prior + .is_none_or(|t| event.seen_at.signed_duration_since(t) >= COALESCE_WINDOW); + if should_update { + self.coalesce.insert(key, event); + consumed += 1; + } + } + Err(mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected) => { + break; + } + } + } + consumed + } + + /// Take the coalesced batch, leaving the receiver ready for the + /// next drain cycle. Drain task calls this then issues per-token + /// UPDATEs through the org-scoped repo. + pub fn take_batch(&mut self) -> Vec { + let mut out = Vec::with_capacity(self.coalesce.len()); + for (_, event) in self.coalesce.drain() { + out.push(event); + } + out + } +} + +/// Build a bounded write-behind channel sized to `capacity`. +#[must_use] +pub fn channel(capacity: usize) -> (ApiTokenLastUsedSender, ApiTokenLastUsedReceiver) { + let (tx, rx) = mpsc::channel(capacity); + ( + ApiTokenLastUsedSender { inner: tx }, + ApiTokenLastUsedReceiver { + inner: rx, + coalesce: HashMap::new(), + }, + ) +} + +/// Drain the receiver and apply the coalesced batch through the +/// org-scoped repo. Errors are logged and swallowed so a transient +/// DB hiccup does not crash the drain task. +/// +/// Returns the number of UPDATEs issued (post-coalesce). Test +/// hooks rely on the count to assert the coalescing invariant. +pub async fn drain_once( + rx: &mut ApiTokenLastUsedReceiver, + repo: &ApiTokenRepo, + max_events: usize, +) -> usize { + rx.drain_pending(max_events); + let batch = rx.take_batch(); + let count = batch.len(); + for event in batch { + let scoped = OrgScoped::new(repo, event.org_id); + if let Err(err) = scoped + .update_last_used(event.token_id, event.seen_at, event.ip) + .await + { + warn!( + token_id = %event.token_id, + org_id = %event.org_id, + error = %err, + "api-token last_used UPDATE failed; dropping event", + ); + } + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::TimeZone; + + fn fixture_event(token_byte: u8, seen_secs: i64) -> ApiTokenLastUsedUpdate { + ApiTokenLastUsedUpdate { + org_id: Uuid::from_bytes([0xCC; 16]), + token_id: Uuid::from_bytes([token_byte; 16]), + ip: Some(IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)), + seen_at: Utc.timestamp_opt(seen_secs, 0).unwrap(), + } + } + + #[tokio::test] + async fn try_send_returns_true_until_queue_fills() { + let (tx, _rx) = channel(2); + assert!(tx.try_send(fixture_event(1, 1))); + assert!(tx.try_send(fixture_event(2, 2))); + assert!(!tx.try_send(fixture_event(3, 3))); + } + + #[tokio::test] + async fn coalesce_collapses_repeats_within_window() { + let (tx, mut rx) = channel(8); + tx.try_send(fixture_event(1, 1_700_000_000)); + tx.try_send(fixture_event(1, 1_700_000_030)); + rx.drain_pending(8); + let batch = rx.take_batch(); + assert_eq!(batch.len(), 1); + } + + #[tokio::test] + async fn coalesce_admits_update_after_window_elapses() { + let (tx, mut rx) = channel(8); + tx.try_send(fixture_event(1, 1_700_000_000)); + tx.try_send(fixture_event(1, 1_700_000_120)); + rx.drain_pending(8); + let batch = rx.take_batch(); + assert_eq!(batch.len(), 1); + assert!(batch[0].seen_at.timestamp() >= 1_700_000_120); + } + + #[tokio::test] + async fn distinct_tokens_emit_distinct_batch_entries() { + let (tx, mut rx) = channel(8); + tx.try_send(fixture_event(1, 1_700_000_000)); + tx.try_send(fixture_event(2, 1_700_000_001)); + rx.drain_pending(8); + let batch = rx.take_batch(); + assert_eq!(batch.len(), 2); + } + + #[tokio::test] + async fn distinct_orgs_emit_distinct_batch_entries() { + let (tx, mut rx) = channel(8); + let mut a = fixture_event(1, 1_700_000_000); + a.org_id = Uuid::from_bytes([0xCC; 16]); + let mut b = fixture_event(1, 1_700_000_001); + b.org_id = Uuid::from_bytes([0xDD; 16]); + tx.try_send(a); + tx.try_send(b); + rx.drain_pending(8); + let batch = rx.take_batch(); + assert_eq!(batch.len(), 2); + } + + #[tokio::test] + async fn closed_channel_silently_drops() { + let (tx, rx) = channel(4); + drop(rx); + assert!(!tx.try_send(fixture_event(1, 1))); + } +} diff --git a/crates/zagrosi-identity/src/config.rs b/crates/zagrosi-identity/src/config.rs new file mode 100644 index 0000000..9ca4a8d --- /dev/null +++ b/crates/zagrosi-identity/src/config.rs @@ -0,0 +1,1536 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Layered configuration loader for `zagrosi-identity`. +//! +//! Reads configuration from environment variables and an optional TOML +//! file via `figment`. Environment values take precedence; the file +//! fills gaps. Unknown fields are tolerated so future-version configs +//! can deserialise without erroring on fields this crate does not yet +//! recognise. +//! +//! The crate skeleton ships the minimum surface needed to validate the +//! two env vars introduced by the foundation work: `ZAGROSI_SECRETS_KEY` +//! and `ZAGROSI_VALKEY_URL`. Later layers extend [`IdentityConfig`] with +//! Argon2 / password / breach-list / session / OIDC / DNS / rate-limit +//! / DB-pool fields alongside the code that consumes them. + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use figment::Figment; +use figment::providers::{Env, Format, Toml}; +use zeroize::Zeroize; + +use crate::Result; +use crate::error::IdentityError; + +/// Number of bytes the decoded `ZAGROSI_SECRETS_KEY` must contain. +/// +/// Crate-private — `pub(crate)` keeps `crypto/secrets.rs` callers +/// honest while satisfying the workspace `unreachable_pub = warn` +/// lint without polluting the crate's public API surface with an +/// AEAD-internal length. +pub(crate) const SECRETS_KEY_LEN: usize = 32; + +/// Heap-resident container for the decoded master key. +/// +/// Wraps `Option>` so the master key never traverses a +/// stack-frame slot once it leaves [`IdentityConfig::load`]. `Drop` +/// zeroes the inner bytes; the custom [`std::fmt::Debug`] impl renders +/// only `` so a careless `tracing::debug!(?cfg)` cannot dump +/// the master key into log surfaces. +#[derive(Default)] +struct DecodedSecretsKey(Option>); + +impl Clone for DecodedSecretsKey { + fn clone(&self) -> Self { + // Cloning duplicates the heap allocation so the original and the + // clone each manage their own zeroize-on-drop lifecycle. + Self(self.0.as_ref().map(|boxed| Box::new(**boxed))) + } +} + +impl std::fmt::Debug for DecodedSecretsKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self.0.as_ref() { + Some(_) => f.write_str("DecodedSecretsKey()"), + None => f.write_str("DecodedSecretsKey(None)"), + } + } +} + +impl Drop for DecodedSecretsKey { + fn drop(&mut self) { + if let Some(mut boxed) = self.0.take() { + boxed.zeroize(); + } + } +} + +/// Top-level configuration consumed by `zagrosi-identity`. +/// +/// The base64 source string `secrets_key` is **never** rendered through +/// the derived `Debug` or serialised back out through serde — both paths +/// are intercepted by the hand-rolled impls below so a careless +/// `tracing::debug!(?cfg)` or `serde_json::to_string(&cfg)` cannot leak +/// the master key into log surfaces. Deserialise still works (figment +/// loads the env value into the field at construction time). +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct IdentityConfig { + /// 32-byte base64 master key for the AES-256-GCM secrets envelope. + /// Sourced from `ZAGROSI_SECRETS_KEY`. Consumed by the secrets shim. + /// `serde(skip_serializing)` ensures the raw base64 bytes never + /// round-trip back to wire surfaces; deserialise stays enabled so + /// figment can read the env var. + #[serde(skip_serializing)] + pub secrets_key: String, + + /// Valkey connection URL for the rate limiter. Sourced from + /// `ZAGROSI_VALKEY_URL`. Consumed by the rate-limit module. + pub valkey_url: String, + + /// Argon2id hashing profile. Defaults to OWASP 2024 baseline. + /// The password-auth surface consumes this for sign-up / sign-in / password-reset. + pub argon2: Argon2Config, + + /// Password policy. Length-only checks; no character-class + /// rules per NIST SP 800-63B. + pub password: PasswordConfig, + + /// Breach-list lookup configuration. HIBP k-anonymity client. + pub breachlist: BreachlistConfig, + + /// Single-use email token (verify-email + password-reset) TTL. + /// Defaults to 30 minutes per the password-auth design. + #[serde(default = "default_email_token_ttl_minutes")] + pub email_token_ttl_minutes: u32, + + /// Rate-limit + lockout policy consumed by the Valkey-backed + /// [`zagrosi_core::RateLimiter`] impl ([`crate::rate_limit::ValkeyRateLimiter`]). + #[serde(default)] + pub rate_limit: RateLimitConfig, + + /// Session-resolver policy. Issuance TTL, cache sizing, fail-closed + /// degraded-mode TTL, and the optional NATS broker URL for + /// cross-replica revocation events. + #[serde(default)] + pub session: SessionConfig, + + /// Multi-IdP routing DNS verification policy. Enumerates the + /// DNSSEC-validating resolvers consulted by the domain-ownership + /// flow plus the per-domain verify cache TTL. + #[serde(default)] + pub dns: DnsConfig, + + /// Outbound-SMTP policy consumed by the email-outbox worker's + /// [`crate::email::LettreTransport`]. Both fields default empty; + /// unlike the secrets / Valkey / DNS knobs this is **not** + /// validated at [`IdentityConfig::load`] time, because the + /// gateway and migration-smoke binaries load the config without + /// running the email worker. [`crate::email::LettreTransport::from_config`] + /// performs the validation at worker-construction time instead, so + /// a deploy that never starts the worker (e.g. a read-replica API + /// node) does not need SMTP configured. + #[serde(default)] + pub email: EmailConfig, + + /// Platform-administration policy. v0.1 carries only the + /// service-token admin allowlist; this is the interim gate until + /// the RBAC layer lands a real role check. Defaults empty (no + /// platform admins → the service-token routes 403 every caller) + /// and is **not** validated at [`IdentityConfig::load`] time. + #[serde(default)] + pub platform: PlatformConfig, + + /// Decoded master key, populated by [`IdentityConfig::load`] on + /// successful validation. Skipped by serde so it never round-trips + /// through TOML / env / wire surfaces. + #[serde(skip)] + decoded_secrets_key: DecodedSecretsKey, +} + +const fn default_email_token_ttl_minutes() -> u32 { + 30 +} + +/// Argon2id hashing profile. +/// +/// Defaults track OWASP's 2024 baseline (`m=19456 KiB`, `t=2`, `p=1`), +/// which `argon2`'s built-in defaults already match. `max_concurrency` +/// caps the number of in-flight `spawn_blocking` Argon2id verifies so a +/// burst of sign-ins cannot exhaust the blocking pool. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct Argon2Config { + /// Memory cost in KiB. `ZAGROSI_ARGON2_M_COST`. Default `19456`. + pub m_cost: u32, + /// Iteration count. `ZAGROSI_ARGON2_T_COST`. Default `2`. + pub t_cost: u32, + /// Parallelism. `ZAGROSI_ARGON2_P_COST`. Default `1`. + pub p_cost: u32, + /// Maximum concurrent Argon2id operations. `ZAGROSI_ARGON2_MAX_CONCURRENCY`. + /// Default: `num_cpus::get()`. + pub max_concurrency: usize, +} + +impl Default for Argon2Config { + fn default() -> Self { + Self { + m_cost: 19_456, + t_cost: 2, + p_cost: 1, + max_concurrency: num_cpus::get(), + } + } +} + +/// Password policy. +/// +/// Length-only per NIST SP 800-63B (no character-class rules). +/// `max_length` is hard-coded at 256 to bound `DoS` surface from +/// arbitrarily long Argon2id inputs. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct PasswordConfig { + /// Minimum accepted password length. `ZAGROSI_PASSWORD_MIN_LENGTH`. + /// Default `12`. Validation rejects values below `12` (NIST floor). + pub min_length: usize, + /// Maximum accepted password length. Hard-coded `256`; not + /// env-configurable (`DoS` guard). + #[serde(default = "default_password_max_length")] + pub max_length: usize, +} + +impl Default for PasswordConfig { + fn default() -> Self { + Self { + min_length: 12, + max_length: 256, + } + } +} + +const fn default_password_max_length() -> usize { + 256 +} + +/// Breach-list lookup mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum BreachlistMode { + /// Live HIBP k-anonymity call. Production default. + #[default] + Online, + /// Skip the call entirely. Intended for air-gapped deploys. + Disabled, + /// Reserved for the deferred mirror feature. Treated as + /// [`BreachlistMode::Disabled`] in v0.1 with a deprecation warning. + Offline, +} + +/// HIBP-backed breach-list configuration. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct BreachlistConfig { + /// Mode switch. `ZAGROSI_PASSWORD_BREACHLIST_MODE`. Default `Online`. + pub mode: BreachlistMode, + /// HTTP request timeout in seconds. Hard-coded `5`. + #[serde(default = "default_breachlist_timeout_secs")] + pub timeout_secs: u64, + /// HIBP range endpoint. Hard-coded + /// `https://api.pwnedpasswords.com/range/`. + #[serde(default = "default_breachlist_endpoint")] + pub endpoint: String, +} + +impl Default for BreachlistConfig { + fn default() -> Self { + Self { + mode: BreachlistMode::Online, + timeout_secs: default_breachlist_timeout_secs(), + endpoint: default_breachlist_endpoint(), + } + } +} + +const fn default_breachlist_timeout_secs() -> u64 { + 5 +} + +fn default_breachlist_endpoint() -> String { + "https://api.pwnedpasswords.com/range/".to_string() +} + +/// Sliding-window budget parsed from a `/` literal. +/// +/// Used by [`RateLimitConfig::signin_per_ip`]. The `` suffix +/// accepts `s`, `min`, or `h` to keep the env value human-readable +/// while staying unambiguous; `` is bounded to `u32` so a +/// pathologically large limit cannot overflow the in-Lua INCR check. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(try_from = "String", into = "String")] +pub struct RateLimitBudget { + /// Maximum requests permitted per `window`. + pub count: u32, + /// Window duration in seconds. Always positive. + pub window_seconds: u32, +} + +impl RateLimitBudget { + /// Default sign-in budget: 20 requests per minute per source IP. + pub const SIGNIN_DEFAULT: Self = Self { + count: 20, + window_seconds: 60, + }; + + /// Default per-token budget: 60 requests per minute. `SCIM` `IdPs` + /// frequently egress from small NAT pools shared by many users, + /// so the per-token bucket is sized larger than the per-IP one + /// to avoid throttling legitimate enterprise traffic. + pub const SIGNIN_PER_TOKEN_DEFAULT: Self = Self { + count: 60, + window_seconds: 60, + }; + + /// Default per-PAT budget: 120 requests per minute. PATs back + /// API and MCP clients which run hotter than SCIM provisioning + /// agents; the wider budget reflects that traffic profile. + pub const PAT_PER_MINUTE_DEFAULT: Self = Self { + count: 120, + window_seconds: 60, + }; + + /// Parse a `/` literal. + /// + /// `` recognises `s`, `min`, and `h` suffixes. The bare + /// integer form is rejected so misconfigurations cannot silently + /// fall through to "unlimited". + /// + /// # Errors + /// + /// Returns [`IdentityError::MalformedRateLimit`] for any parse or + /// validation failure (zero count, zero window, unknown suffix, + /// non-numeric component). + pub fn parse(literal: &str) -> Result { + let trimmed = literal.trim(); + let Some((count_str, window_str)) = trimmed.split_once('/') else { + return Err(IdentityError::MalformedRateLimit { + reason: format!("expected `/`, got `{trimmed}`"), + }); + }; + let count: u32 = + count_str + .trim() + .parse() + .map_err(|_| IdentityError::MalformedRateLimit { + reason: format!("count `{count_str}` is not a positive integer"), + })?; + if count == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "count must be > 0".into(), + }); + } + let window_seconds = parse_window(window_str.trim())?; + Ok(Self { + count, + window_seconds, + }) + } + + /// Render back to a `/` literal. + #[must_use] + pub fn render(&self) -> String { + let suffix = match self.window_seconds { + 1 => "s".to_string(), + 60 => "min".to_string(), + 3_600 => "h".to_string(), + other => format!("{other}s"), + }; + format!("{}/{}", self.count, suffix) + } +} + +impl Default for RateLimitBudget { + fn default() -> Self { + Self::SIGNIN_DEFAULT + } +} + +impl TryFrom for RateLimitBudget { + type Error = IdentityError; + fn try_from(value: String) -> Result { + Self::parse(&value) + } +} + +impl From for String { + fn from(value: RateLimitBudget) -> Self { + value.render() + } +} + +fn parse_window(input: &str) -> Result { + let (num_str, unit_secs) = if let Some(num) = input.strip_suffix("min") { + (num, 60_u32) + } else if let Some(num) = input.strip_suffix('h') { + (num, 3_600_u32) + } else if let Some(num) = input.strip_suffix('s') { + (num, 1_u32) + } else { + return Err(IdentityError::MalformedRateLimit { + reason: format!("window `{input}` missing s/min/h suffix"), + }); + }; + let num_str = num_str.trim(); + let multiplier: u32 = if num_str.is_empty() { + 1 + } else { + num_str + .parse() + .map_err(|_| IdentityError::MalformedRateLimit { + reason: format!("window `{input}` is not a positive integer with s/min/h suffix"), + })? + }; + let total = + multiplier + .checked_mul(unit_secs) + .ok_or_else(|| IdentityError::MalformedRateLimit { + reason: format!("window `{input}` overflows u32 seconds"), + })?; + if total == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "window must be > 0".into(), + }); + } + Ok(total) +} + +/// Rate-limit + lockout policy. +/// +/// Sub-policies live here so the Valkey-backed limiter and downstream +/// session code can read consistent values: +/// +/// - [`RateLimitConfig::signin_per_ip`] — sliding-window per-IP budget +/// for the sign-in / password-reset / email-verify endpoints. +/// - [`RateLimitConfig::lockout_initial_minutes`] — first lockout +/// length once a per-account breach threshold trips. +/// - [`RateLimitConfig::lockout_max_hours`] — exponential backoff cap. +/// - [`RateLimitConfig::valkey_pool_size`] — number of multiplexed +/// fred connections in the pool. `fred` already multiplexes a single +/// client across tasks; the pool is sized for parallelism under +/// sustained sign-in load. +/// +/// Validation runs at [`IdentityConfig::load`] time so a misconfigured +/// deploy refuses to start instead of brown-outs at first sign-in. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct RateLimitConfig { + /// Per-source-IP budget for sign-in / password-reset / email-verify. + /// `ZAGROSI_RATE_LIMIT_SIGNIN_PER_IP`. Default `20/min`. + pub signin_per_ip: RateLimitBudget, + /// Per-token budget for SCIM / service-token scopes (everything + /// keyed on a token hash other than personal access tokens). + /// `ZAGROSI_RATE_LIMIT_SIGNIN_PER_TOKEN`. Default `60/min`. + /// Sized larger than the per-IP budget because `SCIM` `IdPs` commonly + /// egress from small NAT pools shared by many tenant users. + #[serde(default = "default_signin_per_token")] + pub signin_per_token: RateLimitBudget, + /// Per-PAT budget for personal-access-token resolves. PATs back + /// API and MCP clients which run hotter than SCIM provisioning + /// agents, so the bucket is sized larger than the generic + /// per-token budget. `ZAGROSI_RATE_LIMIT_PAT_PER_MIN`. + /// Default `120/min`. + #[serde(default = "default_pat_per_minute")] + pub pat_per_minute: RateLimitBudget, + /// First lockout window. Subsequent breaches double up to + /// [`RateLimitConfig::lockout_max_hours`] hours. + /// `ZAGROSI_RATE_LIMIT_LOCKOUT_INITIAL_MINUTES`. Default `15`. + pub lockout_initial_minutes: u32, + /// Lockout cap. `ZAGROSI_RATE_LIMIT_LOCKOUT_MAX_HOURS`. Default `24`. + pub lockout_max_hours: u32, + /// Threshold of consecutive failed sign-ins that trips a lockout. + /// Defaults to `5`; surfaced here so tests can override when + /// constructing a service directly. + #[serde(default = "default_lockout_threshold")] + pub lockout_threshold: u32, + /// Window after a successful unlock during which an in-flight + /// stale failure (a wrong-password request that started before + /// the success arrived) is dropped instead of bumping the breach + /// counter. Defaults to `2000` ms which comfortably covers an + /// Argon2id verify on the OWASP baseline profile while keeping + /// the legitimate-attacker window short. + /// `ZAGROSI_RATE_LIMIT_UNLOCK_GRACE_MS`. + #[serde(default = "default_unlock_grace_ms")] + pub unlock_grace_ms: u32, + /// Number of fred connections in the multiplexed pool. + /// `ZAGROSI_VALKEY_POOL_SIZE`. Default `num_cpus::get()`. + #[serde(default = "default_valkey_pool_size")] + pub valkey_pool_size: usize, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + signin_per_ip: RateLimitBudget::default(), + signin_per_token: default_signin_per_token(), + pat_per_minute: default_pat_per_minute(), + lockout_initial_minutes: 15, + lockout_max_hours: 24, + lockout_threshold: default_lockout_threshold(), + unlock_grace_ms: default_unlock_grace_ms(), + valkey_pool_size: default_valkey_pool_size(), + } + } +} + +const fn default_lockout_threshold() -> u32 { + 5 +} + +const fn default_unlock_grace_ms() -> u32 { + 2_000 +} + +const fn default_signin_per_token() -> RateLimitBudget { + RateLimitBudget::SIGNIN_PER_TOKEN_DEFAULT +} + +const fn default_pat_per_minute() -> RateLimitBudget { + RateLimitBudget::PAT_PER_MINUTE_DEFAULT +} + +fn default_valkey_pool_size() -> usize { + num_cpus::get() +} + +/// Session-resolver policy. +/// +/// Tunes the gateway-facing fast-path cache plus the issuance TTL. The +/// cache TTL splits into a healthy-mode value and a fail-closed-mode +/// value; the resolver flips between them based on the NATS health +/// probe so a partition that drops the eviction stream cannot leave +/// stale `revoked_at` rows alive in the cache for longer than the +/// fail-closed window. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct SessionConfig { + /// Lifetime of a freshly minted browser / bearer session in days. + /// `ZAGROSI_SESSION_TTL_DAYS`. Default `7`. + #[serde(default = "default_session_ttl_days")] + pub ttl_days: u32, + + /// Cache TTL applied while the NATS broker is connected and + /// processing eviction events. `ZAGROSI_SESSION_CACHE_TTL_SECS`. + /// Default `30`. Larger values trade revocation latency for fewer + /// DB round-trips on the cache-miss path. + #[serde(default = "default_session_cache_ttl_secs")] + pub cache_ttl_secs: u32, + + /// Cache TTL applied when the NATS broker is unreachable. The + /// resolver flips to this TTL on the next health-tick interval so + /// stale revocations cannot survive the partition. + /// `ZAGROSI_SESSION_FAIL_CLOSED_TTL_SECS`. Default `1`. + #[serde(default = "default_session_fail_closed_ttl_secs")] + pub fail_closed_ttl_secs: u32, + + /// Cache size cap (entries). The moka cache evicts least-recently- + /// used entries when this size is exceeded. + /// `ZAGROSI_SESSION_CACHE_CAPACITY`. Default `50_000`. + #[serde(default = "default_session_cache_capacity")] + pub cache_capacity: u64, + + /// NATS broker URL. Empty disables cross-replica eviction; the + /// resolver still ships the password-update invariant + the + /// fail-closed cache TTL so the 1-second revocation SLA is met + /// even without a broker. + /// `ZAGROSI_SESSION_NATS_URL`. Default empty. + #[serde(default)] + pub nats_url: String, + + /// Health-probe tick interval, in seconds. The resolver polls the + /// NATS connection state at this cadence and flips the cache TTL + /// between healthy and fail-closed values when the state changes. + /// `ZAGROSI_SESSION_HEALTH_TICK_SECS`. Default `1`. + #[serde(default = "default_session_health_tick_secs")] + pub health_tick_secs: u32, + + /// Bound on the in-memory `last_seen_at` write-behind channel. + /// Updates are coalesced server-side once per session per minute; + /// channel-full drops the update silently because `last_seen_at` + /// is best-effort metadata, not a security primitive. + /// `ZAGROSI_SESSION_LAST_SEEN_BUFFER`. Default `10_000`. + #[serde(default = "default_session_last_seen_buffer")] + pub last_seen_buffer: usize, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + ttl_days: default_session_ttl_days(), + cache_ttl_secs: default_session_cache_ttl_secs(), + fail_closed_ttl_secs: default_session_fail_closed_ttl_secs(), + cache_capacity: default_session_cache_capacity(), + nats_url: String::new(), + health_tick_secs: default_session_health_tick_secs(), + last_seen_buffer: default_session_last_seen_buffer(), + } + } +} + +impl SessionConfig { + /// Validate inter-field invariants. Run from + /// [`IdentityConfig::load`] so misconfigured deploys fail at + /// startup rather than browning out under load. + /// + /// # Errors + /// + /// Returns [`IdentityError::MalformedRateLimit`] (the closest + /// existing variant for runtime-config validation) when an + /// invariant is violated. + pub fn validate(&self) -> Result<()> { + if self.ttl_days == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.ttl_days must be > 0".into(), + }); + } + if self.cache_ttl_secs == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.cache_ttl_secs must be > 0".into(), + }); + } + if self.fail_closed_ttl_secs == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.fail_closed_ttl_secs must be > 0".into(), + }); + } + if self.fail_closed_ttl_secs > self.cache_ttl_secs { + return Err(IdentityError::MalformedSessionConfig { + reason: format!( + "session.fail_closed_ttl_secs ({}) must be <= cache_ttl_secs ({})", + self.fail_closed_ttl_secs, self.cache_ttl_secs, + ), + }); + } + if self.cache_capacity == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.cache_capacity must be > 0".into(), + }); + } + if self.health_tick_secs == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.health_tick_secs must be > 0".into(), + }); + } + if self.last_seen_buffer == 0 { + return Err(IdentityError::MalformedSessionConfig { + reason: "session.last_seen_buffer must be > 0".into(), + }); + } + Ok(()) + } +} + +const fn default_session_ttl_days() -> u32 { + 7 +} + +const fn default_session_cache_ttl_secs() -> u32 { + 30 +} + +const fn default_session_fail_closed_ttl_secs() -> u32 { + 1 +} + +const fn default_session_cache_capacity() -> u64 { + 50_000 +} + +const fn default_session_health_tick_secs() -> u32 { + 1 +} + +const fn default_session_last_seen_buffer() -> usize { + 10_000 +} + +/// Multi-IdP routing DNS verification policy. +/// +/// Drives the domain-ownership challenge flow consumed by +/// [`crate::routing::domain_verify`]. The `resolvers` field is a +/// comma-separated list of DNSSEC-validating resolver IPs; the +/// production default (`1.1.1.1,9.9.9.9`) names two independently +/// operated upstreams so a single-resolver compromise cannot grant +/// an attacker-controlled domain to an attacker-controlled `IdP`. +/// +/// Validated by [`DnsConfig::validate`] at startup: at least two +/// resolvers MUST be configured, every entry MUST parse as an IP, +/// the verify TTL MUST be > 0 minutes, and the per-resolver timeout +/// MUST be > 0 ms. Misconfiguration refuses startup rather than +/// silently weakening the verification root-of-trust. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct DnsConfig { + /// Comma-separated list of DNSSEC-validating resolver IPs. + /// `ZAGROSI_DNS_RESOLVERS`. Default `"1.1.1.1,9.9.9.9"`. Min 2 + /// entries enforced at startup. + #[serde(default = "default_dns_resolvers")] + pub resolvers: String, + /// Cache TTL for resolver lookups, in minutes. The Moka cache + /// keyed by `(domain, challenge_token)` short-circuits repeated + /// verify attempts within this window. `ZAGROSI_DNS_VERIFY_TTL_MINUTES`. + /// Default `10`. + #[serde(default = "default_dns_verify_ttl_minutes")] + pub verify_ttl_minutes: u32, + /// Per-resolver query timeout, in milliseconds. Bounds tail + /// latency on the verify path so a slow upstream cannot stall + /// the admin SPA. `ZAGROSI_DNS_VERIFY_TIMEOUT_MS`. Default + /// `5000`. + #[serde(default = "default_dns_verify_timeout_ms")] + pub verify_timeout_ms: u32, + /// Cache capacity bound (entries). Defends against an admin + /// spamming verify across many domains. + /// `ZAGROSI_DNS_CACHE_CAPACITY`. Default `10_000`. + #[serde(default = "default_dns_cache_capacity")] + pub cache_capacity: u64, +} + +impl Default for DnsConfig { + fn default() -> Self { + Self { + resolvers: default_dns_resolvers(), + verify_ttl_minutes: default_dns_verify_ttl_minutes(), + verify_timeout_ms: default_dns_verify_timeout_ms(), + cache_capacity: default_dns_cache_capacity(), + } + } +} + +impl DnsConfig { + /// Parse [`DnsConfig::resolvers`] into a vec of IPs. Empty + /// elements are dropped (so `"1.1.1.1, 9.9.9.9"` and + /// `"1.1.1.1,,9.9.9.9"` both parse cleanly). + /// + /// # Errors + /// + /// Returns [`IdentityError::MalformedDnsConfig`] when an entry + /// fails to parse as an IP address. + pub fn parsed_resolvers(&self) -> Result> { + let mut out = Vec::new(); + for raw in self.resolvers.split(',') { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let ip: std::net::IpAddr = + trimmed + .parse() + .map_err(|_| IdentityError::MalformedDnsConfig { + reason: format!("`{trimmed}` is not a valid IP address"), + })?; + out.push(ip); + } + Ok(out) + } + + /// Validate inter-field invariants. + /// + /// At least two resolvers MUST be configured (single-resolver + /// verification is a weaker root-of-trust). The verify TTL and + /// per-resolver timeout MUST be > 0; the cache capacity MUST + /// be > 0. + /// + /// # Errors + /// + /// Returns [`IdentityError::MalformedDnsConfig`] for any + /// invariant violation. + pub fn validate(&self) -> Result<()> { + let resolvers = self.parsed_resolvers()?; + if resolvers.len() < 2 { + return Err(IdentityError::MalformedDnsConfig { + reason: format!( + "ZAGROSI_DNS_RESOLVERS must list at least 2 resolvers (got {})", + resolvers.len() + ), + }); + } + // Reject duplicates: `1.1.1.1,1.1.1.1` would collapse the + // dual-resolver trust model to a single upstream while still + // satisfying the >=2 length guard. Dedupe via a temporary + // BTreeSet so the error message can name the duplicate IP. + let mut seen = std::collections::BTreeSet::new(); + for ip in &resolvers { + if !seen.insert(*ip) { + return Err(IdentityError::MalformedDnsConfig { + reason: format!( + "ZAGROSI_DNS_RESOLVERS contains duplicate entry `{ip}`; \ + dual-resolver trust model requires distinct upstreams", + ), + }); + } + } + if self.verify_ttl_minutes == 0 { + return Err(IdentityError::MalformedDnsConfig { + reason: "dns.verify_ttl_minutes must be > 0".into(), + }); + } + if self.verify_timeout_ms == 0 { + return Err(IdentityError::MalformedDnsConfig { + reason: "dns.verify_timeout_ms must be > 0".into(), + }); + } + if self.cache_capacity == 0 { + return Err(IdentityError::MalformedDnsConfig { + reason: "dns.cache_capacity must be > 0".into(), + }); + } + Ok(()) + } +} + +fn default_dns_resolvers() -> String { + "1.1.1.1,9.9.9.9".to_string() +} + +const fn default_dns_verify_ttl_minutes() -> u32 { + 10 +} + +const fn default_dns_verify_timeout_ms() -> u32 { + 5_000 +} + +const fn default_dns_cache_capacity() -> u64 { + 10_000 +} + +/// Outbound-SMTP policy for the email-outbox worker. +/// +/// `smtp_url` is an RFC-style connection URL parsed by +/// [`lettre::AsyncSmtpTransport::from_url`]. The email-outbox design +/// mandates implicit TLS, so [`crate::email::LettreTransport::from_config`] +/// rejects any scheme other than `smtps://`. `smtp_from` is the +/// envelope/header `From:` mailbox applied to every outbound message +/// (per-tenant override is deferred to the admin layer). +/// +/// Both fields default empty. Validation is deferred to +/// [`crate::email::LettreTransport::from_config`] rather than +/// [`IdentityConfig::load`] so binaries that load the config without +/// running the worker (gateway read-replica, migration-smoke) start +/// cleanly without SMTP configured. +#[derive(Clone, Default, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct EmailConfig { + /// SMTP connection URL. `ZAGROSI_EMAIL.SMTP_URL`. MUST be + /// `smtps://[user[:pass]@]host[:port]` — implicit TLS only. The + /// password component is part of the URL; the surrounding + /// `IdentityConfig` `Debug` impl renders this field as + /// `` so a credentialed URL never reaches a log line. + pub smtp_url: String, + /// Sender mailbox applied to every outbound message, e.g. + /// `"Zagrosi "`. `ZAGROSI_EMAIL.SMTP_FROM`. + pub smtp_from: String, +} + +impl EmailConfig { + /// `true` when neither field is set — the worker is disabled and + /// the deploy never attempts SMTP. + #[must_use] + pub const fn is_unset(&self) -> bool { + self.smtp_url.is_empty() && self.smtp_from.is_empty() + } +} + +impl std::fmt::Debug for EmailConfig { + /// `smtp_url` may embed `user:password@`; render only whether it + /// is set so a `tracing::debug!(?cfg)` cannot exfiltrate the SMTP + /// credential. `smtp_from` is not a secret and renders verbatim. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("EmailConfig") + .field( + "smtp_url", + if self.smtp_url.is_empty() { + &"" + } else { + &"" + }, + ) + .field("smtp_from", &self.smtp_from) + .finish() + } +} + +/// Platform-administration policy. +/// +/// `admin_user_ids` is the interim service-token issuance gate: the +/// `/v1/service-tokens` routes accept only a session whose +/// `subject_id` is in this list. Empty (the default) means no +/// platform admins are configured, so every caller is refused — a +/// fail-closed default. Replaced by a real RBAC role check when the +/// tenant-isolation layer lands; until then this env/TOML allowlist +/// is the source of truth. +/// +/// `ZAGROSI_PLATFORM.ADMIN_USER_IDS` — comma/array of UUIDs. +#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case", default)] +pub struct PlatformConfig { + /// User IDs permitted to mint / revoke service tokens. The list + /// is small and admin-managed; lookup is a linear scan. + pub admin_user_ids: Vec, +} + +impl PlatformConfig { + /// `true` when `user_id` is a configured platform admin. + #[must_use] + pub fn is_admin(&self, user_id: uuid::Uuid) -> bool { + self.admin_user_ids.contains(&user_id) + } +} + +impl RateLimitConfig { + /// Validate inter-field invariants. + /// + /// `lockout_max_hours * 60` MUST be >= `lockout_initial_minutes` so + /// the exponential backoff cap actually exceeds the first lockout + /// (otherwise the limiter would clamp before the first breach + /// completed). `lockout_threshold` MUST be > 0 so a single failed + /// sign-in cannot lock an account. + /// + /// # Errors + /// + /// Returns [`IdentityError::MalformedRateLimit`] when any invariant + /// is violated. + pub fn validate(&self) -> Result<()> { + if self.lockout_threshold == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "lockout_threshold must be > 0".into(), + }); + } + if self.lockout_initial_minutes == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "lockout_initial_minutes must be > 0".into(), + }); + } + if self.lockout_max_hours == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "lockout_max_hours must be > 0".into(), + }); + } + let cap_minutes = self.lockout_max_hours.saturating_mul(60); + if cap_minutes < self.lockout_initial_minutes { + return Err(IdentityError::MalformedRateLimit { + reason: format!( + "lockout_max_hours ({}) * 60 = {} < lockout_initial_minutes ({})", + self.lockout_max_hours, cap_minutes, self.lockout_initial_minutes, + ), + }); + } + if self.unlock_grace_ms == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "unlock_grace_ms must be > 0".into(), + }); + } + if self.valkey_pool_size == 0 { + return Err(IdentityError::MalformedRateLimit { + reason: "valkey_pool_size must be > 0".into(), + }); + } + Ok(()) + } + + /// Unlock-grace window, in milliseconds, exposed to the Lua + /// state machine as `ARGV[5]` for the lockout script and `ARGV[1]` + /// for the unlock script. + #[must_use] + pub const fn unlock_grace_ms(&self) -> u64 { + self.unlock_grace_ms as u64 + } + + /// History-key retention TTL, in milliseconds. The hash holding + /// `attempts` / `backoff_ms` / `last_locked_ms` lives at least + /// this long so escalation memory survives the active lockout + /// window plus a comfortable margin. Defaults to twice + /// [`RateLimitConfig::max_backoff_ms`] with a 1-hour floor so a + /// short cap (e.g. 1h for tests) still keeps history addressable + /// past the typical attacker pause. + #[must_use] + pub fn history_ttl_ms(&self) -> u64 { + let doubled = self.max_backoff_ms().saturating_mul(2); + doubled.max(3_600_000) + } + + /// Initial lockout backoff, in milliseconds. + #[must_use] + pub const fn initial_backoff_ms(&self) -> u64 { + (self.lockout_initial_minutes as u64).saturating_mul(60_000) + } + + /// Lockout cap, in milliseconds. + #[must_use] + pub const fn max_backoff_ms(&self) -> u64 { + (self.lockout_max_hours as u64).saturating_mul(3_600_000) + } +} + +impl std::fmt::Debug for IdentityConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityConfig") + .field("secrets_key", &"") + .field("valkey_url", &self.valkey_url) + .field("argon2", &self.argon2) + .field("password", &self.password) + .field("breachlist", &self.breachlist) + .field("email_token_ttl_minutes", &self.email_token_ttl_minutes) + .field("rate_limit", &self.rate_limit) + .field("session", &self.session) + .field("dns", &self.dns) + .field("email", &self.email) + .field("platform", &self.platform) + .field("decoded_secrets_key", &self.decoded_secrets_key) + .finish() + } +} + +/// Options accepted by [`IdentityConfig::load`]. +/// +/// Duplicates the shape of `zagrosi_core::config::LoadOptions` rather +/// than importing it. Keeps the boundary clean so later sections can +/// add identity-specific options without coupling the two crates' +/// loader contracts. +#[derive(Debug, Default, Clone, Copy)] +pub struct LoadOptions<'a> { + /// Environment variable prefix. Conventionally `"ZAGROSI_"`. + pub env_prefix: &'a str, + /// Optional path to a TOML configuration file. + pub file_path: Option<&'a std::path::Path>, +} + +impl IdentityConfig { + /// Load configuration from environment variables and (optionally) a + /// TOML file. + /// + /// Mirrors `zagrosi_core::CoreConfig::load`. After figment merges + /// the layers, validates that: + /// + /// - `ZAGROSI_SECRETS_KEY` is present and decodes to exactly 32 + /// bytes of base64. + /// - `ZAGROSI_VALKEY_URL` is present and non-empty. + /// + /// # Errors + /// + /// - [`IdentityError::Config`] when env values or file contents + /// fail to deserialise into [`IdentityConfig`]. + /// - [`IdentityError::MissingSecretsKey`] if the env var is absent + /// or empty. + /// - [`IdentityError::MalformedSecretsKey`] if the value is not + /// base64 or does not decode to exactly 32 bytes. + /// - [`IdentityError::MissingValkeyUrl`] if the env var is absent + /// or empty. + pub fn load(opts: LoadOptions<'_>) -> Result { + let mut figment = Figment::new(); + if let Some(path) = opts.file_path { + figment = figment.merge(Toml::file(path)); + } + figment = figment.merge(Env::prefixed(opts.env_prefix)); + let mut cfg: Self = figment.extract()?; + let decoded_bytes = cfg.validate_and_decode()?; + cfg.rate_limit.validate()?; + cfg.session.validate()?; + cfg.dns.validate()?; + cfg.decoded_secrets_key = DecodedSecretsKey(Some(decoded_bytes)); + Ok(cfg) + } + + fn validate_and_decode(&self) -> Result> { + if self.secrets_key.is_empty() { + return Err(IdentityError::MissingSecretsKey); + } + let mut decoded = BASE64_STANDARD.decode(&self.secrets_key).map_err(|_| { + IdentityError::MalformedSecretsKey { + reason: "not valid base64".into(), + } + })?; + if decoded.len() != SECRETS_KEY_LEN { + let actual_len = decoded.len(); + decoded.zeroize(); + return Err(IdentityError::MalformedSecretsKey { + reason: format!("decoded length {actual_len} bytes, expected {SECRETS_KEY_LEN}"), + }); + } + if self.valkey_url.is_empty() { + decoded.zeroize(); + return Err(IdentityError::MissingValkeyUrl); + } + // Password policy floor. NIST SP 800-63B sets the 8-char floor; + // the project chose 12 chars. + if self.password.min_length < 12 { + decoded.zeroize(); + return Err(IdentityError::PasswordTooShort { + min: self.password.min_length, + }); + } + // Allocate the master-key slot directly on the heap and copy into + // it so the only authoritative copy lives behind a pointer that + // `DecodedSecretsKey::Drop` can zeroize. The intermediate + // `decoded: Vec` is then explicitly zeroized before drop. + let mut boxed: Box<[u8; SECRETS_KEY_LEN]> = Box::new([0_u8; SECRETS_KEY_LEN]); + boxed.copy_from_slice(&decoded); + decoded.zeroize(); + Ok(boxed) + } + + /// Borrow the decoded 32-byte master key. + /// + /// # Errors + /// + /// Returns [`IdentityError::MissingSecretsKey`] when the config was + /// constructed via [`Default::default`] without a subsequent + /// successful [`IdentityConfig::load`]. Production call sites + /// (`crypto::Secrets::from_config`) only ever see a `load`-validated + /// config so this branch is purely for misuse safety. + pub fn secrets_key(&self) -> Result<&[u8; SECRETS_KEY_LEN]> { + self.decoded_secrets_key + .0 + .as_deref() + .ok_or(IdentityError::MissingSecretsKey) + } + + /// Take ownership of the decoded master key, leaving `None` behind. + /// + /// Used by `crypto::Secrets::from_config` to move the boxed key + /// straight into a `SecretBox` without producing a stack-frame copy + /// of the underlying 32 bytes. + /// + /// # Errors + /// + /// Returns [`IdentityError::MissingSecretsKey`] when the config was + /// not successfully `load`-ed. + pub(crate) fn take_secrets_key(&mut self) -> Result> { + self.decoded_secrets_key + .0 + .take() + .ok_or(IdentityError::MissingSecretsKey) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(IdentityConfig: Send, Sync); + assert_impl_all!(LoadOptions<'static>: Send, Sync, Copy); + + /// Base64-encoded zero-filled 32-byte key. Decodes to exactly + /// 32 bytes; suitable for tests that only need the validation + /// path to accept. + const VALID_SECRETS_KEY_B64: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + + /// Base64-encoded zero-filled 16-byte value. Valid base64 but + /// shorter than the required 32 bytes. + const SHORT_SECRETS_KEY_B64: &str = "AAAAAAAAAAAAAAAAAAAAAA=="; + + #[test] + fn missing_secrets_key_returns_missing_secrets_key() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MissingSecretsKey) => Ok(()), + other => Err(figment::Error::from(format!( + "expected MissingSecretsKey, got {other:?}" + ))), + } + }); + } + + #[test] + fn malformed_secrets_key_non_base64_returns_malformed() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", "!!!not-base64!!!"); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MalformedSecretsKey { reason }) => { + assert!( + reason.contains("base64"), + "expected reason to mention base64, got: {reason}" + ); + Ok(()) + } + other => Err(figment::Error::from(format!( + "expected MalformedSecretsKey, got {other:?}" + ))), + } + }); + } + + #[test] + fn malformed_secrets_key_wrong_length_returns_malformed() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", SHORT_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MalformedSecretsKey { reason }) => { + assert!( + reason.contains("16 bytes"), + "expected reason to name actual length `16 bytes`, got: {reason}" + ); + Ok(()) + } + other => Err(figment::Error::from(format!( + "expected MalformedSecretsKey, got {other:?}" + ))), + } + }); + } + + #[test] + fn valid_32_byte_base64_passes_secrets_validation() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + assert_eq!(cfg.secrets_key, VALID_SECRETS_KEY_B64); + assert_eq!(cfg.valkey_url, "redis://valkey:6379"); + Ok(()) + }); + } + + #[test] + fn missing_valkey_url_returns_missing() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MissingValkeyUrl) => Ok(()), + other => Err(figment::Error::from(format!( + "expected MissingValkeyUrl, got {other:?}" + ))), + } + }); + } + + #[test] + fn valkey_url_round_trips() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey-test:6379"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + assert_eq!(cfg.valkey_url, "redis://valkey-test:6379"); + Ok(()) + }); + } + + #[test] + fn unknown_fields_in_file_are_tolerated() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file("test.toml", "unknown_future_field = \"ignored\"\n")?; + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let path = jail.directory().join("test.toml"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: Some(&path), + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + assert_eq!(cfg.secrets_key, VALID_SECRETS_KEY_B64); + assert_eq!(cfg.valkey_url, "redis://valkey:6379"); + Ok(()) + }); + } + + #[test] + fn file_only_loads_configuration() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file( + "test.toml", + &format!( + "secrets_key = \"{VALID_SECRETS_KEY_B64}\"\nvalkey_url = \"redis://from-file:6379\"\n" + ), + )?; + let path = jail.directory().join("test.toml"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: Some(&path), + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + assert_eq!(cfg.secrets_key, VALID_SECRETS_KEY_B64); + assert_eq!(cfg.valkey_url, "redis://from-file:6379"); + Ok(()) + }); + } + + #[test] + fn empty_secrets_key_env_returns_missing() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", ""); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MissingSecretsKey) => Ok(()), + other => Err(figment::Error::from(format!( + "expected MissingSecretsKey for empty env, got {other:?}" + ))), + } + }); + } + + #[test] + fn empty_valkey_url_env_returns_missing() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", ""); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MissingValkeyUrl) => Ok(()), + other => Err(figment::Error::from(format!( + "expected MissingValkeyUrl for empty env, got {other:?}" + ))), + } + }); + } + + #[test] + fn env_overrides_file_value() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.create_file("test.toml", "valkey_url = \"redis://from-file:6379\"\n")?; + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://from-env:6379"); + let path = jail.directory().join("test.toml"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: Some(&path), + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + assert_eq!(cfg.valkey_url, "redis://from-env:6379"); + Ok(()) + }); + } + + #[test] + fn debug_does_not_leak_master_key() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + let rendered = format!("{cfg:?}"); + assert!(rendered.contains("redacted")); + assert!( + !rendered.contains(VALID_SECRETS_KEY_B64), + "Debug must not leak the base64 master key" + ); + assert!( + !rendered.contains("AAAAAAA"), + "Debug must not leak any prefix of the base64 master key" + ); + Ok(()) + }); + } + + #[test] + fn dns_default_resolvers_parse_and_validate() { + let cfg = DnsConfig::default(); + cfg.validate() + .unwrap_or_else(|e| panic!("default DnsConfig must validate: {e}")); + let parsed = cfg + .parsed_resolvers() + .unwrap_or_else(|e| panic!("default resolvers must parse: {e}")); + assert_eq!(parsed.len(), 2, "default ships exactly two resolvers"); + } + + #[test] + fn dns_validate_rejects_duplicate_resolvers() { + // CX-2 regression: `1.1.1.1,1.1.1.1` would pass the + // length>=2 guard but collapse the dual-resolver trust + // model to a single upstream. Reject at startup. + let cfg = DnsConfig { + resolvers: "1.1.1.1,1.1.1.1".to_string(), + ..DnsConfig::default() + }; + let err = cfg + .validate() + .expect_err("duplicate resolver IPs must reject"); + match err { + IdentityError::MalformedDnsConfig { reason } => { + assert!( + reason.contains("duplicate"), + "reason should mention duplicate, got: {reason}" + ); + } + other => panic!("expected MalformedDnsConfig, got {other:?}"), + } + } + + #[test] + fn dns_validate_rejects_single_resolver() { + let cfg = DnsConfig { + resolvers: "1.1.1.1".to_string(), + ..DnsConfig::default() + }; + let err = cfg.validate().expect_err("single resolver must reject"); + match err { + IdentityError::MalformedDnsConfig { reason } => { + assert!( + reason.contains("at least 2"), + "reason should mention 2-resolver minimum, got: {reason}" + ); + } + other => panic!("expected MalformedDnsConfig, got {other:?}"), + } + } + + #[test] + fn dns_validate_rejects_empty_resolvers() { + let cfg = DnsConfig { + resolvers: String::new(), + ..DnsConfig::default() + }; + assert!(matches!( + cfg.validate().unwrap_err(), + IdentityError::MalformedDnsConfig { .. } + )); + } + + #[test] + fn dns_validate_rejects_unparseable_ip() { + let cfg = DnsConfig { + resolvers: "1.1.1.1,not-an-ip".to_string(), + ..DnsConfig::default() + }; + let err = cfg.validate().expect_err("non-IP entry must reject"); + match err { + IdentityError::MalformedDnsConfig { reason } => { + assert!( + reason.contains("not-an-ip"), + "reason should echo offending entry, got: {reason}" + ); + } + other => panic!("expected MalformedDnsConfig, got {other:?}"), + } + } + + #[test] + fn dns_validate_rejects_zero_ttl() { + let cfg = DnsConfig { + verify_ttl_minutes: 0, + ..DnsConfig::default() + }; + assert!(matches!( + cfg.validate().unwrap_err(), + IdentityError::MalformedDnsConfig { .. } + )); + } + + #[test] + fn dns_validate_rejects_zero_timeout() { + let cfg = DnsConfig { + verify_timeout_ms: 0, + ..DnsConfig::default() + }; + assert!(matches!( + cfg.validate().unwrap_err(), + IdentityError::MalformedDnsConfig { .. } + )); + } + + #[test] + fn dns_parsed_resolvers_skips_blank_entries() { + let cfg = DnsConfig { + resolvers: "1.1.1.1, ,9.9.9.9, ".to_string(), + ..DnsConfig::default() + }; + let parsed = cfg + .parsed_resolvers() + .unwrap_or_else(|e| panic!("blank-elision parse: {e}")); + assert_eq!(parsed.len(), 2); + } + + #[test] + fn identity_config_load_rejects_single_resolver_at_startup() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + jail.set_env("ZAGROSI_DNS.RESOLVERS", "1.1.1.1"); + let result = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }); + match result { + Err(IdentityError::MalformedDnsConfig { .. }) => Ok(()), + other => Err(figment::Error::from(format!( + "expected MalformedDnsConfig for single resolver, got {other:?}" + ))), + } + }); + } + + #[test] + fn serialize_skips_secrets_key_field() { + figment::Jail::expect_with(|jail| { + jail.clear_env(); + jail.set_env("ZAGROSI_SECRETS_KEY", VALID_SECRETS_KEY_B64); + jail.set_env("ZAGROSI_VALKEY_URL", "redis://valkey:6379"); + let cfg = IdentityConfig::load(LoadOptions { + env_prefix: "ZAGROSI_", + file_path: None, + }) + .map_err(|e| figment::Error::from(e.to_string()))?; + let rendered = + serde_json::to_string(&cfg).map_err(|e| figment::Error::from(e.to_string()))?; + assert!( + !rendered.contains(VALID_SECRETS_KEY_B64), + "serde_json::to_string must not emit the master key" + ); + assert!( + !rendered.contains("secrets_key"), + "secrets_key field must be skip_serialized" + ); + assert!(rendered.contains("valkey_url")); + Ok(()) + }); + } +} diff --git a/crates/zagrosi-identity/src/crypto/mod.rs b/crates/zagrosi-identity/src/crypto/mod.rs new file mode 100644 index 0000000..1e87a09 --- /dev/null +++ b/crates/zagrosi-identity/src/crypto/mod.rs @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Cryptographic shims for the identity crate. +//! +//! The secrets shim ships [`Secrets`], an AES-256-GCM envelope wrapper +//! for every persisted secret that downstream layers (OIDC +//! `client_secret`, SAML SP signing keys, future SMTP credentials) need +//! to hand to Postgres. +//! +//! The wire format `{key_id, nonce, ciphertext, tag}` is forward-compatible +//! with the future KMS layer's KMS-backed envelope rewrap: the KMS layer +//! will introduce additional `key_id` values (`v0.2-kms-`), +//! and the v0.1 shim already returns +//! [`crate::error::IdentityError::UnknownKeyId`] for anything other than +//! [`KEY_ID_V0_1_STATIC`] so the rewrap can route by `key_id` without +//! breaking the public surface. + +pub mod secrets; + +pub use secrets::{Envelope, KEY_ID_V0_1_STATIC, NONCE_LEN, Secrets, TAG_LEN}; diff --git a/crates/zagrosi-identity/src/crypto/secrets.rs b/crates/zagrosi-identity/src/crypto/secrets.rs new file mode 100644 index 0000000..1072706 --- /dev/null +++ b/crates/zagrosi-identity/src/crypto/secrets.rs @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! AES-256-GCM envelope encryption for persisted secrets. +//! +//! v0.1 wraps every stored secret under the static key sourced from +//! `ZAGROSI_SECRETS_KEY` (32-byte base64). The wire envelope +//! `{key_id, nonce, ciphertext, tag}` is forward-compatible with +//! the KMS layer's KMS-backed envelope rewrap: future `key_id` values +//! (`v0.2-kms-`) route through the new provider, while the +//! static `v0.1-static` envelopes keep decrypting under this shim. + +use aes_gcm::aead::AeadInPlace; +use aes_gcm::{Aes256Gcm, Key, KeyInit, Nonce, Tag}; +use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use rand_core::{OsRng, RngCore}; +use secrecy::{ExposeSecret, SecretBox}; +use serde::{Deserialize, Serialize}; + +use crate::config::{IdentityConfig, SECRETS_KEY_LEN}; +use crate::error::IdentityError; + +/// Static `key_id` for the v0.1 single-key shim. +/// +/// The KMS layer introduces additional KMS-backed `key_id` values; today the +/// shim only decrypts envelopes carrying this exact discriminator. +/// +/// **Production code MUST NOT hard-code this constant for routing +/// decisions.** Use [`IdentityError::UnknownKeyId`] from +/// [`Secrets::open`] as the routing signal so the KMS layer's KMS provider +/// can claim envelopes it owns. This constant is exposed for tests + +/// rare authoring of v0.1 envelopes (e.g. seed corpora) only. +pub const KEY_ID_V0_1_STATIC: &str = "v0.1-static"; + +/// AES-GCM nonce length in bytes (96-bit per `RustCrypto` guidance). +pub const NONCE_LEN: usize = 12; + +/// AES-GCM authentication tag length in bytes. +pub const TAG_LEN: usize = 16; + +/// Wire envelope persisted to the DB. +/// +/// Stored verbatim in columns like `oidc_idps.client_secret_ref` and +/// `org_idps.config.sp_signing_key`. Forward-compatible with KMS rewrap +/// because every field of the next envelope generation is also expressible +/// in this shape; only `key_id` needs to widen. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Envelope { + /// Discriminator the decryption-side uses to route to the correct + /// key provider. v0.1 always emits [`KEY_ID_V0_1_STATIC`]. + pub key_id: String, + /// Base64-encoded 12-byte nonce. Each [`Secrets::seal`] call mints a + /// fresh nonce via [`OsRng`]; nonce reuse under the same key is a + /// catastrophic AES-GCM failure mode and is intentionally not + /// reachable from the public API. + pub nonce: String, + /// Base64-encoded ciphertext (excludes the GCM tag). + pub ciphertext: String, + /// Base64-encoded 16-byte GCM authentication tag. Stored separately + /// from `ciphertext` so envelopes are human-inspectable in the DB + /// without losing AEAD semantics. + pub tag: String, +} + +/// AES-256-GCM secrets shim. +/// +/// Construct via [`Secrets::from_key`] (caller-supplied 32-byte key) or +/// [`Secrets::from_config`] (reads `ZAGROSI_SECRETS_KEY` via +/// [`IdentityConfig`]). [`Secrets`] is `Send + Sync`; production wiring +/// shares it via `Arc` inside `IdentityState` (lands with the OIDC client). +pub struct Secrets { + /// Master key. Wrapped in [`SecretBox`] so [`Drop`] zeroes the bytes. + key: SecretBox<[u8; SECRETS_KEY_LEN]>, + /// Discriminator written into every [`Envelope::key_id`]. + key_id: String, +} + +impl Secrets { + /// Build from a heap-resident 32-byte raw key, using + /// [`KEY_ID_V0_1_STATIC`] as the envelope discriminator. + /// + /// Accepting `Box<[u8; 32]>` (rather than `[u8; 32]` by value) keeps + /// the master key on the heap throughout construction — there is no + /// stack-frame slot that ends up holding the raw bytes after + /// [`Box::new`] is moved into [`SecretBox`]. The bytes are then + /// zeroized by [`SecretBox`]'s `Drop` impl. + /// + /// Test code typically writes `Secrets::from_key(Box::new([0x42; 32]))`. + #[must_use] + pub fn from_key(key: Box<[u8; SECRETS_KEY_LEN]>) -> Self { + Self { + key: SecretBox::new(key), + key_id: KEY_ID_V0_1_STATIC.to_owned(), + } + } + + /// Build from a validated [`IdentityConfig`], moving the master key + /// out of the config without producing an intermediate stack copy. + /// + /// The config's `decoded_secrets_key` field is left as `None` after + /// the call returns; subsequent `cfg.secrets_key()` calls would + /// surface [`IdentityError::MissingSecretsKey`]. Callers that need + /// to build multiple `Secrets` from one config should `Arc` + /// after the first construction. + /// + /// # Errors + /// + /// Returns [`IdentityError::MissingSecretsKey`] when the config was + /// not constructed via [`IdentityConfig::load`] (i.e. the decoded + /// master key has not been populated). + pub fn from_config(cfg: &mut IdentityConfig) -> Result { + let boxed = cfg.take_secrets_key()?; + Ok(Self::from_key(boxed)) + } + + /// AEAD-seal a plaintext, returning the wire [`Envelope`] ready to + /// persist. Generates a fresh 96-bit nonce per call via [`OsRng`]. + /// + /// # Errors + /// + /// Returns [`IdentityError::IntegrityError`] when the underlying AEAD + /// primitive fails to produce a tag (`RustCrypto`'s `aes-gcm` documents + /// this as practically unreachable on supported architectures, but + /// the error is surfaced typed rather than via panic to satisfy the + /// workspace `unwrap_used = deny` lint). + pub fn seal(&self, plaintext: &[u8]) -> Result { + let cipher = Aes256Gcm::new(Key::::from_slice(self.key.expose_secret())); + let mut nonce_bytes = [0_u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let mut buffer = plaintext.to_vec(); + let tag = cipher + .encrypt_in_place_detached(nonce, &[], &mut buffer) + .map_err(|_| IdentityError::IntegrityError)?; + Ok(Envelope { + key_id: self.key_id.clone(), + nonce: BASE64_STANDARD.encode(nonce_bytes), + ciphertext: BASE64_STANDARD.encode(&buffer), + tag: BASE64_STANDARD.encode(tag.as_slice()), + }) + } + + /// AEAD-open an envelope, returning plaintext bytes. + /// + /// # Errors + /// + /// - [`IdentityError::UnknownKeyId`] when `env.key_id` is anything + /// other than this provider's configured `key_id`. The KMS layer routes + /// on this error so its KMS provider can claim the envelope. + /// - [`IdentityError::MalformedEnvelope`] when one of the base64 + /// fields fails to decode or has the wrong byte length. + /// - [`IdentityError::IntegrityError`] when the AEAD authentication + /// check fails. Constant-time: never disclose which check failed. + pub fn open(&self, env: &Envelope) -> Result, IdentityError> { + if env.key_id != self.key_id { + return Err(IdentityError::UnknownKeyId(env.key_id.clone())); + } + let nonce_bytes = BASE64_STANDARD + .decode(&env.nonce) + .map_err(|_| IdentityError::MalformedEnvelope("nonce: not valid base64"))?; + if nonce_bytes.len() != NONCE_LEN { + return Err(IdentityError::MalformedEnvelope("nonce: wrong byte length")); + } + let tag_bytes = BASE64_STANDARD + .decode(&env.tag) + .map_err(|_| IdentityError::MalformedEnvelope("tag: not valid base64"))?; + if tag_bytes.len() != TAG_LEN { + return Err(IdentityError::MalformedEnvelope("tag: wrong byte length")); + } + let mut buffer = BASE64_STANDARD + .decode(&env.ciphertext) + .map_err(|_| IdentityError::MalformedEnvelope("ciphertext: not valid base64"))?; + let cipher = Aes256Gcm::new(Key::::from_slice(self.key.expose_secret())); + let nonce = Nonce::from_slice(&nonce_bytes); + let tag = Tag::from_slice(&tag_bytes); + cipher + .decrypt_in_place_detached(nonce, &[], &mut buffer, tag) + .map_err(|_| IdentityError::IntegrityError)?; + Ok(buffer) + } +} + +impl std::fmt::Debug for Secrets { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Secrets") + .field("key_id", &self.key_id) + .field("key", &"") + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(Secrets: Send, Sync); + assert_impl_all!(Envelope: Send, Sync, Clone, std::fmt::Debug); + + /// Fixed 32-byte test key. Never used outside `#[cfg(test)]`. + const TEST_KEY: [u8; SECRETS_KEY_LEN] = [0x42; SECRETS_KEY_LEN]; + + #[test] + fn seal_open_roundtrip() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let plaintext = b"top-secret plaintext"; + let envelope = secrets + .seal(plaintext) + .unwrap_or_else(|e| panic!("seal: {e}")); + let decrypted = secrets + .open(&envelope) + .unwrap_or_else(|e| panic!("open: {e}")); + assert_eq!(decrypted, plaintext); + assert_eq!(envelope.key_id, KEY_ID_V0_1_STATIC); + } + + #[test] + fn seal_open_empty_plaintext() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let envelope = secrets.seal(&[]).unwrap_or_else(|e| panic!("seal: {e}")); + let decrypted = secrets + .open(&envelope) + .unwrap_or_else(|e| panic!("open: {e}")); + assert!(decrypted.is_empty()); + } + + #[test] + fn seal_uses_unique_nonce_per_call() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let plaintext = b"identical plaintext"; + let env_a = secrets + .seal(plaintext) + .unwrap_or_else(|e| panic!("seal a: {e}")); + let env_b = secrets + .seal(plaintext) + .unwrap_or_else(|e| panic!("seal b: {e}")); + assert_ne!(env_a.nonce, env_b.nonce, "nonces must be unique per call"); + assert_ne!( + env_a.ciphertext, env_b.ciphertext, + "fresh nonce must produce different ciphertext" + ); + } + + #[test] + fn open_rejects_tampered_ciphertext() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets + .seal(b"plaintext") + .unwrap_or_else(|e| panic!("seal: {e}")); + let mut bytes = BASE64_STANDARD + .decode(&env.ciphertext) + .unwrap_or_else(|e| panic!("decode: {e}")); + bytes[0] ^= 0x01; + env.ciphertext = BASE64_STANDARD.encode(&bytes); + let result = secrets.open(&env); + assert!(matches!(result, Err(IdentityError::IntegrityError))); + } + + #[test] + fn open_rejects_tampered_tag() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets + .seal(b"plaintext") + .unwrap_or_else(|e| panic!("seal: {e}")); + let mut tag = BASE64_STANDARD + .decode(&env.tag) + .unwrap_or_else(|e| panic!("decode: {e}")); + tag[0] ^= 0x01; + env.tag = BASE64_STANDARD.encode(&tag); + let result = secrets.open(&env); + assert!(matches!(result, Err(IdentityError::IntegrityError))); + } + + #[test] + fn open_rejects_tampered_nonce() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets + .seal(b"plaintext") + .unwrap_or_else(|e| panic!("seal: {e}")); + let mut nonce = BASE64_STANDARD + .decode(&env.nonce) + .unwrap_or_else(|e| panic!("decode: {e}")); + nonce[0] ^= 0x01; + env.nonce = BASE64_STANDARD.encode(&nonce); + let result = secrets.open(&env); + assert!(matches!(result, Err(IdentityError::IntegrityError))); + } + + #[test] + fn envelope_wire_format_stable() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + let json: serde_json::Value = + serde_json::to_value(&env).unwrap_or_else(|e| panic!("serialise: {e}")); + let object = json + .as_object() + .unwrap_or_else(|| panic!("envelope must serialise to object")); + let keys: std::collections::BTreeSet<_> = object.keys().map(String::as_str).collect(); + let expected: std::collections::BTreeSet<_> = ["key_id", "nonce", "ciphertext", "tag"] + .into_iter() + .collect(); + assert_eq!(keys, expected, "envelope wire keys must not drift"); + } + + #[test] + fn open_unknown_key_id_returns_unknown_key_id() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets + .seal(b"plaintext") + .unwrap_or_else(|e| panic!("seal: {e}")); + env.key_id = "v0.2-kms-rotation-1".into(); + let result = secrets.open(&env); + match result { + Err(IdentityError::UnknownKeyId(id)) => assert_eq!(id, "v0.2-kms-rotation-1"), + other => panic!("expected UnknownKeyId, got {other:?}"), + } + } + + #[test] + fn open_rejects_non_base64_nonce() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + env.nonce = "!!!not-base64!!!".into(); + let result = secrets.open(&env); + assert!(matches!( + result, + Err(IdentityError::MalformedEnvelope("nonce: not valid base64")) + )); + } + + #[test] + fn open_rejects_non_base64_tag() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + env.tag = "!!!not-base64!!!".into(); + let result = secrets.open(&env); + assert!(matches!( + result, + Err(IdentityError::MalformedEnvelope("tag: not valid base64")) + )); + } + + #[test] + fn open_rejects_non_base64_ciphertext() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + env.ciphertext = "!!!not-base64!!!".into(); + let result = secrets.open(&env); + assert!(matches!( + result, + Err(IdentityError::MalformedEnvelope( + "ciphertext: not valid base64" + )) + )); + } + + #[test] + fn open_rejects_wrong_nonce_length() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + // Encode 8 zero bytes instead of 12. + env.nonce = BASE64_STANDARD.encode([0_u8; 8]); + let result = secrets.open(&env); + assert!(matches!( + result, + Err(IdentityError::MalformedEnvelope("nonce: wrong byte length")) + )); + } + + #[test] + fn open_rejects_wrong_tag_length() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let mut env = secrets.seal(b"x").unwrap_or_else(|e| panic!("seal: {e}")); + env.tag = BASE64_STANDARD.encode([0_u8; 8]); + let result = secrets.open(&env); + assert!(matches!( + result, + Err(IdentityError::MalformedEnvelope("tag: wrong byte length")) + )); + } + + #[test] + fn debug_does_not_leak_key_bytes() { + let secrets = Secrets::from_key(Box::new(TEST_KEY)); + let rendered = format!("{secrets:?}"); + assert!(rendered.contains("redacted")); + // 0x42 == 'B' — the rendered string would contain a literal 'B' + // ASCII run if the inner bytes were ever formatted. Spot-check + // that the entire 32-byte run does not appear verbatim. + let key_ascii: String = (0..SECRETS_KEY_LEN).map(|_| 'B').collect(); + assert!(!rendered.contains(&key_ascii)); + } +} diff --git a/crates/zagrosi-identity/src/domain/api_token.rs b/crates/zagrosi-identity/src/domain/api_token.rs new file mode 100644 index 0000000..0385f07 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/api_token.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `ApiToken` (personal access token) aggregate. + +use chrono::{DateTime, Utc}; +use std::net::IpAddr; +use uuid::Uuid; + +/// Personal access token (`pat_*`). `token_hash` is SHA-256 over the +/// full raw token (prefix included). `(token_hash, revoked_at IS NULL)` +/// is partially unique. `last_used_*` columns are best-effort +/// observability — concurrent updates may lose without consequence. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApiToken { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// SHA-256 of the raw token (`pat_<43>`). 32 bytes. + pub token_hash: [u8; 32], + /// Owning user. + pub user_id: Uuid, + /// Owning org. PAT scope is always (user, org) — see + /// the API-token surface for the broader policy model. + pub org_id: Uuid, + /// Human-set display name shown on the token-management UI. + pub display_name: String, + /// Authorisation scopes. Free-form strings consumed by future + /// policy code (the service-token surface). + pub scopes: Vec, + /// Last-used timestamp; updated by the API-token introspector. + pub last_used_at: Option>, + /// Last source IP that introspected the token. + pub last_used_ip: Option, + /// Row creation timestamp. + pub created_at: DateTime, + /// Optional hard expiry timestamp; `None` means never. + pub expires_at: Option>, + /// Revocation timestamp; `None` for live tokens. + pub revoked_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/federated.rs b/crates/zagrosi-identity/src/domain/federated.rs new file mode 100644 index 0000000..a8f59e9 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/federated.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `FederatedIdentity` (canonical SSO anchor) aggregate. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// SSO anchor row. The composite uniqueness on +/// `(protocol, issuer_or_entity_id, subject_or_nameid)` is the +/// project-wide invariant; see `documentation/identity.md`, section "SSO +/// canonical user lookup". `user_id` is `None` for tombstones; the +/// row still occupies the unique slot to prevent silent +/// re-attachment after soft-delete. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FederatedIdentity { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// `oidc` or `saml`. + pub protocol: String, + /// OIDC `iss` or SAML `EntityID`. + pub issuer_or_entity_id: String, + /// OIDC `sub` or SAML `NameID`. + pub subject_or_nameid: String, + /// IdP that produced this anchor. + pub org_idp_id: Uuid, + /// Linked user; `None` for tombstones. + pub user_id: Option, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last successful login through this anchor. + pub last_login_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/group.rs b/crates/zagrosi-identity/src/domain/group.rs new file mode 100644 index 0000000..a90ad9f --- /dev/null +++ b/crates/zagrosi-identity/src/domain/group.rs @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `Group` + `GroupMembership` aggregates for the SCIM 2.0 `Groups` +//! resource. Persisted via `repo::group_repo::GroupRepo` (multi-tenant +//! through `OrgScoped`). + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// SCIM 2.0 `Group` resource (RFC 7643 §4.2). +/// +/// Multi-tenant — every group belongs to exactly one org. The +/// `display_name` is unique per `(org_id, lower(display_name))` +/// while the row is live. `external_id` mirrors SCIM `externalId` +/// (IdP-assigned identifier). `row_version` is the per-row +/// monotonic mutation counter consumed by the SCIM ETag derivation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Group { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Owning org. + pub org_id: Uuid, + /// SCIM `displayName`. + pub display_name: String, + /// SCIM `externalId` (opaque IdP-assigned identifier). + pub external_id: Option, + /// Per-row monotonic mutation counter. + pub row_version: i64, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last-mutation timestamp. + pub updated_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} + +/// Membership join row between a [`Group`] and a `User`. +/// +/// `(group_id, user_id)` is unique while live. Soft-deletion +/// tombstones the row so audit queries can walk historical +/// membership; the partial unique index allows the same pair to be +/// re-added once the prior row is tombstoned. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupMembership { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Group side of the join. + pub group_id: Uuid, + /// User side of the join. + pub user_id: Uuid, + /// Row creation timestamp. + pub created_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/membership.rs b/crates/zagrosi-identity/src/domain/membership.rs new file mode 100644 index 0000000..b4e819f --- /dev/null +++ b/crates/zagrosi-identity/src/domain/membership.rs @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `Membership` (user ↔ org link) aggregate. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// One per `(user_id, org_id)` live row. The `(user_id, org_id)` +/// uniqueness is enforced by a partial unique index over +/// `deleted_at IS NULL` so a user can re-join after leaving. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Membership { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Linked user. + pub user_id: Uuid, + /// Linked org. + pub org_id: Uuid, + /// Coarse role placeholder until the tenant-isolation layer's RBAC lands. Defaults to `member`. + pub basic_role: String, + /// Auth path that minted the membership: one of + /// `password`, `oidc`, `saml`, `scim`, `manual`. + pub joined_via: String, + /// Timestamp the membership was JIT-provisioned via SSO/SCIM; + /// `None` for password / manual joins. + pub jit_provisioned_at: Option>, + /// Row creation timestamp. + pub created_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/mod.rs b/crates/zagrosi-identity/src/domain/mod.rs new file mode 100644 index 0000000..9dc37a0 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/mod.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Pure domain types for the identity crate. +//! +//! Every type in this module is a value object: no `sqlx::FromRow` +//! derive, no axum extractors, no behaviour beyond the pure `Eq` / +//! `Clone` derives. Conversion to / from database rows lives in the +//! [`crate::repo`] layer; conversion to / from HTTP wire formats lives +//! in the route handlers (later sections). Keeping the boundary sharp +//! lets unit tests exercise these types without touching the database +//! or the network. +//! +//! `Send + Sync + 'static` is satisfied by every domain type because +//! every field is an owned scalar / `String` / `Vec` / numeric +//! primitive. The assertions at the foot of this file freeze that +//! invariant — adding a non-`Send` field to any of these types will +//! break the build. + +pub mod api_token; +pub mod federated; +pub mod group; +pub mod membership; +pub mod oidc_pending; +pub mod oidc_refresh; +pub mod org; +pub mod org_idp; +pub mod org_idp_domain; +pub mod saml_pending; +pub mod saml_replay; +pub mod scim_resource; +pub mod service_token; +pub mod session; +pub mod token_format; +pub mod user; + +pub use api_token::ApiToken; +pub use federated::FederatedIdentity; +pub use group::{Group, GroupMembership}; +pub use membership::Membership; +pub use oidc_pending::OidcPendingAuth; +pub use oidc_refresh::OidcRefreshToken; +pub use org::Org; +pub use org_idp::OrgIdp; +pub use org_idp_domain::{DomainRouteHit, OrgIdpDomain}; +pub use saml_pending::SamlPendingAuth; +pub use saml_replay::SamlAssertionRecord; +pub use scim_resource::ScimResource; +pub use service_token::ServiceToken; +pub use session::Session; +pub use token_format::{ + HASH_LEN, TOKEN_BODY_LEN, TokenHash, TokenPrefix, hash_token, mint, parse_raw, +}; +pub use user::User; + +#[cfg(test)] +mod send_sync_assertions { + use super::*; + use static_assertions::assert_impl_all; + + // Every domain aggregate must be `Send + Sync + 'static` so it can + // round-trip across tokio task boundaries (HTTP handlers, NATS + // workers, SCIM batches). `'static` is implied by all fields being + // owned (no borrowed slices). + assert_impl_all!(User: Send, Sync); + assert_impl_all!(Org: Send, Sync); + assert_impl_all!(Membership: Send, Sync); + assert_impl_all!(Session: Send, Sync); + assert_impl_all!(ApiToken: Send, Sync); + assert_impl_all!(OrgIdp: Send, Sync); + assert_impl_all!(OrgIdpDomain: Send, Sync); + assert_impl_all!(DomainRouteHit: Send, Sync); + assert_impl_all!(FederatedIdentity: Send, Sync); + assert_impl_all!(OidcPendingAuth: Send, Sync); + assert_impl_all!(OidcRefreshToken: Send, Sync); + assert_impl_all!(SamlPendingAuth: Send, Sync); + assert_impl_all!(SamlAssertionRecord: Send, Sync); + assert_impl_all!(ScimResource: Send, Sync); + assert_impl_all!(ServiceToken: Send, Sync); + assert_impl_all!(Group: Send, Sync); + assert_impl_all!(GroupMembership: Send, Sync); +} diff --git a/crates/zagrosi-identity/src/domain/oidc_pending.rs b/crates/zagrosi-identity/src/domain/oidc_pending.rs new file mode 100644 index 0000000..6ec490b --- /dev/null +++ b/crates/zagrosi-identity/src/domain/oidc_pending.rs @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `OidcPendingAuth` aggregate. State held between OIDC redirect and +//! callback. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Pending OIDC authorisation record. Every secret carried by the +/// browser between redirect and callback (`state`, `nonce`, the PKCE +/// verifier, the CSRF cookie value) is persisted only as a SHA-256 +/// digest — the raw values stay client-side. The partial unique on +/// `(state_hash) WHERE used_at IS NULL` enforces single-use redemption. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcPendingAuth { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// IdP this request targets. Carries the org indirectly via + /// `org_idps.org_id`. + pub org_idp_id: Uuid, + /// SHA-256 of the `state` parameter sent to the IdP. + pub state_hash: [u8; 32], + /// SHA-256 of the OIDC `nonce` echoed in the ID token. + pub nonce_hash: [u8; 32], + /// SHA-256 of the PKCE code-verifier (RFC 7636 S256). + pub verifier_hash: [u8; 32], + /// SHA-256 of the `__Host-zagrosi_oidc_csrf` cookie value. + pub csrf_cookie_hash: [u8; 32], + /// Redirect URI registered for this transaction. + pub redirect_uri: String, + /// Row creation timestamp. + pub created_at: DateTime, + /// Hard expiry timestamp (~10 minutes after creation per the design notes). + pub expires_at: DateTime, + /// Single-use seal; `Some(now)` after the callback handler + /// consumes the row. + pub used_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/oidc_refresh.rs b/crates/zagrosi-identity/src/domain/oidc_refresh.rs new file mode 100644 index 0000000..9ff96ec --- /dev/null +++ b/crates/zagrosi-identity/src/domain/oidc_refresh.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `OidcRefreshToken` aggregate (refresh-rotation chain). + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Refresh-token chain entry. `prev_id` self-references the row that +/// minted this one so the OIDC client can revoke the entire chain when a +/// re-use is detected. `token_hash` is SHA-256 over the raw refresh +/// token (the prefix is implementation-private to the OIDC client). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OidcRefreshToken { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Owning session. + pub session_id: Uuid, + /// SHA-256 of the raw refresh-token value. + pub token_hash: [u8; 32], + /// Previous link in the chain; `None` for the first refresh of a + /// session. + pub prev_id: Option, + /// Issue timestamp. + pub issued_at: DateTime, + /// Single-use seal; `Some(now)` after rotation. + pub used_at: Option>, + /// Revocation timestamp; `None` while live. + pub revoked_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/org.rs b/crates/zagrosi-identity/src/domain/org.rs new file mode 100644 index 0000000..c2b8568 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/org.rs @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `Org` (organisation / tenant root) aggregate. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Tenant root record. `slug` is unique among live (`deleted_at IS NULL`) +/// rows via a partial unique index in migration `002_orgs.sql`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Org { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// URL-safe identifier (lowercased; whitespace-free). + pub slug: String, + /// Human-readable display name. + pub display_name: String, + /// Optional primary email-domain claim. The multi-IdP routing layer keys + /// off `org_idp_domains` rather than this column for IdP routing. + pub primary_domain: Option, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last-mutation timestamp. + pub updated_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/org_idp.rs b/crates/zagrosi-identity/src/domain/org_idp.rs new file mode 100644 index 0000000..338a88c --- /dev/null +++ b/crates/zagrosi-identity/src/domain/org_idp.rs @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `OrgIdp` (per-org SSO IdP configuration) aggregate. + +use chrono::{DateTime, Utc}; +use serde_json::Value as JsonValue; +use uuid::Uuid; + +/// Per-org IdP configuration. `protocol` is one of `oidc` / `saml`. +/// `config` is a versioned JSONB blob whose schema is described by +/// `OidcConfigV1` / `SamlConfigV1` ports in `zagrosi-core`. The +/// secret material inside `config` (e.g. OIDC `client_secret`, SAML SP +/// signing key) is wrapped via the `crypto::Secrets` shim +/// before reaching the repository. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrgIdp { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Owning org. + pub org_id: Uuid, + /// `oidc` or `saml`. + pub protocol: String, + /// Display name shown in admin UI / IdP picker. + pub display_name: String, + /// Versioned JSONB configuration blob (encrypted secrets included). + pub config: JsonValue, + /// Schema version for `config`. Bumped when a new field becomes + /// non-optional. + pub config_version: i16, + /// Whether SCIM/SSO Just-in-Time provisioning is allowed. + pub jit_provisioning: bool, + /// Whether this IdP handles unrouted traffic for the org. + pub is_default: bool, + /// Kill-switch; flipping to `false` rejects new sign-ins. + pub enabled: bool, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last-mutation timestamp. + pub updated_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/org_idp_domain.rs b/crates/zagrosi-identity/src/domain/org_idp_domain.rs new file mode 100644 index 0000000..77809ac --- /dev/null +++ b/crates/zagrosi-identity/src/domain/org_idp_domain.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `OrgIdpDomain` — verified-domain → IdP mapping aggregate. +//! +//! Each row claims one DNS domain for one IdP. The pair +//! `(lower(domain), org_idp_id)` is partial-unique on verified live +//! rows so an unverified placeholder cannot block a competing claim, +//! but a verified row excludes any other verified claim of the same +//! `(lower(domain), org_idp_id)` tuple. +//! +//! `challenge_token` is a `vrf_*`-prefixed base64url string published +//! to DNS as `_zagrosi-verify. IN TXT ""`. The verify +//! endpoint resolves the TXT record through the dual-resolver DNSSEC +//! path and matches against the persisted token. +//! +//! `priority` orders multiple verified claims for the same domain in +//! the routing-decision picker (lower wins). + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Per-IdP domain claim. Tied to an [`crate::domain::OrgIdp`] via +/// `org_idp_id`; that IdP is in turn tied to the owning org. Domain +/// strings are stored as entered (preserving display case); routing +/// lookups always normalise to `lower(domain)` first. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrgIdpDomain { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Owning IdP. Joins to `org_idps.id`. + pub org_idp_id: Uuid, + /// Domain as entered. The partial unique index is on + /// `lower(domain)` so case differences cannot fan out into + /// duplicate verified rows. + pub domain: String, + /// `vrf_*`-prefixed challenge token published as the TXT record. + /// Empty for legacy rows created before migration 020. + pub challenge_token: String, + /// Wall-clock when DNS verification last succeeded. `None` for + /// pending rows. + pub verified_at: Option>, + /// Resolver path that produced the last successful verification + /// (e.g. `"1.1.1.1+9.9.9.9"`). `None` until the first verify. + pub last_verified_via: Option, + /// Picker tie-breaker. Lower priority wins. Defaults to `100`. + pub priority: i32, + /// Row creation timestamp. + pub created_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} + +/// One row of the routing-decision lookup. Joins +/// [`OrgIdpDomain`] against the underlying IdP so the discover +/// handler has every field needed to build a picker entry without +/// chasing additional repos. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DomainRouteHit { + /// IdP id; the discover response carries this. + pub org_idp_id: Uuid, + /// Owning org id. Routing does not gate on org but downstream + /// audit emits this in the event payload. + pub org_id: Uuid, + /// `oidc` or `saml`. Discriminator the discover handler maps + /// onto its `method` field. + pub protocol: String, + /// Display name shown in the picker. + pub display_name: String, + /// Domain priority (lower wins). Carried so the handler can + /// sort the picker entries. + pub priority: i32, +} diff --git a/crates/zagrosi-identity/src/domain/saml_pending.rs b/crates/zagrosi-identity/src/domain/saml_pending.rs new file mode 100644 index 0000000..4544f53 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/saml_pending.rs @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `SamlPendingAuth` aggregate. State held between the SP-initiated +//! AuthnRequest and the IdP's POST to the ACS endpoint. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Pending SAML AuthnRequest record. Mirrors the contract of +/// [`crate::domain::OidcPendingAuth`]: the row pins the request to a +/// specific `org_idps` row, persists the IdP-bound request id and the +/// 256-bit `RelayState` so the ACS handler can correlate the IdP's +/// response back to the originating start request, and runs against a +/// hard 10-minute expiry. The partial unique index +/// `saml_pending_auth_request_id_unused` guarantees the request id is +/// single-use; representation of a used id is rejected at the SQL +/// layer before the ACS strict-order validation pipeline fires. +/// +/// `request_id` is stored verbatim (NOT hashed) because the IdP +/// responds with the value in `Response/@InResponseTo`, and samael's +/// `parse_xml_response_with_mode` requires the original ASCII id to +/// validate the response correlation. The id is generated server-side +/// from a 256-bit CSPRNG draw (see `crate::saml::authn`), so it is +/// unguessable in practice. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SamlPendingAuth { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// SAML AuthnRequest id (`saml:AuthnRequest/@ID`). 256 bits of + /// CSPRNG entropy rendered as a stable ASCII id token; the IdP + /// echoes this verbatim in `Response/@InResponseTo`. + pub request_id: String, + /// 256-bit base64url RelayState. A signed envelope is overkill for + /// a one-shot per-org SP; the value is opaque to the IdP and the + /// ACS handler matches against the persisted row. + pub relay_state: String, + /// IdP this request targets. Resolves the org via + /// `org_idps.org_id`. + pub org_idp_id: Uuid, + /// Row creation timestamp. + pub created_at: DateTime, + /// Hard expiry (~10 minutes after creation). + pub expires_at: DateTime, + /// Single-use seal; `Some(now)` after the ACS handler consumes the + /// row. + pub used_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/saml_replay.rs b/crates/zagrosi-identity/src/domain/saml_replay.rs new file mode 100644 index 0000000..76aaf27 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/saml_replay.rs @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `SamlAssertionRecord` aggregate (assertion replay ledger). + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// One row per `(org_idp_id, assertion_id)`. The composite primary key +/// IS the replay-rejection mechanism. A duplicate insert raises a +/// unique violation, which the SAML SP layer translates into an +/// authentication failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SamlAssertionRecord { + /// Owning IdP. Composite PK part 1. + pub org_idp_id: Uuid, + /// `` attribute from the SAML response. Composite + /// PK part 2. + pub assertion_id: String, + /// `` attribute. Cleanup sweeps prune + /// rows past this timestamp. + pub not_on_or_after: DateTime, + /// Row insert timestamp. + pub created_at: DateTime, +} diff --git a/crates/zagrosi-identity/src/domain/scim_resource.rs b/crates/zagrosi-identity/src/domain/scim_resource.rs new file mode 100644 index 0000000..ec662e0 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/scim_resource.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `ScimResource` (SCIM bearer token) aggregate. + +use chrono::{DateTime, Utc}; +use sqlx::types::ipnetwork::IpNetwork; +use std::net::IpAddr; +use uuid::Uuid; + +/// Per-org SCIM bearer token (`scim_*`). Each row is keyed by the +/// SHA-256 hash of the raw token. `scopes` is the SCIM scope set; +/// `allowed_cidrs` constrains the IPs that may present the token — +/// an empty array means unrestricted. `tolerant_mode` toggles +/// SCIM-server workarounds for Entra ID PATCH deviations. +/// +/// Naming nuance: the table is `scim_tokens` (a SCIM bearer token +/// IS the SCIM "service-credential" resource); the in-crate name +/// `ScimResource` matches the persistence-layer naming to keep the +/// SCIM vocabulary distinct from the broader `*Token` family. The +/// SCIM server retains this naming. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScimResource { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Owning org. + pub org_id: Uuid, + /// Display name shown in admin UI. + pub display_name: String, + /// SHA-256 of the raw `scim_*` token. + pub token_hash: [u8; 32], + /// SCIM scope set, e.g. `users:read`, `groups:write`. + pub scopes: Vec, + /// Source-IP allow-list. Empty means unrestricted. + pub allowed_cidrs: Vec, + /// Toggles SCIM-server Entra ID workarounds. + pub tolerant_mode: bool, + /// Last-used timestamp. + pub last_used_at: Option>, + /// Last source IP that introspected the token. + pub last_used_ip: Option, + /// Row creation timestamp. + pub created_at: DateTime, + /// Optional hard expiry timestamp. + pub expires_at: Option>, + /// Revocation timestamp. + pub revoked_at: Option>, + /// Soft-delete tombstone. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/service_token.rs b/crates/zagrosi-identity/src/domain/service_token.rs new file mode 100644 index 0000000..196cd9b --- /dev/null +++ b/crates/zagrosi-identity/src/domain/service_token.rs @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `ServiceToken` (internal service-to-service bearer) aggregate. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Internal service-to-service bearer (`svc_*`) consumed by the +/// service-token surface. Intentionally org-agnostic; service tokens +/// authorise platform-wide internal callers. The tenant-isolation +/// layer's RLS will whitelist this table for the service / migration +/// roles rather than gate it by tenant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceToken { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Caller name (e.g. `email-worker`, `scim-bridge`). + pub service_name: String, + /// SHA-256 of the raw `svc_*` token. + pub token_hash: [u8; 32], + /// NATS-subject allow-list the service may publish / subscribe on. + /// Free-form patterns; the service-token surface enforces a `>` / `*`-aware match. + pub allowed_subjects: Vec, + /// Display name shown in admin UI. + pub display_name: String, + /// Row creation timestamp. + pub created_at: DateTime, + /// Revocation timestamp. + pub revoked_at: Option>, + /// Soft-delete tombstone. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/session.rs b/crates/zagrosi-identity/src/domain/session.rs new file mode 100644 index 0000000..ee4d48e --- /dev/null +++ b/crates/zagrosi-identity/src/domain/session.rs @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `Session` aggregate (browser / bearer session cookie). + +use chrono::{DateTime, Utc}; +use std::net::IpAddr; +use uuid::Uuid; + +/// Browser session record. The `token_hash` column persists the +/// SHA-256 of the raw `sid_*` cookie value; the raw value never lands +/// in the database. `version` is the optimistic-locking counter that +/// `update_active_org` increments. `amr` (RFC 8176) and +/// `acr` (RFC 6711) record the authentication methods + assurance +/// level for downstream policy evaluation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Session { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// SHA-256 digest of the raw cookie value. Bound to the + /// `BYTEA token_hash` column. + pub token_hash: [u8; 32], + /// Owning user. + pub user_id: Uuid, + /// Currently-selected org for this session; `None` for newly + /// authenticated sessions before the first org is picked. + pub org_id: Option, + /// User agent string at issue time. Best-effort observability. + pub user_agent: Option, + /// Source IP at issue time. Best-effort observability. + pub ip_addr: Option, + /// Optimistic-lock counter; the session module increments on + /// `update_active_org`. + pub version: i64, + /// Authentication Method Reference values per RFC 8176. + pub amr: Vec, + /// Authentication Context Class Reference per RFC 6711 / OIDC Core. + pub acr: Option, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last-seen timestamp; updated on cookie introspection. + pub last_seen_at: DateTime, + /// Hard expiry timestamp; the session is rejected past this point + /// regardless of `revoked_at`. + pub expires_at: DateTime, + /// Revocation timestamp; `None` for live sessions. + pub revoked_at: Option>, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/domain/token_format.rs b/crates/zagrosi-identity/src/domain/token_format.rs new file mode 100644 index 0000000..2155e46 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/token_format.rs @@ -0,0 +1,440 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Canonical token-format chokepoint for the identity crate. +//! +//! Every persisted secret token in the identity surface +//! (sessions, PATs, SCIM bearers, service tokens, password resets, +//! email verifications) is represented on the wire as +//! `<43 base64url chars>`. This module is the **single +//! source of truth** for the prefix set, the body length, the wire- +//! format parser, and the SHA-256 hash function whose digest is +//! persisted in `BYTEA token_hash` columns. +//! +//! ## Invariants +//! +//! 1. The prefix is part of the SHA-256 input. Two raw tokens that +//! differ only by prefix (e.g. `sid_` vs `pat_`) hash +//! to different digests — preventing a session token from being +//! accepted at a PAT lookup site even if an attacker reused the +//! body. This is asserted by the `prefix_changes_hash` test. +//! 2. The body is exactly [`TOKEN_BODY_LEN`] base64url characters; +//! `parse_raw` rejects anything else. +//! 3. Prefix-aware parsing is the *first* gate any consumer applies +//! to a raw bearer token, BEFORE any database lookup. This keeps +//! obviously-malformed input from costing a round-trip. +//! +//! ## Cross-crate handover +//! +//! `zagrosi_core::TokenClass` (the cross-crate ports) carries the *gateway-facing* +//! subset of prefixes (`sid_/pat_/scim_/svc_`). The internal flow +//! tokens (`vrf_/rst_`) never reach the gateway introspector and are +//! intentionally absent from `TokenClass`. The two enums are kept +//! separate so that adding a new internal-only prefix does not force +//! a change to the cross-crate port surface; conversion is +//! one-directional via [`TokenPrefix::as_token_class`] when an +//! identity-side caller needs to bridge to the gateway port. +//! +//! ## Constant-time compare +//! +//! Repo-layer `find_by_token_hash` paths perform `WHERE token_hash = $1` +//! against a partial-unique index, which is already constant-time at +//! the storage layer. Application-layer comparisons of the resulting +//! [`TokenHash`] (e.g. inside test fixtures) MUST go through +//! [`TokenHash::ct_eq`] — re-exported from `subtle` — to defend the +//! invariant against future call sites that compare hashes outside a +//! SQL predicate. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand_core::{OsRng, RngCore as _}; +use sha2::Digest as _; +use sha2::Sha256; +use subtle::ConstantTimeEq; +use zagrosi_core::TokenClass; + +use crate::error::IdentityError; + +/// Body length in characters for every raw token (`sid_<43>`, `pat_<43>`, +/// `scim_<43>`, `svc_<43>`, `vrf_<43>`, `rst_<43>`). 43 base64url chars +/// encode 32 bytes via the standard length formula `ceil(32 * 4 / 3)` +/// minus the `=` padding character that base64url omits. +pub const TOKEN_BODY_LEN: usize = 43; + +/// Number of random bytes drawn from the OS RNG per [`mint`] call. +/// 32 bytes is 256 bits of entropy, well above the 128-bit floor the +/// project plan asserts for state / nonce values. +pub const TOKEN_RANDOM_BYTES: usize = 32; + +/// SHA-256 digest length in bytes. Re-exported as a named constant so +/// repo layers can declare `[u8; HASH_LEN]` without a magic number. +pub const HASH_LEN: usize = 32; + +/// Token class prefix. +/// +/// Identity-internal flow tokens (`vrf_`, `rst_`) join the four +/// gateway-facing prefixes from `zagrosi_core::TokenClass`. The +/// numeric ordering of the variants is documented as stable ONLY +/// within this enum; downstream code MUST match exhaustively rather +/// than relying on a discriminant order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum TokenPrefix { + /// `sid_` — browser session cookie or bearer. + Session, + /// `pat_` — personal access token. + Pat, + /// `scim_` — SCIM 2.0 bearer. + Scim, + /// `svc_` — internal service-to-service token. + Service, + /// `vrf_` — single-use email-verification token. + Verification, + /// `rst_` — single-use password-reset token. + Reset, +} + +impl TokenPrefix { + /// Prefix string as it appears on the wire (trailing underscore + /// included). The returned string is always in the form `xxx_`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Session => "sid_", + Self::Pat => "pat_", + Self::Scim => "scim_", + Self::Service => "svc_", + Self::Verification => "vrf_", + Self::Reset => "rst_", + } + } + + /// Match a raw prefix string back to a [`TokenPrefix`]. + /// + /// Accepts only exact matches against the five-character forms + /// (`scim_` is the only five-char prefix; the rest are four). Used + /// internally by [`parse_raw`]. + #[must_use] + pub fn from_prefix_str(prefix: &str) -> Option { + match prefix { + "sid_" => Some(Self::Session), + "pat_" => Some(Self::Pat), + "scim_" => Some(Self::Scim), + "svc_" => Some(Self::Service), + "vrf_" => Some(Self::Verification), + "rst_" => Some(Self::Reset), + _ => None, + } + } + + /// Convert this internal prefix into the gateway-facing + /// `zagrosi_core::TokenClass`. Returns `None` for the + /// identity-internal prefixes (`Verification`, `Reset`) which + /// never reach the gateway introspector. + #[must_use] + pub const fn as_token_class(self) -> Option { + match self { + Self::Session => Some(TokenClass::Session), + Self::Pat => Some(TokenClass::PersonalAccessToken), + Self::Scim => Some(TokenClass::Scim), + Self::Service => Some(TokenClass::Service), + Self::Verification | Self::Reset => None, + } + } +} + +/// SHA-256 digest of a raw token. Carrier type for +/// `BYTEA token_hash` column reads / writes. +/// +/// Wraps `[u8; HASH_LEN]` to give the type system a hook for +/// constant-time comparison via [`ConstantTimeEq`] and to make +/// the sqlx `BYTEA` round-trip explicit at the repo boundary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TokenHash(pub [u8; HASH_LEN]); + +impl TokenHash { + /// Borrow the raw digest as a byte slice for sqlx `BYTEA` binds. + #[must_use] + pub const fn as_slice(&self) -> &[u8] { + &self.0 + } + + /// Constant-time equality. Repo layers prefer SQL predicates + /// (`WHERE token_hash = $1`) which are already storage-engine + /// constant-time; this helper is reserved for non-DB call sites + /// (test fixtures, future in-memory caches). + #[must_use] + pub fn ct_eq(&self, other: &Self) -> bool { + bool::from(ConstantTimeEq::ct_eq(&self.0[..], &other.0[..])) + } +} + +impl From<[u8; HASH_LEN]> for TokenHash { + fn from(bytes: [u8; HASH_LEN]) -> Self { + Self(bytes) + } +} + +impl AsRef<[u8]> for TokenHash { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// Parse a raw token string into `(prefix, body)`. +/// +/// Validation rejects: +/// - any prefix outside the documented six-prefix set +/// - body length other than [`TOKEN_BODY_LEN`] +/// - any byte outside the base64url alphabet (`A-Z`, `a-z`, `0-9`, +/// `-`, `_`) +/// +/// # Errors +/// +/// Returns [`IdentityError::MalformedToken`] with a `&'static str` +/// reason that callers MUST NOT surface verbatim into log lines that +/// land in user-visible error pages — the reason is a routing aid for +/// internal logs only. +pub fn parse_raw(raw: &str) -> Result<(TokenPrefix, &str), IdentityError> { + let underscore = raw + .find('_') + .ok_or(IdentityError::MalformedToken("missing prefix delimiter"))?; + let prefix_end = underscore + .checked_add(1) + .ok_or(IdentityError::MalformedToken("prefix length overflow"))?; + let prefix_str = raw + .get(..prefix_end) + .ok_or(IdentityError::MalformedToken("prefix slice failed"))?; + let prefix = TokenPrefix::from_prefix_str(prefix_str) + .ok_or(IdentityError::MalformedToken("unknown prefix"))?; + let body = raw + .get(prefix_end..) + .ok_or(IdentityError::MalformedToken("missing body"))?; + if body.len() != TOKEN_BODY_LEN { + return Err(IdentityError::MalformedToken("body length is not 43")); + } + if !body + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(IdentityError::MalformedToken("body contains non-base64url")); + } + Ok((prefix, body)) +} + +/// SHA-256 over the entire raw token (prefix + body included). +/// +/// Hashing the prefix is part of the [crate-level invariant](self): +/// `sid_` and `pat_` MUST hash to different digests so +/// that a session token can never be accepted at a PAT lookup site +/// (and vice versa) even if an attacker reuses the body. This +/// behaviour is asserted by `prefix_changes_hash` below. +#[must_use] +pub fn hash_token(raw: &str) -> TokenHash { + let mut hasher = Sha256::new(); + hasher.update(raw.as_bytes()); + let digest = hasher.finalize(); + let mut out = [0_u8; HASH_LEN]; + out.copy_from_slice(&digest); + TokenHash(out) +} + +/// Mint a fresh token of the given class. +/// +/// Reads [`TOKEN_RANDOM_BYTES`] (32) bytes from the OS RNG, +/// base64url-encodes (no padding, [`TOKEN_BODY_LEN`] = 43 chars), +/// and prepends the prefix. Returns the raw token string — +/// `mint` is the **only** sanctioned mint path; callers that need +/// the digest should hash the returned value via [`hash_token`]. +#[must_use] +pub fn mint(prefix: TokenPrefix) -> String { + let mut bytes = [0_u8; TOKEN_RANDOM_BYTES]; + OsRng.fill_bytes(&mut bytes); + let body = URL_SAFE_NO_PAD.encode(bytes); + debug_assert_eq!(body.len(), TOKEN_BODY_LEN); + let mut out = String::with_capacity(prefix.as_str().len() + body.len()); + out.push_str(prefix.as_str()); + out.push_str(&body); + out +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + use static_assertions::assert_impl_all; + use std::collections::HashSet; + + assert_impl_all!(TokenPrefix: Send, Sync, Copy); + assert_impl_all!(TokenHash: Send, Sync, Copy); + + #[test] + fn parse_raw_accepts_session_token() { + let raw = mint(TokenPrefix::Session); + let (prefix, body) = parse_raw(&raw).expect("session parse"); + assert_eq!(prefix, TokenPrefix::Session); + assert_eq!(body.len(), TOKEN_BODY_LEN); + } + + #[test] + fn parse_raw_accepts_all_six_prefixes() { + for prefix in [ + TokenPrefix::Session, + TokenPrefix::Pat, + TokenPrefix::Scim, + TokenPrefix::Service, + TokenPrefix::Verification, + TokenPrefix::Reset, + ] { + let raw = mint(prefix); + let (parsed, _) = parse_raw(&raw).expect("parse"); + assert_eq!(parsed, prefix); + } + } + + #[test] + fn parse_raw_rejects_unknown_prefix() { + assert!(matches!( + parse_raw("abc_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Err(IdentityError::MalformedToken(_)) + )); + } + + #[test] + fn parse_raw_rejects_missing_underscore() { + assert!(matches!( + parse_raw("sidaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + Err(IdentityError::MalformedToken(_)) + )); + } + + #[test] + fn parse_raw_rejects_short_body() { + assert!(matches!( + parse_raw("sid_short"), + Err(IdentityError::MalformedToken(_)) + )); + } + + #[test] + fn parse_raw_rejects_long_body() { + // 44 char body + let body = "a".repeat(TOKEN_BODY_LEN + 1); + let raw = format!("sid_{body}"); + assert!(matches!( + parse_raw(&raw), + Err(IdentityError::MalformedToken(_)) + )); + } + + #[test] + fn parse_raw_rejects_non_base64url() { + let body_with_plus: String = "a".repeat(TOKEN_BODY_LEN - 1) + "+"; + let raw = format!("sid_{body_with_plus}"); + assert!(matches!( + parse_raw(&raw), + Err(IdentityError::MalformedToken(_)) + )); + } + + #[test] + fn prefix_changes_hash() { + let body = "a".repeat(TOKEN_BODY_LEN); + let h1 = hash_token(&format!("sid_{body}")); + let h2 = hash_token(&format!("pat_{body}")); + assert_ne!(h1, h2, "prefix MUST be part of hash input"); + } + + #[test] + fn mint_session_starts_with_prefix() { + let raw = mint(TokenPrefix::Session); + assert!(raw.starts_with("sid_")); + assert_eq!(raw.len(), 4 + TOKEN_BODY_LEN); + } + + #[test] + fn mint_scim_includes_five_char_prefix() { + let raw = mint(TokenPrefix::Scim); + assert!(raw.starts_with("scim_")); + assert_eq!(raw.len(), 5 + TOKEN_BODY_LEN); + } + + #[test] + fn mint_emits_unique_tokens() { + let mut seen: HashSet = HashSet::with_capacity(1000); + for _ in 0..1000 { + let token = mint(TokenPrefix::Session); + assert!(seen.insert(token), "mint produced collision"); + } + } + + #[test] + fn token_class_bridge_for_gateway_prefixes() { + assert_eq!( + TokenPrefix::Session.as_token_class(), + Some(TokenClass::Session) + ); + assert_eq!( + TokenPrefix::Pat.as_token_class(), + Some(TokenClass::PersonalAccessToken) + ); + assert_eq!(TokenPrefix::Scim.as_token_class(), Some(TokenClass::Scim)); + assert_eq!( + TokenPrefix::Service.as_token_class(), + Some(TokenClass::Service) + ); + } + + #[test] + fn token_class_bridge_blocks_internal_prefixes() { + assert_eq!(TokenPrefix::Verification.as_token_class(), None); + assert_eq!(TokenPrefix::Reset.as_token_class(), None); + } + + #[test] + fn token_hash_ct_eq_matches() { + let hash = hash_token("sid_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let same = hash_token("sid_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let diff = hash_token("sid_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"); + assert!(hash.ct_eq(&same)); + assert!(!hash.ct_eq(&diff)); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(1000))] + + #[test] + fn parse_raw_rejects_arbitrary_wrong_lengths( + len in 0_usize..200_usize, + body_seed in any::(), + ) { + prop_assume!(len != TOKEN_BODY_LEN); + let body: String = (0..len) + .map(|i| { + let alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + let idx = usize::try_from(body_seed.wrapping_add(i as u64) % alphabet.len() as u64).unwrap_or(0); + alphabet[idx] as char + }) + .collect(); + let raw = format!("sid_{body}"); + prop_assert!(parse_raw(&raw).is_err()); + } + + #[test] + fn prefix_aware_hash_collision_resistant(seed in any::()) { + // Build a body deterministically so the prefix delta is the only differentiator. + let body: String = (0..TOKEN_BODY_LEN) + .map(|i| { + let alphabet = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"; + let idx = usize::try_from(seed.wrapping_add(i as u64) % alphabet.len() as u64).unwrap_or(0); + alphabet[idx] as char + }) + .collect(); + let h_sid = hash_token(&format!("sid_{body}")); + let h_pat = hash_token(&format!("pat_{body}")); + let h_scim = hash_token(&format!("scim_{body}")); + prop_assert_ne!(h_sid, h_pat); + prop_assert_ne!(h_sid, h_scim); + prop_assert_ne!(h_pat, h_scim); + } + } +} diff --git a/crates/zagrosi-identity/src/domain/user.rs b/crates/zagrosi-identity/src/domain/user.rs new file mode 100644 index 0000000..785c2f3 --- /dev/null +++ b/crates/zagrosi-identity/src/domain/user.rs @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! `User` aggregate. Persisted via `repo::user_repo::UserRepo`. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Canonical user record. +/// +/// `email` is stored case-preserving for display; `email_lower` mirrors +/// the database generated column (`lower(email)`) and is the column +/// every uniqueness / lookup index targets. `password_hash` is `None` +/// for SSO-only accounts. `password_updated_at` is the password-reset +/// revocation invariant consumed by sessions (sessions issued before +/// this timestamp are rejected). `password_hash_version` tracks the +/// Argon2id profile version. `active` mirrors SCIM `active`; SCIM +/// `active=false` flips this and revokes every live session for the +/// user in the same DB transaction. `external_id` mirrors SCIM +/// `externalId` (IdP-assigned opaque identifier). `row_version` is +/// the per-row monotonic mutation counter consumed by the SCIM ETag +/// derivation (`http::scim::etag::meta_version`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct User { + /// Application-generated UUID v7 primary key. + pub id: Uuid, + /// Display-case email address. + pub email: String, + /// `lower(email)` mirror of the DB generated column. + pub email_lower: String, + /// Display name shown in chrome / member rosters. Distinct from + /// `email` so renames are cheap. + pub display_name: String, + /// Timestamp the user verified their email; `None` until the + /// `vrf_*` flow completes. + pub email_verified_at: Option>, + /// PHC-format password hash; `None` for SSO-only users. + pub password_hash: Option, + /// Timestamp the password was last set / rotated. The session module + /// rejects sessions whose `created_at` precedes this value. + pub password_updated_at: Option>, + /// Argon2id profile version; bumped by password-auth rotation. + pub password_hash_version: i16, + /// Timestamp the user enrolled an MFA factor; `None` until enrolled. + pub mfa_enrolled_at: Option>, + /// SCIM `active` flag. Flipping to `false` revokes every live + /// session for the user in the same DB transaction. + pub active: bool, + /// SCIM `externalId` (opaque IdP-assigned identifier). `None` + /// for users provisioned outside SCIM. + pub external_id: Option, + /// Per-row monotonic mutation counter; bumped on every PATCH/PUT + /// to disambiguate ETags within the same `updated_at` granularity. + pub row_version: i64, + /// Row creation timestamp. + pub created_at: DateTime, + /// Last-mutation timestamp. + pub updated_at: DateTime, + /// Soft-delete tombstone; `None` for live rows. + pub deleted_at: Option>, +} diff --git a/crates/zagrosi-identity/src/email/dispatch.rs b/crates/zagrosi-identity/src/email/dispatch.rs new file mode 100644 index 0000000..57db692 --- /dev/null +++ b/crates/zagrosi-identity/src/email/dispatch.rs @@ -0,0 +1,489 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown)] +//! Consumer-side outbox dispatch. +//! +//! The producer ([`crate::email::EmailOutboxWriter`]) writes a +//! fully-rendered row (`subject` / `body_text` / `body_html` are +//! materialised at enqueue time — the worker does **not** render +//! templates). This module drains those rows. +//! +//! ## Locking model +//! +//! Each row is processed inside its own short transaction. The +//! dequeue `SELECT ... FOR UPDATE SKIP LOCKED LIMIT 1` row-locks +//! exactly one eligible row and skips rows another worker already +//! holds, so N worker replicas drain a backlog with no duplicate +//! sends and no skipped rows. The transaction stays open across the +//! transport call; commit (success or terminal failure) releases the +//! lock. A worker crash mid-send rolls the transaction back, leaving +//! the row `queued`/`failed` for the next sweep — no email is lost +//! and none is sent twice (the transport call is idempotent-keyed). +//! +//! ## Transport indirection +//! +//! [`OutboxDispatcher::process_one`] takes the send action as an +//! async closure rather than depending on a concrete transport. The +//! worker passes `|msg| transport.send(msg)`; tests pass a closure +//! that records calls and returns scripted outcomes, so the +//! dequeue / retry / dead-letter / SKIP-LOCKED logic is exercised +//! without an SMTP server. + +use std::future::Future; + +use chrono::Utc; +use sqlx::PgPool; +use uuid::Uuid; +use zagrosi_core::{EmailMessage, EmailTransportError}; + +use crate::email::retry; +use crate::error::{IdentityError, Result}; + +/// Lifecycle states of an `email_outbox` row. +/// +/// Wire values match the `CHECK (state IN (...))` constraint in +/// migration `010_email_outbox`. `Sending` is part of the schema +/// constraint for forward-compatibility but is intentionally unused +/// by this dispatcher: the per-row transaction lock already provides +/// mutual exclusion, so there is no committed intermediate state to +/// reap after a crash. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutboxState { + /// Freshly enqueued, never attempted. + Queued, + /// Reserved by the schema; unused by this dispatcher (see type + /// docs). Present only so the round-trip mapping is total. + Sending, + /// Delivered to the transport successfully; terminal. + Sent, + /// At least one transient failure; awaiting `next_attempt_at`. + Failed, + /// Retry cap reached or a permanent fault; terminal, never retried. + Dead, +} + +impl OutboxState { + /// Wire string written to / read from `email_outbox.state`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Queued => "queued", + Self::Sending => "sending", + Self::Sent => "sent", + Self::Failed => "failed", + Self::Dead => "dead", + } + } + + /// Parse a wire string. Returns `None` for an unrecognised value + /// (a row that violates the migration `CHECK` — treated as a hard + /// error by callers rather than silently coerced). + /// + /// Named `from_wire` (not `from_str`) deliberately: it is not a + /// [`std::str::FromStr`] impl — the fallible-but-`Option` shape + /// and "wire format" framing are intentional. + #[must_use] + pub fn from_wire(raw: &str) -> Option { + match raw { + "queued" => Some(Self::Queued), + "sending" => Some(Self::Sending), + "sent" => Some(Self::Sent), + "failed" => Some(Self::Failed), + "dead" => Some(Self::Dead), + _ => None, + } + } +} + +/// The columns the dispatcher reads to build an [`EmailMessage`]. +/// +/// `attempts` is the **pre-increment** value as stored; the dispatcher +/// bumps it on failure before consulting [`retry::next_attempt`]. +#[derive(Clone)] +pub struct DispatchRow { + /// `email_outbox.id`. + pub id: Uuid, + /// Owning org, `None` for system mail. + pub org_id: Option, + /// `To:` recipient. + pub to_address: String, + /// `From:` sender (producer copied this from outbound SMTP config). + pub from_address: String, + /// Pre-rendered subject line. + pub subject: String, + /// Pre-rendered plain-text body. + pub body_text: String, + /// Optional pre-rendered HTML body. + pub body_html: Option, + /// Producer-computed idempotency key (carried into the transport). + pub idempotency_key: String, + /// Stored (pre-increment) attempt count. + pub attempts: i32, +} + +impl std::fmt::Debug for DispatchRow { + /// `to_address` is recipient PII and `body_text`/`body_html` + /// carry rendered single-use token URLs; all three render + /// `` so a `tracing::debug!(?row)` at any call site + /// cannot leak them. `idempotency_key` is an opaque hash (safe, + /// and the only correlation handle for ops) and survives, mirroring + /// the `zagrosi_core::EmailMessage` redaction policy. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DispatchRow") + .field("id", &self.id) + .field("org_id", &self.org_id) + .field("to_address", &"") + .field("from_address", &"") + .field("subject", &"") + .field("body_text", &"") + .field("body_html", &self.body_html.as_ref().map(|_| "")) + .field("idempotency_key", &self.idempotency_key) + .field("attempts", &self.attempts) + .finish() + } +} + +impl DispatchRow { + fn to_message(&self) -> EmailMessage { + EmailMessage { + from: self.from_address.clone(), + to: self.to_address.clone(), + subject: self.subject.clone(), + body_text: self.body_text.clone(), + body_html: self.body_html.clone(), + idempotency_key: self.idempotency_key.clone(), + } + } +} + +/// What happened to one processed row. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProcessOutcome { + /// Transport accepted the message; row is `sent`. + Sent { + /// Processed row id. + id: Uuid, + }, + /// Transient failure; row is `failed`, eligible again at + /// `next_attempt_at`. + Retried { + /// Processed row id. + id: Uuid, + /// Post-increment attempt count. + attempts: i32, + }, + /// Retry cap reached or permanent fault; row is `dead`. + DeadLettered { + /// Processed row id. + id: Uuid, + /// Post-increment attempt count. + attempts: i32, + }, +} + +/// The dequeue query. Held as a constant so a unit test can assert +/// the `FOR UPDATE SKIP LOCKED` clause is present without a database +/// (a regression guard: dropping it silently re-introduces +/// duplicate-send races under concurrent workers). +const DEQUEUE_SQL: &str = "\ +SELECT id, org_id, to_address, from_address, subject, body_text, body_html, \ +idempotency_key, attempts \ +FROM email_outbox \ +WHERE state IN ('queued','failed') \ + AND (next_attempt_at IS NULL OR next_attempt_at <= now()) \ +ORDER BY next_attempt_at ASC NULLS FIRST \ +FOR UPDATE SKIP LOCKED \ +LIMIT 1"; + +/// Drains `email_outbox` one locked row per transaction. +#[derive(Debug, Clone)] +pub struct OutboxDispatcher { + pool: PgPool, +} + +impl OutboxDispatcher { + /// Construct over a connection pool. + #[must_use] + pub const fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Number of rows currently eligible for an immediate send. Used + /// by the worker to publish the `email_outbox_pending_total` + /// gauge on each sweep; not part of the dequeue critical path. + /// + /// # Errors + /// + /// Returns [`IdentityError::Database`] if the count query fails. + pub async fn pending_count(&self) -> Result { + let row: (i64,) = sqlx::query_as( + "SELECT count(*) FROM email_outbox \ + WHERE state IN ('queued','failed') \ + AND (next_attempt_at IS NULL OR next_attempt_at <= now())", + ) + .fetch_one(&self.pool) + .await + .map_err(IdentityError::from)?; + Ok(row.0) + } + + /// Claim, send, and finalise the single oldest eligible row. + /// + /// Returns `Ok(None)` when no row is eligible (the backlog is + /// drained or every eligible row is locked by a peer worker). + /// + /// `send` is invoked at most once per call, inside the row's + /// transaction. Its `Ok`/`Err` decides the terminal state: + /// + /// - `Ok(())` → `sent`. + /// - `Err(Unavailable)` → `failed` with a backed-off + /// `next_attempt_at`, or `dead` once the attempt cap + /// ([`retry::MAX_ATTEMPTS`]) is hit. + /// - `Err(Permanent { .. })` → `dead` immediately (a bad + /// recipient / rejected content will not become valid on retry). + /// + /// # Errors + /// + /// Returns [`IdentityError::Database`] on a SQL / transaction + /// failure. A transport error is **not** propagated — it is + /// recorded on the row and folded into the returned + /// [`ProcessOutcome`]. + pub async fn process_one(&self, send: F) -> Result> + where + F: FnOnce(EmailMessage) -> Fut, + Fut: Future>, + { + let mut tx = self.pool.begin().await.map_err(IdentityError::from)?; + + let Some(row) = dequeue(&mut tx).await? else { + // Nothing eligible. Roll the empty txn back explicitly so + // the connection returns to the pool without a dangling + // open transaction. + tx.rollback().await.map_err(IdentityError::from)?; + return Ok(None); + }; + + let send_result = send(row.to_message()).await; + let outcome = match send_result { + Ok(()) => { + mark_sent(&mut tx, row.id).await?; + ProcessOutcome::Sent { id: row.id } + } + Err(err) => mark_failure(&mut tx, &row, &err).await?, + }; + + tx.commit().await.map_err(IdentityError::from)?; + Ok(Some(outcome)) + } +} + +/// Column tuple returned by [`DEQUEUE_SQL`], in select order. +type RawOutboxRow = ( + Uuid, + Option, + String, + String, + String, + String, + Option, + String, + i32, +); + +/// Claim the oldest eligible row inside `tx` (row-locked via the +/// `FOR UPDATE SKIP LOCKED` in [`DEQUEUE_SQL`]). +async fn dequeue(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result> { + let raw = sqlx::query_as::<_, RawOutboxRow>(DEQUEUE_SQL) + .fetch_optional(&mut **tx) + .await + .map_err(IdentityError::from)?; + Ok(raw.map(|r| DispatchRow { + id: r.0, + org_id: r.1, + to_address: r.2, + from_address: r.3, + subject: r.4, + body_text: r.5, + body_html: r.6, + idempotency_key: r.7, + attempts: r.8, + })) +} + +/// Mark the row delivered. Terminal; clears any prior `last_error`. +async fn mark_sent(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, id: Uuid) -> Result<()> { + sqlx::query( + "UPDATE email_outbox \ + SET state = 'sent', sent_at = now(), last_error = NULL \ + WHERE id = $1", + ) + .bind(id) + .execute(&mut **tx) + .await + .map_err(IdentityError::from)?; + Ok(()) +} + +/// Apply a failed send: bump `attempts`, then either reschedule +/// (`failed` + backed-off `next_attempt_at`) or dead-letter (`dead`). +/// A permanent fault skips the retry schedule entirely. +async fn mark_failure( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + row: &DispatchRow, + err: &EmailTransportError, +) -> Result { + let next_attempts = row.attempts.saturating_add(1); + let last_error = redacted_error(err); + let permanent = matches!(err, EmailTransportError::Permanent { .. }); + let backoff = if permanent { + None + } else { + retry::next_attempt(next_attempts) + }; + + if let Some(delay) = backoff { + let next_at = Utc::now() + + chrono::Duration::from_std(delay).unwrap_or_else(|_| { + // `delay` is one of the fixed schedule constants + // (<= 1h); the conversion cannot overflow. Fall back + // to the smallest delay rather than panic if the + // table ever changes. + chrono::Duration::seconds(30) + }); + sqlx::query( + "UPDATE email_outbox \ + SET state = 'failed', attempts = $2, \ + next_attempt_at = $3, last_error = $4 \ + WHERE id = $1", + ) + .bind(row.id) + .bind(next_attempts) + .bind(next_at) + .bind(&last_error) + .execute(&mut **tx) + .await + .map_err(IdentityError::from)?; + Ok(ProcessOutcome::Retried { + id: row.id, + attempts: next_attempts, + }) + } else { + sqlx::query( + "UPDATE email_outbox \ + SET state = 'dead', attempts = $2, \ + next_attempt_at = NULL, last_error = $3 \ + WHERE id = $1", + ) + .bind(row.id) + .bind(next_attempts) + .bind(&last_error) + .execute(&mut **tx) + .await + .map_err(IdentityError::from)?; + Ok(ProcessOutcome::DeadLettered { + id: row.id, + attempts: next_attempts, + }) + } +} + +/// Render a transport error into the `last_error` column value. +/// +/// [`EmailTransportError`]'s `Display` is redaction-safe by +/// construction (the permanent variant wraps detail in +/// `RedactedString`, the transient variant carries an +/// operator-scrubbed string). The result is length-capped so a +/// pathological upstream cannot bloat the row. +fn redacted_error(err: &EmailTransportError) -> String { + /// Character cap (not byte cap): `String::truncate` panics if the + /// byte offset splits a multi-byte UTF-8 sequence, and the error + /// text can carry non-ASCII (IDN host, localized SMTP reply). A + /// panic here would unwind the worker loop and wedge the queue. + const MAX_CHARS: usize = 500; + let s = err.to_string(); + if s.chars().count() <= MAX_CHARS { + s + } else { + s.chars().take(MAX_CHARS).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_round_trips_through_wire_string() { + for state in [ + OutboxState::Queued, + OutboxState::Sending, + OutboxState::Sent, + OutboxState::Failed, + OutboxState::Dead, + ] { + assert_eq!(OutboxState::from_wire(state.as_str()), Some(state)); + } + } + + #[test] + fn unknown_state_string_is_rejected() { + assert_eq!(OutboxState::from_wire("bogus"), None); + assert_eq!(OutboxState::from_wire(""), None); + } + + #[test] + fn dequeue_sql_uses_for_update_skip_locked() { + // Regression guard: removing SKIP LOCKED silently + // re-introduces duplicate-send races across worker replicas. + assert!( + DEQUEUE_SQL.contains("FOR UPDATE SKIP LOCKED"), + "dequeue must row-lock with SKIP LOCKED; SQL was: {DEQUEUE_SQL}", + ); + assert!( + DEQUEUE_SQL.contains("state IN ('queued','failed')"), + "dequeue must only consider retriable states", + ); + assert!( + DEQUEUE_SQL.contains("LIMIT 1"), + "one row per locked transaction", + ); + } + + #[test] + fn redacted_error_caps_length_and_stays_safe() { + use zagrosi_core::{EmailTransportFault, PermanentFaultCategory, RedactedString}; + + let permanent = EmailTransportError::Permanent { + fault: EmailTransportFault { + category: PermanentFaultCategory::InvalidRecipient, + smtp_code: Some(550), + redacted_detail: RedactedString::new("bob@secret.example".into()), + }, + }; + let rendered = redacted_error(&permanent); + assert!(rendered.contains("invalid recipient")); + assert!(!rendered.contains("bob@secret.example")); + + let long = EmailTransportError::Unavailable("x".repeat(5_000)); + assert!(redacted_error(&long).len() <= 500); + } + + #[test] + fn dispatch_row_builds_message_preserving_idempotency_key() { + let row = DispatchRow { + id: Uuid::nil(), + org_id: None, + to_address: "to@example.com".into(), + from_address: "from@example.com".into(), + subject: "Hi".into(), + body_text: "Body".into(), + body_html: Some("

Body

".into()), + idempotency_key: "idem-123".into(), + attempts: 0, + }; + let msg = row.to_message(); + assert_eq!(msg.idempotency_key, "idem-123"); + assert_eq!(msg.to, "to@example.com"); + assert_eq!(msg.body_html.as_deref(), Some("

Body

")); + } +} diff --git a/crates/zagrosi-identity/src/email/mod.rs b/crates/zagrosi-identity/src/email/mod.rs new file mode 100644 index 0000000..68f7b90 --- /dev/null +++ b/crates/zagrosi-identity/src/email/mod.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Transactional email outbox — producer and consumer. +//! +//! The DB `email_outbox` table is authoritative: a row committed +//! alongside the user-state mutation IS the durable record that an +//! email must be delivered. NATS is only a wake-up hint; if the +//! publish fails the worker still drains the outbox on its next +//! periodic sweep, so no email is lost. +//! +//! ## Producer side ([`outbox`], [`template`]) +//! +//! [`EmailOutboxWriter`] takes a borrowed transaction, forcing the +//! caller to fold the outbox insert into the same atomic unit as the +//! user-state mutation. The producer renders the subject + body at +//! enqueue time (so the worker performs no template rendering); +//! [`TemplateName`] records which template produced the row for +//! observability. +//! +//! ## Consumer side ([`dispatch`], [`transport`], [`retry`], [`worker`]) +//! +//! [`worker::EmailWorker`] drains rows via +//! [`dispatch::OutboxDispatcher`] (one row per `FOR UPDATE SKIP +//! LOCKED` transaction → safe under N concurrent replicas), sends +//! through an [`zagrosi_core::EmailTransport`] (the default concrete +//! impl is [`transport::LettreTransport`]), and applies the +//! [`retry`] backoff schedule with dead-lettering at the attempt cap. + +pub mod dispatch; +pub mod outbox; +pub mod retry; +pub mod template; +pub mod transport; +pub mod worker; + +pub use dispatch::{DispatchRow, OutboxDispatcher, OutboxState, ProcessOutcome}; +pub use outbox::{EmailOutboxWriter, EnqueueRequest}; +pub use retry::{MAX_ATTEMPTS, next_attempt}; +pub use template::TemplateName; +pub use transport::LettreTransport; +pub use worker::{DrainOutcome, EMAIL_OUTBOX_SUBJECT, EmailWorker}; diff --git a/crates/zagrosi-identity/src/email/outbox.rs b/crates/zagrosi-identity/src/email/outbox.rs new file mode 100644 index 0000000..3919eb9 --- /dev/null +++ b/crates/zagrosi-identity/src/email/outbox.rs @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Transactional outbox writer. +//! +//! The producer pattern (password-auth): +//! +//! 1. Open `sqlx::Transaction<'_, sqlx::Postgres>`. +//! 2. Mutate user state. +//! 3. Call [`EmailOutboxWriter::enqueue`] to write the outbox row +//! inside the same transaction. Idempotency-keyed `INSERT ... ON +//! CONFLICT DO NOTHING` guards against duplicate enqueues. +//! 4. Commit. +//! 5. Best-effort publish on `email.outbox.queue` via +//! [`EmailOutboxWriter::notify`] AFTER commit. A publish failure +//! is logged + ignored; the email-outbox worker drains the outbox on its +//! next sweep, so no email is lost. +//! +//! The compile-time signature forbids passing a `&PgPool` instead of a +//! `&mut sqlx::Transaction<...>`; this defends against accidental +//! out-of-transaction enqueues that would break the atomic-with-user +//! -mutation contract. + +use sha2::{Digest as _, Sha256}; +use sqlx::Postgres; +use uuid::Uuid; + +use crate::email::template::TemplateName; +use crate::error::{IdentityError, Result}; + +/// Outbox-row shape the producer hands to the writer. +/// +/// `correlation_id` is folded into the idempotency key so a retry +/// path (e.g. a sign-up form double-submission) collapses to one +/// outbox row. The email-outbox worker uses `template_key` to resolve the +/// fluent template; `payload` is rendered into the template by the +/// worker. +#[derive(Debug, Clone)] +pub struct EnqueueRequest { + /// Owning user. + pub user_id: Uuid, + /// Owning org. `None` for system mail (anti-enumeration sign-up + /// collision is the canonical example). + pub org_id: Option, + /// Recipient email address. Display-case preserved. + pub recipient: String, + /// Sender address. Operators set this via outbound SMTP config. + pub from_address: String, + /// Template the email-outbox worker will render. + pub template: TemplateName, + /// Pre-rendered subject line. + pub subject: String, + /// Plain-text body (falls back when the recipient's MUA strips + /// HTML). + pub body_text: String, + /// Optional HTML body. + pub body_html: Option, + /// Free-form correlation id for tracing the outbox row back to + /// the originating request. Folded into the idempotency key. + pub correlation_id: Uuid, +} + +impl EnqueueRequest { + /// Compute the deterministic idempotency key. + /// + /// SHA-256 over `(user_id || event_kind || correlation_id)` — the + /// same producer call site emits the same key, so a retried + /// `enqueue` collapses on the partial unique index. + #[must_use] + pub fn idempotency_key(&self) -> String { + let mut hasher = Sha256::new(); + hasher.update(self.user_id.as_bytes()); + hasher.update(b":"); + hasher.update(self.template.as_key().as_bytes()); + hasher.update(b":"); + hasher.update(self.correlation_id.as_bytes()); + let digest = hasher.finalize(); + let mut hex = String::with_capacity(64); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(&mut hex, "{byte:02x}"); + } + hex + } +} + +/// Outbox writer. +/// +/// Construct with [`EmailOutboxWriter::new`]; `notify` is currently +/// a no-op that logs at `debug` because the NATS dependency lives in +/// the email-outbox layer. Once that layer lands, swap the body for an `async_nats` publish. +#[derive(Debug, Default, Clone, Copy)] +pub struct EmailOutboxWriter; + +impl EmailOutboxWriter { + /// Construct a new writer. Stateless today; once the email-outbox plugs in + /// NATS, the writer will own the JetStream client. + #[must_use] + pub const fn new() -> Self { + Self + } + + /// Enqueue an outbox row inside the caller's transaction. + /// + /// Idempotency is enforced by the `email_outbox_org_idempotency_unique` + /// partial unique index (NULLS NOT DISTINCT) introduced in the migration set. + /// Repeat calls with the same `(org_id, idempotency_key)` collapse + /// to one row via `ON CONFLICT DO NOTHING`. + pub async fn enqueue( + &self, + tx: &mut sqlx::Transaction<'_, Postgres>, + request: &EnqueueRequest, + ) -> Result<()> { + let idempotency_key = request.idempotency_key(); + sqlx::query!( + r#" + INSERT INTO email_outbox ( + id, org_id, to_address, from_address, subject, + body_text, body_html, template_key, locale, + idempotency_key, state, attempts, next_attempt_at + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, 'en', + $9, 'queued', 0, now() + ) + ON CONFLICT (org_id, idempotency_key) DO NOTHING + "#, + Uuid::now_v7(), + request.org_id, + request.recipient, + request.from_address, + request.subject, + request.body_text, + request.body_html.as_deref(), + request.template.as_key(), + idempotency_key, + ) + .execute(&mut **tx) + .await + .map_err(IdentityError::from)?; + Ok(()) + } + + /// Publish a wake-up message on `email.outbox.queue`. Best-effort — + /// must be called AFTER the producer commits the transaction. + /// Failure is logged + ignored. + /// + /// Today this is a no-op (the email-outbox worker drains the outbox + /// on its own schedule). The email-outbox layer wires it to NATS; + /// at which point this becomes async. + pub fn notify(&self, idempotency_key: &str) { + tracing::debug!( + target: "email.outbox.notify", + idempotency_key, + "outbox notify is a no-op until the email-outbox layer wires NATS", + ); + } +} diff --git a/crates/zagrosi-identity/src/email/retry.rs b/crates/zagrosi-identity/src/email/retry.rs new file mode 100644 index 0000000..82973d5 --- /dev/null +++ b/crates/zagrosi-identity/src/email/retry.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Exponential-backoff schedule for the email-outbox worker. +//! +//! The `email_outbox` row carries an `attempts` counter. Each failed +//! send increments it; [`next_attempt`] maps the **post-increment** +//! count to the delay before the row becomes eligible again. After +//! [`MAX_ATTEMPTS`] failures the row is dead-lettered (the function +//! returns `None`) and no further sends are attempted. +//! +//! The schedule is a fixed table rather than a computed `base * 2^n` +//! so the operator-visible cadence is exact and asserted by tests: +//! 30 s → 2 min → 10 min → 1 h, then dead-letter. There are +//! [`MAX_ATTEMPTS`] (`5`) total send attempts: the initial attempt +//! plus four backed-off retries consuming the four schedule entries. + +use std::time::Duration; + +/// Total send attempts before a row is moved to `dead`. +/// +/// One initial attempt plus four backed-off retries. The producer +/// writes the row with `attempts = 0`; the worker increments on each +/// failed send. When the post-increment count reaches this value the +/// row is dead-lettered. +pub const MAX_ATTEMPTS: i32 = 5; + +/// Backoff delays indexed by `attempts - 1` (the just-incremented +/// failure count). Length is `MAX_ATTEMPTS - 1`: the last failure +/// has no following retry, it dead-letters instead. +/// +/// 1st failure → wait 30 s, 2nd → 2 min, 3rd → 10 min, 4th → 1 h. +/// The 5th failure dead-letters. +const BACKOFF: [Duration; (MAX_ATTEMPTS - 1) as usize] = [ + Duration::from_secs(30), + Duration::from_secs(120), + Duration::from_secs(600), + Duration::from_secs(3_600), +]; + +/// Map a post-increment `attempts` count to the delay before the row +/// is eligible for another send. +/// +/// `attempts` is the value **after** incrementing on a failed send +/// (so the first failed send calls this with `1`). Returns `None` +/// when the cap is reached — the caller dead-letters the row. +/// +/// Values `<= 0` are treated as `1` (defence against a caller passing +/// the pre-increment count); the schedule is clamped, never indexed +/// out of bounds. +#[must_use] +pub fn next_attempt(attempts: i32) -> Option { + if attempts >= MAX_ATTEMPTS { + return None; + } + let idx = attempts.max(1) - 1; + // `idx >= 0` (because `attempts.max(1) >= 1`), so `try_from` + // cannot fail; `get` bounds-checks the upper end. No sign-losing + // `as` cast. + usize::try_from(idx) + .ok() + .and_then(|i| BACKOFF.get(i)) + .copied() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schedule_is_monotonically_increasing() { + let mut prev = Duration::ZERO; + for attempts in 1..MAX_ATTEMPTS { + let delay = next_attempt(attempts).expect("retry below cap yields a delay"); + assert!( + delay > prev, + "delay for attempts={attempts} ({delay:?}) must exceed prior ({prev:?})", + ); + prev = delay; + } + } + + #[test] + fn schedule_matches_documented_constants() { + assert_eq!(next_attempt(1), Some(Duration::from_secs(30))); + assert_eq!(next_attempt(2), Some(Duration::from_secs(120))); + assert_eq!(next_attempt(3), Some(Duration::from_secs(600))); + assert_eq!(next_attempt(4), Some(Duration::from_secs(3_600))); + } + + #[test] + fn cap_reached_returns_none() { + assert_eq!(next_attempt(MAX_ATTEMPTS), None); + assert_eq!(next_attempt(MAX_ATTEMPTS + 1), None); + assert_eq!(next_attempt(99), None); + } + + #[test] + fn non_positive_attempts_clamp_to_first_delay() { + // Defence against a caller passing the pre-increment count. + assert_eq!(next_attempt(0), Some(Duration::from_secs(30))); + assert_eq!(next_attempt(-5), Some(Duration::from_secs(30))); + } + + #[test] + fn backoff_table_length_is_max_attempts_minus_one() { + // The final failed attempt dead-letters instead of scheduling + // another retry, so the table has MAX_ATTEMPTS-1 entries. + assert_eq!(BACKOFF.len(), (MAX_ATTEMPTS - 1) as usize); + } +} diff --git a/crates/zagrosi-identity/src/email/template.rs b/crates/zagrosi-identity/src/email/template.rs new file mode 100644 index 0000000..aa36cde --- /dev/null +++ b/crates/zagrosi-identity/src/email/template.rs @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Catalogue of email templates the password-auth producer hands to +//! the email-outbox worker. +//! +//! The actual `.ftl` files live under `crates/zagrosi-identity/templates/`. +//! The email-outbox worker resolves [`TemplateName`] to a fluent-templates +//! key + locale and renders against the payload JSON the producer +//! attached to the outbox row. + +/// Templates this crate writes into `email_outbox`. +/// +/// Wire-format value is the snake_case stringification (used by the +/// email-outbox worker as the lookup key into the embedded fluent +/// loader). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum TemplateName { + /// Sign-up verification email. Carries a `vrf_*` token URL. + VerifyEmail, + /// Password reset email. Carries a `rst_*` token URL. + PasswordReset, + /// Anti-enumeration sign-up collision: the email already + /// belongs to a known user. Instructs the recipient to sign in + /// or use the password-reset flow. + AccountAlreadyExists, + /// Org-invite email. The invite-issuance code path lives in + /// the admin layer; the template ships here so the embedded fluent + /// loader is complete from password-auth onward. + OrgInvite, +} + +impl TemplateName { + /// Wire-format key written to `email_outbox.template_key`. + #[must_use] + pub const fn as_key(self) -> &'static str { + match self { + Self::VerifyEmail => "verify_email", + Self::PasswordReset => "password_reset", + Self::AccountAlreadyExists => "account_already_exists", + Self::OrgInvite => "org_invite", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn keys_are_stable() { + assert_eq!(TemplateName::VerifyEmail.as_key(), "verify_email"); + assert_eq!(TemplateName::PasswordReset.as_key(), "password_reset"); + assert_eq!( + TemplateName::AccountAlreadyExists.as_key(), + "account_already_exists", + ); + assert_eq!(TemplateName::OrgInvite.as_key(), "org_invite"); + } +} diff --git a/crates/zagrosi-identity/src/email/transport.rs b/crates/zagrosi-identity/src/email/transport.rs new file mode 100644 index 0000000..600ff6f --- /dev/null +++ b/crates/zagrosi-identity/src/email/transport.rs @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown)] +//! `lettre`-backed [`EmailTransport`] implementation. +//! +//! [`LettreTransport`] is the default concrete transport the +//! email-outbox worker calls after dequeuing a row. It speaks SMTP +//! over **implicit TLS** (`smtps://`) only — the design forbids +//! cleartext and opportunistic-STARTTLS schemes, so +//! [`LettreTransport::from_config`] rejects any other URL scheme up +//! front rather than silently downgrading the connection. +//! +//! TLS uses rustls with the `aws-lc-rs` crypto provider. That is +//! selected by the workspace `lettre` feature set +//! (`tokio1-rustls` + `aws-lc-rs`, `default-features = false`); see +//! the pin rationale in the root `Cargo.toml`. The feature choice +//! cannot be re-asserted with a `cfg(feature = ...)` guard from this +//! crate because the features belong to the `lettre` dependency, not +//! `zagrosi-identity` — the workspace pin is the single source of +//! truth and a `cargo tree -e features` check belongs in CI. +//! +//! Per-tenant SMTP routing and HTTP-API providers are out of scope +//! here; they plug in behind the same [`EmailTransport`] trait +//! without touching this module (deferred to the admin layer). + +use async_trait::async_trait; +use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType}; +use lettre::transport::smtp::AsyncSmtpTransport; +use lettre::{AsyncTransport, Message, Tokio1Executor}; +use zagrosi_core::{ + EmailMessage, EmailTransport, EmailTransportError, EmailTransportFault, PermanentFaultCategory, + RedactedString, +}; + +use crate::config::EmailConfig; +use crate::error::IdentityError; + +/// Default SMTP-implicit-TLS scheme. The only scheme accepted by +/// [`LettreTransport::from_config`]. +const REQUIRED_SCHEME: &str = "smtps://"; + +/// `lettre` async-SMTP transport with a built-in connection pool. +/// +/// Cloning is cheap and shares the underlying pooled connections, so +/// the worker can hand clones to concurrent row processors. +#[derive(Clone)] +pub struct LettreTransport { + mailer: AsyncSmtpTransport, +} + +impl std::fmt::Debug for LettreTransport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // The mailer wraps the credentialed URL; never render it. + f.write_str("LettreTransport()") + } +} + +impl LettreTransport { + /// Build a transport from [`EmailConfig`]. + /// + /// Validation performed here (deferred from `IdentityConfig::load` + /// so non-worker binaries start without SMTP configured): + /// + /// - `smtp_url` is non-empty and uses the `smtps://` scheme. + /// - `smtp_url` parses as a `lettre` connection URL. + /// - `smtp_from` is non-empty and parses as a mailbox. + /// + /// # Errors + /// + /// [`IdentityError::EmailTransportConfig`] with an operator-facing + /// reason that **never** echoes the credentialed URL. + pub fn from_config(cfg: &EmailConfig) -> Result { + if cfg.smtp_url.is_empty() { + return Err(IdentityError::EmailTransportConfig { + reason: "ZAGROSI_EMAIL.SMTP_URL is required to run the email worker".into(), + }); + } + // Scheme check is a literal prefix test, not a parse, so the + // credential in the URL is never split out into a value that + // could later be logged. + if !cfg.smtp_url.starts_with(REQUIRED_SCHEME) { + return Err(IdentityError::EmailTransportConfig { + reason: format!( + "ZAGROSI_EMAIL.SMTP_URL must use the `{REQUIRED_SCHEME}` scheme \ + (implicit TLS); cleartext and opportunistic STARTTLS are refused" + ), + }); + } + if cfg.smtp_from.is_empty() { + return Err(IdentityError::EmailTransportConfig { + reason: "ZAGROSI_EMAIL.SMTP_FROM is required to run the email worker".into(), + }); + } + // Validate the sender mailbox now so a misconfiguration fails + // at worker construction, not on the first dequeued row. + cfg.smtp_from + .parse::() + .map_err(|_| IdentityError::EmailTransportConfig { + reason: "ZAGROSI_EMAIL.SMTP_FROM is not a valid mailbox \ + (expected `Name ` or `user@host`)" + .into(), + })?; + + let builder = + AsyncSmtpTransport::::from_url(&cfg.smtp_url).map_err(|_| { + IdentityError::EmailTransportConfig { + // Deliberately generic: the lettre error can echo the + // host portion of the URL; keep it out of the reason. + reason: "ZAGROSI_EMAIL.SMTP_URL is not a valid SMTP connection URL".into(), + } + })?; + Ok(Self { + mailer: builder.build(), + }) + } + + /// Construct directly from an already-built `lettre` mailer. + /// Used by tests and by future per-tenant routing that builds the + /// mailer through a different path. + #[must_use] + pub const fn from_mailer(mailer: AsyncSmtpTransport) -> Self { + Self { mailer } + } +} + +/// Build the `lettre` [`Message`] from the worker's value object. +/// +/// A build failure (malformed mailbox or header) is a **permanent** +/// fault: re-sending the identical bytes will fail identically, so +/// the row must dead-letter rather than spin the retry schedule. +fn build_message(msg: &EmailMessage) -> Result { + let from = msg + .from + .parse::() + .map_err(|_| permanent(PermanentFaultCategory::InvalidSender, "unparseable From"))?; + let to = msg + .to + .parse::() + .map_err(|_| permanent(PermanentFaultCategory::InvalidRecipient, "unparseable To"))?; + + let builder = Message::builder() + .from(from) + .to(to) + .subject(msg.subject.clone()); + + let message = match &msg.body_html { + Some(html) => builder.multipart(MultiPart::alternative_plain_html( + msg.body_text.clone(), + html.clone(), + )), + None => builder.singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(msg.body_text.clone()), + ), + }; + + message.map_err(|_| { + permanent( + PermanentFaultCategory::ContentRejected, + "message build failed", + ) + }) +} + +/// Helper to construct a redaction-safe permanent fault. `detail` is +/// an operator-facing constant only — never attacker / recipient +/// content (which the wrapper would redact anyway). +fn permanent(category: PermanentFaultCategory, detail: &str) -> EmailTransportError { + EmailTransportError::Permanent { + fault: EmailTransportFault { + category, + smtp_code: None, + redacted_detail: RedactedString::new(detail.to_owned()), + }, + } +} + +/// Classify a `lettre` SMTP send error. +/// +/// `lettre` distinguishes permanent (5xx-class) from transient +/// (4xx-class / connection / timeout) failures. Permanent → the row +/// dead-letters. Everything else → transient, so the worker's +/// backoff schedule retries until the cap. Fine-grained +/// recipient-vs-content sub-categorisation of 5xx replies is a +/// deferred refinement; v0.1 maps every permanent SMTP failure to +/// [`PermanentFaultCategory::Other`]. +/// +/// **PII:** `lettre::transport::smtp::Error`'s `Display` appends its +/// source chain, and for a `Response`/`Transient` kind that source is +/// the raw SMTP reply text — which routinely echoes the envelope +/// `RCPT TO` address (`452 4.1.1 Mailbox full`). +/// `err.to_string()` is therefore **never** used here; the worker +/// records only a static kind-bucket label in `last_error`. The +/// underlying error is logged once, at the worker call site, behind +/// the redacted-`Debug` boundary. +fn classify(err: &lettre::transport::smtp::Error) -> EmailTransportError { + if err.is_permanent() { + permanent(PermanentFaultCategory::Other, "smtp permanent failure") + } else { + EmailTransportError::Unavailable("smtp transient failure".to_owned()) + } +} + +#[async_trait] +impl EmailTransport for LettreTransport { + async fn send(&self, message: EmailMessage) -> Result<(), EmailTransportError> { + let built = build_message(&message)?; + self.mailer + .send(built) + .await + .map(|_| ()) + .map_err(|e| classify(&e)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(LettreTransport: Send, Sync, Clone, std::fmt::Debug); + + fn cfg(url: &str, from: &str) -> EmailConfig { + EmailConfig { + smtp_url: url.into(), + smtp_from: from.into(), + } + } + + #[test] + fn empty_url_is_rejected() { + let err = LettreTransport::from_config(&cfg("", "a@b.test")).unwrap_err(); + assert!(matches!(err, IdentityError::EmailTransportConfig { .. })); + } + + #[test] + fn non_smtps_scheme_is_rejected() { + for url in [ + "smtp://user:pw@host:25", + "smtp://host:587?tls=required", + "http://host", + ] { + let err = LettreTransport::from_config(&cfg(url, "a@b.test")).unwrap_err(); + match err { + IdentityError::EmailTransportConfig { reason } => { + assert!( + reason.contains("smtps://"), + "reason should name the required scheme, got: {reason}", + ); + } + other => panic!("expected EmailTransportConfig, got {other:?}"), + } + } + } + + #[test] + fn config_reason_never_echoes_the_credentialed_url() { + // A bad-but-smtps URL with an embedded password: the failure + // reason must not leak the secret. + let err = LettreTransport::from_config(&cfg("smtps://user:sup3rsecret@", "a@b.test")) + .unwrap_err(); + let IdentityError::EmailTransportConfig { reason } = err else { + panic!("expected EmailTransportConfig"); + }; + assert!( + !reason.contains("sup3rsecret"), + "reason must not echo the SMTP password: {reason}", + ); + } + + #[test] + fn empty_from_is_rejected() { + let err = LettreTransport::from_config(&cfg("smtps://host:465", "")).unwrap_err(); + match err { + IdentityError::EmailTransportConfig { reason } => { + assert!(reason.contains("SMTP_FROM")); + } + other => panic!("expected EmailTransportConfig, got {other:?}"), + } + } + + #[test] + fn invalid_from_mailbox_is_rejected() { + let err = LettreTransport::from_config(&cfg("smtps://host:465", "not a mailbox at all")) + .unwrap_err(); + assert!(matches!(err, IdentityError::EmailTransportConfig { .. })); + } + + // `#[tokio::test]`: the pooled `lettre` transport's `Drop` needs a + // tokio runtime in scope (it tears down the async connection + // pool). A plain `#[test]` aborts with "panic in a destructor". + // No network I/O happens here — `from_url` only parses and + // `build()` is infallible. + #[tokio::test] + async fn valid_smtps_config_builds_a_transport() { + let t = LettreTransport::from_config(&cfg( + "smtps://user:pw@smtp.example.com:465", + "Zagrosi ", + )) + .expect("valid smtps config builds"); + assert_eq!(format!("{t:?}"), "LettreTransport()"); + } + + #[test] + fn build_message_text_only_is_singlepart() { + let msg = EmailMessage { + from: "from@example.com".into(), + to: "to@example.com".into(), + subject: "Subject".into(), + body_text: "Plain body".into(), + body_html: None, + idempotency_key: "k".into(), + }; + build_message(&msg).expect("text-only message builds"); + } + + #[test] + fn build_message_with_html_is_multipart() { + let msg = EmailMessage { + from: "from@example.com".into(), + to: "to@example.com".into(), + subject: "Subject".into(), + body_text: "Plain".into(), + body_html: Some("

HTML

".into()), + idempotency_key: "k".into(), + }; + build_message(&msg).expect("multipart message builds"); + } + + #[test] + fn build_message_bad_recipient_is_permanent_invalid_recipient() { + let msg = EmailMessage { + from: "from@example.com".into(), + to: "@@not-an-address@@".into(), + subject: "S".into(), + body_text: "B".into(), + body_html: None, + idempotency_key: "k".into(), + }; + match build_message(&msg) { + Err(EmailTransportError::Permanent { fault }) => { + assert_eq!(fault.category, PermanentFaultCategory::InvalidRecipient); + } + other => panic!("expected Permanent InvalidRecipient, got {other:?}"), + } + } + + #[test] + fn build_message_bad_sender_is_permanent_invalid_sender() { + let msg = EmailMessage { + from: "## bogus ##".into(), + to: "to@example.com".into(), + subject: "S".into(), + body_text: "B".into(), + body_html: None, + idempotency_key: "k".into(), + }; + match build_message(&msg) { + Err(EmailTransportError::Permanent { fault }) => { + assert_eq!(fault.category, PermanentFaultCategory::InvalidSender); + } + other => panic!("expected Permanent InvalidSender, got {other:?}"), + } + } +} diff --git a/crates/zagrosi-identity/src/email/worker.rs b/crates/zagrosi-identity/src/email/worker.rs new file mode 100644 index 0000000..591b51a --- /dev/null +++ b/crates/zagrosi-identity/src/email/worker.rs @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown)] +//! Email-outbox worker run-loop. +//! +//! [`EmailWorker`] drains `email_outbox` by repeatedly calling +//! [`OutboxDispatcher::process_one`]. Two things wake a drain: +//! +//! 1. A periodic sweep ([`EmailWorker::with_sweep_interval`], +//! default 30 s) — the authoritative liveness mechanism. The DB +//! outbox is the source of truth; even with NATS completely down +//! every row is eventually delivered by the sweep. +//! 2. A NATS hint on `email.outbox.queue` — a latency optimisation +//! only. The producer publishes (best-effort, post-commit) so a +//! freshly enqueued mail is sent in well under a second instead of +//! waiting up to one sweep interval. The hint payload is ignored; +//! its arrival just triggers an immediate drain (the worker always +//! re-reads from the DB). +//! +//! The NATS subscription feeds an internal [`tokio::sync::Notify`]. +//! Tests poke that `Notify` directly via [`EmailWorker::waker`] to +//! exercise the wake path deterministically without a broker. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; +use tracing::{debug, error, info, warn}; +use zagrosi_core::EmailTransport; + +use crate::email::dispatch::{OutboxDispatcher, ProcessOutcome}; + +/// NATS subject the producer publishes wake hints on and the worker +/// subscribes to. The payload is intentionally unused. +pub const EMAIL_OUTBOX_SUBJECT: &str = "email.outbox.queue"; + +/// Default periodic sweep cadence. +const DEFAULT_SWEEP: Duration = Duration::from_secs(30); +/// Default rows drained per wake before yielding back to the select. +const DEFAULT_BATCH: u32 = 50; + +/// Tally of one [`EmailWorker::drain_once`] pass. Returned for test +/// assertions; the same numbers are emitted as metrics. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct DrainOutcome { + /// Rows delivered to the transport this pass. + pub sent: u64, + /// Rows that hit a transient failure and were rescheduled. + pub retried: u64, + /// Rows dead-lettered (retry cap reached or permanent fault). + pub dead: u64, +} + +impl DrainOutcome { + /// Total rows processed (`sent + retried + dead`). + #[must_use] + pub const fn processed(&self) -> u64 { + self.sent + self.retried + self.dead + } +} + +/// Drains `email_outbox`, sending via the injected transport. +pub struct EmailWorker { + dispatcher: OutboxDispatcher, + transport: Arc, + sweep_interval: Duration, + batch_size: u32, + waker: Arc, + shutdown: CancellationToken, +} + +impl EmailWorker { + /// Construct with default sweep (30 s) and batch (50). + #[must_use] + pub fn new(dispatcher: OutboxDispatcher, transport: Arc) -> Self { + Self { + dispatcher, + transport, + sweep_interval: DEFAULT_SWEEP, + batch_size: DEFAULT_BATCH, + waker: Arc::new(Notify::new()), + shutdown: CancellationToken::new(), + } + } + + /// Override the periodic sweep cadence. + #[must_use] + pub const fn with_sweep_interval(mut self, interval: Duration) -> Self { + self.sweep_interval = interval; + self + } + + /// Override the per-wake drain batch size (floored at 1; a zero + /// batch would make every drain a no-op and wedge the queue). + #[must_use] + pub const fn with_batch_size(mut self, batch: u32) -> Self { + // `u32::max` routes through `Ord::max`, not const-stable on + // the pinned toolchain; an explicit compare keeps this `const`. + self.batch_size = if batch == 0 { 1 } else { batch }; + self + } + + /// Supply an external cancellation token so the owning service + /// can drive a clean shutdown. + #[must_use] + pub fn with_shutdown(mut self, token: CancellationToken) -> Self { + self.shutdown = token; + self + } + + /// Handle used to wake an immediate drain. The NATS listener + /// holds one; tests use it to exercise the wake path without a + /// broker. + #[must_use] + pub fn waker(&self) -> Arc { + Arc::clone(&self.waker) + } + + /// The shutdown token (clone-and-cancel to stop [`EmailWorker::run`]). + #[must_use] + pub fn shutdown_token(&self) -> CancellationToken { + self.shutdown.clone() + } + + /// Spawn the NATS wake-hint listener. + /// + /// Any message on [`EMAIL_OUTBOX_SUBJECT`] triggers an immediate + /// drain. The loop re-arms the subscription on broker bounces with + /// bounded backoff (mirrors the session-event subscriber). A + /// dropped hint is harmless — the periodic sweep is the safety + /// net — so subscription errors are logged, never fatal. + /// + /// Returns a [`tokio::task::JoinHandle`]; the owning service holds + /// it so shutdown can abort the listener. + #[must_use] + pub fn spawn_nats_listener(&self, client: async_nats::Client) -> tokio::task::JoinHandle<()> { + let waker = Arc::clone(&self.waker); + let shutdown = self.shutdown.clone(); + tokio::spawn(async move { + let mut backoff = Duration::from_millis(250); + loop { + if shutdown.is_cancelled() { + return; + } + tokio::select! { + () = shutdown.cancelled() => return, + result = run_nats_listener(&client, &waker) => { + match result { + Ok(()) => warn!( + "email-outbox NATS listener stream ended; restarting after backoff", + ), + Err(err) => error!( + %err, + "email-outbox NATS listener error; restarting after backoff", + ), + } + } + } + tokio::select! { + () = shutdown.cancelled() => return, + () = tokio::time::sleep(backoff) => {} + } + backoff = std::cmp::min(backoff * 2, Duration::from_secs(30)); + } + }) + } + + /// Run until the shutdown token is cancelled. + /// + /// An initial drain runs immediately (the first interval tick + /// fires at once), then on every sweep tick or wake hint. A DB + /// error in a drain is logged and the loop continues — the next + /// sweep retries; the worker never exits on a transient fault. + pub async fn run(self) { + let mut interval = tokio::time::interval(self.sweep_interval); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + info!( + sweep_secs = self.sweep_interval.as_secs(), + batch = self.batch_size, + "email-outbox worker started", + ); + loop { + tokio::select! { + () = self.shutdown.cancelled() => { + info!("email-outbox worker shutting down"); + return; + } + _ = interval.tick() => {} + () = self.waker.notified() => { + debug!("email-outbox worker woken by hint"); + } + } + self.drain_once().await; + } + } + + /// Drain up to `batch_size` rows. Logs and stops the pass on a DB + /// error (the row stays eligible; the next sweep retries). Never + /// panics, never propagates — a worker must not die on a bad row. + pub async fn drain_once(&self) -> DrainOutcome { + let mut outcome = DrainOutcome::default(); + for _ in 0..self.batch_size { + let started = Instant::now(); + let transport = Arc::clone(&self.transport); + let dispatcher = self.dispatcher.clone(); + // Process the row in a child task so a panic in a transport + // impl (or anywhere in `process_one`) is caught as a + // `JoinError` instead of unwinding the worker loop. A panic + // drops the open transaction → Postgres rolls it back → the + // row stays `queued`/`failed` and the next sweep retries. + // No email lost, none double-sent. Honours the + // "never panics, never propagates" contract above. + let join = tokio::spawn(async move { + dispatcher + .process_one(move |msg| async move { transport.send(msg).await }) + .await + }) + .await; + let result = match join { + Ok(r) => r, + Err(panic) => { + metrics::counter!("email_outbox_dispatch_errors_total").increment(1); + error!( + %panic, + "email-outbox row processing panicked; isolated, ending sweep early", + ); + break; + } + }; + match result { + Ok(None) => break, + Ok(Some(ProcessOutcome::Sent { id })) => { + record_send_duration(started.elapsed()); + metrics::counter!("email_outbox_sent_total").increment(1); + debug!(outbox_id = %id, "email sent"); + outcome.sent += 1; + } + Ok(Some(ProcessOutcome::Retried { id, attempts })) => { + record_send_duration(started.elapsed()); + metrics::counter!("email_outbox_attempt_failures_total").increment(1); + warn!(outbox_id = %id, attempts, "email send failed; rescheduled"); + outcome.retried += 1; + } + Ok(Some(ProcessOutcome::DeadLettered { id, attempts })) => { + record_send_duration(started.elapsed()); + metrics::counter!("email_outbox_dead_total").increment(1); + error!( + outbox_id = %id, + attempts, + "email dead-lettered (retry cap or permanent fault)", + ); + outcome.dead += 1; + } + Err(err) => { + metrics::counter!("email_outbox_dispatch_errors_total").increment(1); + warn!(%err, "email-outbox dispatch error; ending sweep early"); + break; + } + } + } + match self.dispatcher.pending_count().await { + Ok(pending) => { + // The metrics gauge API is f64. A backlog never + // approaches 2^52 rows, so the precision loss is + // unreachable in practice; the lint is allowed + // locally rather than papered over with a lossy cast. + #[allow(clippy::cast_precision_loss)] + let pending_f = pending as f64; + metrics::gauge!("email_outbox_pending_total").set(pending_f); + } + Err(err) => debug!(%err, "email-outbox pending_count sample failed"), + } + outcome + } +} + +fn record_send_duration(elapsed: Duration) { + metrics::histogram!("email_outbox_send_duration_seconds").record(elapsed.as_secs_f64()); +} + +async fn run_nats_listener( + client: &async_nats::Client, + waker: &Notify, +) -> Result<(), async_nats::SubscribeError> { + use futures::StreamExt as _; + + let mut sub = client.subscribe(EMAIL_OUTBOX_SUBJECT).await?; + info!( + subject = EMAIL_OUTBOX_SUBJECT, + "email-outbox NATS listener armed" + ); + while sub.next().await.is_some() { + // Payload ignored: the hint only means "drain now"; the + // worker always re-reads the DB (the source of truth). + waker.notify_one(); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(EmailWorker: Send, Sync); + assert_impl_all!(DrainOutcome: Send, Sync, Copy); + + #[test] + fn subject_is_stable() { + assert_eq!(EMAIL_OUTBOX_SUBJECT, "email.outbox.queue"); + } + + #[test] + fn drain_outcome_processed_sums_all_terminal_states() { + let o = DrainOutcome { + sent: 3, + retried: 2, + dead: 1, + }; + assert_eq!(o.processed(), 6); + assert_eq!(DrainOutcome::default().processed(), 0); + } + + // `#[tokio::test]`: sqlx's lazy pool requires a Tokio context + // even though it never connects here. + #[tokio::test] + async fn batch_size_floor_is_one() { + // A zero batch would make every drain a no-op and silently + // wedge the queue. Clamp to at least one. + let pool = sqlx::postgres::PgPool::connect_lazy("postgres://invalid") + .expect("lazy pool never connects here"); + let dispatcher = OutboxDispatcher::new(pool); + let transport: Arc = Arc::new(NoopTransport); + let worker = EmailWorker::new(dispatcher, transport).with_batch_size(0); + assert_eq!(worker.batch_size, 1); + } + + struct NoopTransport; + + #[async_trait::async_trait] + impl EmailTransport for NoopTransport { + async fn send( + &self, + _message: zagrosi_core::EmailMessage, + ) -> Result<(), zagrosi_core::EmailTransportError> { + Ok(()) + } + } +} diff --git a/crates/zagrosi-identity/src/error.rs b/crates/zagrosi-identity/src/error.rs new file mode 100644 index 0000000..359bd85 --- /dev/null +++ b/crates/zagrosi-identity/src/error.rs @@ -0,0 +1,842 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Errors produced by the `zagrosi-identity` crate. +//! +//! Per the workspace boundary policy (`zagrosi-core::error`), this is +//! the home for every identity-specific failure introduced across the +//! identity implementation. Downstream layers extend this enum; they +//! MUST NOT extend `ZagrosiError`. +//! +//! The crate skeleton ships only the variants needed for configuration +//! validation. Subsequent layers add hashing, session, OIDC, SAML, +//! SCIM, rate-limit, and email variants alongside their own code. + +/// Errors produced by `zagrosi-identity`. +/// +/// The `Config` variant holds a boxed [`figment::Error`] to keep the +/// enum itself small (mirrors `zagrosi_core::ZagrosiError::Config`); +/// `figment::Error` is otherwise large enough to bloat every +/// `Result` returned across the workspace. +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + /// Configuration loading or parsing failed. + #[error("configuration error: {0}")] + Config(#[source] Box), + + /// `ZAGROSI_SECRETS_KEY` is required but not present in the + /// configuration. The 32-byte base64 master key is consumed by the + /// AES-256-GCM secrets envelope. + #[error("ZAGROSI_SECRETS_KEY is required (32-byte base64)")] + MissingSecretsKey, + + /// `ZAGROSI_SECRETS_KEY` is present but malformed. Either it is not + /// valid base64 or it does not decode to exactly 32 bytes. + #[error("ZAGROSI_SECRETS_KEY is malformed: {reason}")] + MalformedSecretsKey { + /// Human-readable description of the validation failure. + reason: String, + }, + + /// `ZAGROSI_VALKEY_URL` is required but not present in the + /// configuration. The URL is consumed by the rate limiter + /// (the rate-limit module). + #[error("ZAGROSI_VALKEY_URL is required")] + MissingValkeyUrl, + + /// AEAD authentication failed (tampered ciphertext, wrong key, or + /// wrong nonce). The exact failure mode is intentionally not + /// surfaced — AES-GCM verification is constant-time and disclosing + /// which check failed would leak side-channel signal to attackers. + #[error("aead integrity check failed")] + IntegrityError, + + /// Envelope JSON is well-formed but a field is malformed (non-base64, + /// wrong byte length, etc.). Carries a `&'static str` reason so the + /// caller can branch on the cause without leaking attacker-supplied + /// content into log surfaces. + #[error("malformed secrets envelope: {0}")] + MalformedEnvelope(&'static str), + + /// Envelope has a `key_id` not handled by this provider. v0.1 only + /// handles [`crate::crypto::KEY_ID_V0_1_STATIC`]; future KMS + /// provider handles `v0.2-kms-*`. Returning this variant (rather + /// than [`IdentityError::IntegrityError`]) is the documented + /// routing point for the future KMS layer's rewrap. + #[error("unknown envelope key_id: {0}")] + UnknownKeyId(String), + + /// Database / persistence error from the `sqlx` driver. The wrapped + /// `sqlx::Error` carries the full diagnostic chain (Postgres SQLSTATE, + /// operation, etc.) for log surfaces. Boxed to keep the enum small — + /// `sqlx::Error` is otherwise large enough to bloat every + /// `Result` returned across repo call-sites. + #[error("database error: {0}")] + Database(#[source] Box), + + /// Raw token string failed prefix / body validation. Domain-layer + /// `domain::token_format::parse_raw` is the single chokepoint; this + /// variant is returned rather than the gateway-facing + /// `zagrosi_core::AuthError::MalformedPrefix` so identity-internal + /// flows (password reset, email verification) can branch on it + /// without depending on the gateway port. + #[error("malformed token: {0}")] + MalformedToken(&'static str), + + /// `find_by_email_lower` looked up an address that does not match a + /// live (`deleted_at IS NULL`) row. Used by repo callers that want + /// to return a typed not-found rather than `Option`. + #[error("user not found")] + UserNotFound, + + /// `OrgRepo` lookup did not match a live row. + #[error("organisation not found")] + OrgNotFound, + + /// Token-hash lookup did not match a live row across sessions / + /// PATs / SCIM tokens / refresh tokens / service tokens. Callers + /// log this at `info` not `warn` — the bulk of these are scanners. + #[error("token not found")] + TokenNotFound, + + /// `UserRepo::create` (or membership create) hit the partial unique + /// index on `email_lower` for a live user. Mapped from PG SQLSTATE + /// `23505` against the relevant constraint name. + #[error("email address already in use")] + EmailAlreadyExists, + + /// `OrgRepo::create` hit the live-row unique partial index on `slug`. + #[error("organisation slug already in use")] + OrgSlugAlreadyExists, + + /// `MembershipRepo::create` hit the live-row partial unique on + /// `(user_id, org_id)`. Distinct from `EmailAlreadyExists` so the + /// password-auth org-join flow can branch on it cleanly. + #[error("membership already exists")] + MembershipAlreadyExists, + + /// `oidc_refresh_tokens` chain replay — a refresh token already + /// marked `used_at IS NOT NULL` was redeemed again. The OIDC client + /// translates this into a chain-wide revocation. + #[error("oidc refresh-chain replay detected")] + RefreshChainReplay, + + /// `saml_assertion_replay` PK collision — the same assertion ID + /// landed twice for the same `org_idp_id`. The SAML SP translates + /// this into an authentication failure. + #[error("saml assertion replay detected")] + AssertionReplay, + + /// `federated_identities` anchor `(protocol, iss, sub)` is taken + /// by a tombstoned (`user_id` NULL) row. The legal re-attachment + /// path is the admin merge flow (deferred to the admin layer). + #[error("federated identity is tombstoned (admin merge required)")] + FederatedIdentityTombstoned, + + /// `SessionRepo::update_active_org` lost the optimistic-lock + /// race — the row's `version` no longer matches the caller's. + /// The session module retries on this variant. + #[error("optimistic lock conflict")] + OptimisticLockConflict, + + /// Argon2id startup verify-bench exceeded 1.5 s; the configured + /// profile would brown out under load. Binary refuses to start. + #[error("argon2 profile too slow ({measured_ms}ms > 1500ms); refusing to start")] + Argon2ProfileTooSlow { + /// Measured verify-bench duration in milliseconds. + measured_ms: u64, + }, + + /// Argon2id hash / verify failed at the algorithm layer (malformed + /// PHC string, parameter mismatch). Distinct from `InvalidCredentials` + /// because this is an internal failure, not a wrong-password branch. + #[error("argon2 internal error: {0}")] + Argon2Internal(&'static str), + + /// Submitted password is shorter than `IdentityConfig::password.min_length`. + #[error("password too short (minimum {min} chars)")] + PasswordTooShort { + /// Minimum accepted password length. + min: usize, + }, + + /// Submitted password exceeds the hard-coded 256-char `DoS` guard. + #[error("password too long (maximum {max} chars)")] + PasswordTooLong { + /// Maximum accepted password length. + max: usize, + }, + + /// Submitted password appears in the HIBP breach corpus. + #[error("password appears in known-breach corpus")] + PasswordBreached, + + /// HIBP service unreachable while mode is `online`. Sign-up + /// fail-closes; surface a `Retry-After` to the caller. + #[error("breach-list service unavailable; please retry")] + BreachlistUnavailable, + + /// Sign-in credentials invalid. Constant-time path; the variant + /// MUST NOT disclose whether the email or password was wrong. + #[error("invalid credentials")] + InvalidCredentials, + + /// User exists but cannot sign in (soft-deleted, locked, etc.). + #[error("account disabled")] + AccountDisabled, + + /// User exists but has not verified their email. + #[error("email not verified")] + EmailNotVerified, + + /// Single-use token (verification or reset) is past `expires_at`. + #[error("token expired")] + TokenExpired, + + /// Single-use token already consumed (`used_at IS NOT NULL`). + #[error("token already used")] + TokenAlreadyUsed, + + /// Token prefix does not match the expected class. Defence-in-depth + /// pre-check; the prefix is part of the hash so the lookup would + /// already fail without matching the right token table. + #[error("token prefix mismatch (expected {expected})")] + TokenPrefixMismatch { + /// Expected prefix string (e.g. `"vrf_"`). + expected: &'static str, + }, + + /// Email address malformed or unparseable. + #[error("invalid email")] + InvalidEmail, + + /// Rate-limit configuration failed validation at startup. The reason + /// is a human-readable description of the violated invariant + /// (malformed `/` literal, zero / out-of-range + /// numeric, etc.). + #[error("rate-limit configuration is malformed: {reason}")] + MalformedRateLimit { + /// Human-readable description of the validation failure. + reason: String, + }, + + /// Session-resolver configuration failed validation at startup. + /// Carries a human-readable description of the violated + /// invariant (zero / out-of-range numeric, fail-closed TTL + /// exceeding the healthy TTL, empty NATS URL when fail-closed + /// is required, etc.). + #[error("session configuration is malformed: {reason}")] + MalformedSessionConfig { + /// Human-readable description of the validation failure. + reason: String, + }, + + /// Sliding-window per-IP / per-token bucket exhausted. `retry_after` + /// populates the `Retry-After` header; `scope` identifies the + /// bucket (sign-in, password reset, SCIM, etc.) for telemetry. + #[error("rate limit exceeded for {scope}; retry in {retry_after:?}")] + RateLimited { + /// Wall-clock duration the caller should wait before retrying. + retry_after: std::time::Duration, + /// Bucket scope tag (`signin` / `password_reset` / `scim` / ...). + scope: &'static str, + }, + + /// Per-account exponential lockout active. `retry_after` populates + /// the `Retry-After` header; `attempts` is the breach count for + /// telemetry. + #[error("account locked out for {retry_after:?} after {attempts} failed attempts")] + LockedOut { + /// Wall-clock duration until the lockout expires. + retry_after: std::time::Duration, + /// Breach count for telemetry. + attempts: u32, + }, + + /// Valkey backend unavailable. Sign-in / password-reset / SCIM + /// endpoints fail closed: a 503 Service Unavailable surface is + /// preferable to silently dropping rate-limit enforcement. + #[error("rate-limit backend unavailable: {0}")] + RateLimiterUnavailable(String), + + /// Caller submitted a malformed personal-access-token request + /// body (empty / over-long display name, `expires_at` in the past, + /// etc.). The reason is a human-readable description suitable for + /// surfacing in the response body. It MUST NOT contain the + /// raw token bytes or any other secret. + #[error("invalid api-token request: {reason}")] + InvalidApiTokenRequest { + /// Human-readable description of the violated invariant. + reason: String, + }, + + /// Personal-access-token scope string is not in the v0.1 catalogue + /// (`tokens:read`, `tokens:write`, `me:read`). The bad scope + /// string is echoed back so the SPA can highlight the offending + /// chip. + #[error("invalid scope: {scope}")] + InvalidScope { + /// Echo of the rejected scope string. + scope: String, + }, + + /// Caller's auth context lacks the scope required to perform this + /// action. The needed scope name is surfaced so the SPA can prompt + /// the user to mint a new token with the necessary scope. + #[error("insufficient scope; required: {needed}")] + InsufficientScope { + /// Scope string the caller would need. + needed: &'static str, + }, + + /// OIDC start: no enabled OIDC `IdP` found for the requested org. + /// Returned as `404 not_found` so cross-org probes do not leak the + /// existence of an org without an OIDC `IdP` configured. + #[error("no enabled oidc idp for org")] + OidcIdpNotFound, + + /// OIDC start: more than one enabled OIDC `IdP` and the caller did not + /// disambiguate via the `?domain=...` query parameter. Returns + /// `400 idp_ambiguous` so the SPA can re-prompt with a domain hint. + #[error("oidc idp selection ambiguous")] + OidcAmbiguousIdp, + + /// OIDC config validation failed (issuer URL malformed, scopes + /// missing `openid`, JWKS thumbprint not 64-hex, etc.). Reason is + /// surfaced verbatim to admin callers; never reaches end users. + #[error("oidc config invalid: {reason}")] + OidcConfigInvalid { + /// Human-readable description of the violated invariant. + reason: String, + }, + + /// OIDC callback: `__Host-zagrosi_oidc` cookie absent. Treated as a + /// state-mismatch from the auditor's perspective so attackers cannot + /// distinguish a missing cookie from a forged state. + #[error("oidc cookie missing")] + OidcCookieMissing, + + /// OIDC callback: cookie present but envelope failed to open or its + /// inner JSON shape is malformed. Mapped to the same generic + /// callback-failed surface as `OidcStateMismatch`. + #[error("oidc cookie malformed: {0}")] + OidcCookieMalformed(&'static str), + + /// OIDC callback: query `state` parameter has no matching live + /// pending row, OR the row's stored hashes do not constant-time + /// match the cookie-carried raw values. Both sub-causes surface as + /// the same enum variant so the auditor receives a single signal + /// for the family without giving the attacker an oracle. + #[error("oidc state mismatch")] + OidcStateMismatch, + + /// OIDC callback: matching pending row already has `used_at IS NOT + /// NULL`. Distinct audit signal so ops dashboards can spot replay + /// attacks (vs. ordinary state errors). + #[error("oidc callback replay")] + OidcReplay, + + /// OIDC callback: pending row past `expires_at`. Auth window + /// closed; caller restarts the flow. + #[error("oidc pending expired")] + OidcExpired, + + /// OIDC callback: RFC 9207 `iss` query parameter does not + /// constant-time match the pinned issuer URL. Defends against the + /// IdP-mix-up family of attacks. + #[error("oidc iss mismatch")] + OidcIssMismatch, + + /// OIDC callback: ID-token validation failed (signature, `iss`, + /// `aud`, `azp`, `exp`, `iat`, `nonce`, `at_hash`, `c_hash`). The + /// public surface is uniform; the audit event carries an internal + /// sub-reason. + #[error("oidc id token invalid: {0}")] + OidcIdTokenInvalid(&'static str), + + /// OIDC callback: discovery JWKS document SHA-256 thumbprint does + /// not match `org_idps.config.expected_jwks_thumbprint`. + /// Defence-in-depth pin against compromised discovery. + #[error("oidc jwks thumbprint mismatch")] + OidcJwksThumbprintMismatch, + + /// OIDC JIT: the `IdP` issued an ID token whose `email_verified` is + /// not `true` and the per-IdP override `allow_unverified_email_jit` + /// is `false` (default). Caller must verify their email at the `IdP` + /// before sign-in is permitted. + #[error("oidc email not verified at idp")] + OidcEmailNotVerified, + + /// OIDC JIT: a live `users` row already exists for the ID token's + /// `email_lower` but the SSO anchor `(iss, sub)` is fresh. Refuses + /// to auto-merge (admin-link required). + #[error("oidc account already exists; admin link required")] + OidcAccountAlreadyExists, + + /// OIDC: discovery / JWKS / token-endpoint HTTP exchange failed. + /// The wrapped reason is `&'static str` so attacker-controlled + /// detail never reaches log surfaces; the underlying error is logged + /// once via `tracing::warn` at the call-site. + #[error("oidc upstream failure: {0}")] + OidcDiscoveryFailed(&'static str), + + /// OIDC JIT: the per-IdP `jit_provisioning` toggle is `false` and + /// the federated-identities anchor is not (yet) linked. The user + /// cannot sign in via SSO without admin onboarding. Distinct from + /// `OidcStateMismatch` so the audit classifier routes this to the + /// `signin_failed` family (admin-policy denial, not state forgery). + #[error("oidc jit provisioning disabled")] + OidcJitDisabled, + + /// SCIM `Group` resource not found in the caller's tenant scope. + /// Cross-org IDs map to this variant — never `Forbidden` — so + /// status-code probes cannot leak existence across tenants. + #[error("scim group not found")] + GroupNotFound, + + /// SCIM `Group.displayName` already in use within the caller's + /// org. Mapped to `409 uniqueness` by the SCIM error envelope. + #[error("scim group displayName already in use")] + GroupDisplayNameExists, + + /// SCIM `If-Match` precondition failed — the caller's `ETag` does + /// not match the row's current `(updated_at, row_version)` pair. + /// Mapped to `412 precondition failed` by the SCIM error envelope. + #[error("scim precondition failed")] + ScimPreconditionFailed, + + /// Multi-IdP routing: caller submitted a domain that fails shape + /// validation (empty, too long, illegal characters, idna-rejected, + /// or otherwise unparseable). The `reason` is suitable for + /// surfacing in the response body. + #[error("invalid domain: {reason}")] + InvalidDomain { + /// Human-readable description of the violated invariant. + reason: String, + }, + + /// Multi-IdP routing: domain-create / verify rejected because the + /// domain is on the public-suffix list or curated catch-all + /// blocklist. Distinct from [`IdentityError::InvalidDomain`] so + /// the SPA can surface a tailored error chip. + #[error("public email-domain cannot be claimed")] + PublicEmailDomainCannotBeClaimed, + + /// Multi-IdP routing: DNS TXT verification failed (DNSSEC + /// validation rejected, NXDOMAIN, SERVFAIL, no matching TXT, + /// resolvers disagreed, or timeout). The `reason` is the + /// `VerifyFailure` discriminator name; safe for callers to + /// branch on. + #[error("domain verification failed: {reason}")] + DomainVerificationFailed { + /// Stable failure-mode discriminator (`dnssec_bogus`, + /// `nx_domain`, `serv_fail`, `no_matching_txt`, + /// `resolver_disagreement`, `timeout`). + reason: &'static str, + }, + + /// Multi-IdP routing: `IdentityConfig::dns` failed startup + /// validation. Carries a human-readable description. + #[error("dns configuration is malformed: {reason}")] + MalformedDnsConfig { + /// Human-readable description of the violated invariant. + reason: String, + }, + + /// Email-outbox worker: `IdentityConfig::email` failed validation + /// at [`crate::email::LettreTransport::from_config`] time (empty + /// URL, non-`smtps://` scheme, unparseable URL, or empty + /// `smtp_from`). Carries a human-readable description; it never + /// includes the credentialed SMTP URL. Worker-construction-time + /// only — never reaches an end-user HTTP surface. + #[error("email transport configuration is malformed: {reason}")] + EmailTransportConfig { + /// Human-readable description of the violated invariant. + reason: String, + }, + + /// Service-token issuance request failed validation (empty / + /// malformed `service_name`, empty `allowed_subjects`, a subject + /// pattern outside the permitted charset, or empty / over-long + /// `display_name`). The `reason` is safe to surface in the + /// response body — it never contains the raw token. + #[error("invalid service-token request: {reason}")] + InvalidServiceTokenRequest { + /// Human-readable description of the violated invariant. + reason: String, + }, +} + +impl From for IdentityError { + fn from(err: zagrosi_core::RateLimiterError) -> Self { + // The error enum is `#[non_exhaustive]`; future variants are + // mapped onto `RateLimiterUnavailable` so the auth fail-closed + // contract still holds when `zagrosi-core` adds new failure + // shapes (timeout, partition, etc.). + match err { + zagrosi_core::RateLimiterError::Backend(msg) => Self::RateLimiterUnavailable(msg), + other => Self::RateLimiterUnavailable(other.to_string()), + } + } +} + +impl From for IdentityError { + fn from(_: zagrosi_core::BreachListError) -> Self { + // Every failure mode of the lookup surfaces to the password + // flow as `BreachlistUnavailable`. The password-auth design mandates + // fail-closed when mode is `online`; consumers that opt into + // `disabled` mode short-circuit before this conversion is + // reachable. + Self::BreachlistUnavailable + } +} + +impl From for IdentityError { + fn from(_: argon2::Error) -> Self { + Self::Argon2Internal("argon2 hash/verify failed") + } +} + +impl From for IdentityError { + fn from(err: argon2::password_hash::Error) -> Self { + if matches!(err, argon2::password_hash::Error::Password) { + Self::InvalidCredentials + } else { + Self::Argon2Internal("argon2 password-hash error") + } + } +} + +impl From for IdentityError { + fn from(err: figment::Error) -> Self { + Self::Config(Box::new(err)) + } +} + +impl From for IdentityError { + fn from(err: sqlx::Error) -> Self { + Self::Database(Box::new(err)) + } +} + +/// Postgres SQLSTATE for unique-violation. See `repo::map_sqlx_error`. +const SQLSTATE_UNIQUE_VIOLATION: &str = "23505"; + +/// Postgres SQLSTATE for foreign-key violation. See `repo::map_sqlx_error`. +#[allow(dead_code)] // surfaced once the OIDC client lands refresh-chain FK checks. +const SQLSTATE_FOREIGN_KEY_VIOLATION: &str = "23503"; + +/// Map a `sqlx::Error` into a domain-classified [`IdentityError`]. +/// +/// Resolution rules: +/// - `RowNotFound` → caller-supplied `not_found` (e.g. +/// [`IdentityError::TokenNotFound`] or [`IdentityError::UserNotFound`]). +/// - `Database(pg)` with `SQLSTATE 23505` (unique violation) AND a +/// constraint name matching `unique_constraint` (when the caller +/// supplied one) → `unique`. When `unique_constraint` is `None`, any +/// 23505 maps to `unique` (legacy behaviour, used only by repos +/// whose insert can hit exactly one unique index). +/// - any other error → [`IdentityError::Database`] verbatim. +/// +/// Repo call-sites pre-bind the variant they want and the constraint +/// name they expect, keeping the mapping table local to the query +/// without leaking PG specifics into the public surface. Restricting +/// the mapping by constraint name defends against PK collisions or +/// secondary-index conflicts being silently misclassified as a +/// caller-domain conflict. +pub(crate) fn map_sqlx_error( + err: sqlx::Error, + not_found: IdentityError, + unique: IdentityError, + unique_constraint: Option<&str>, +) -> IdentityError { + if matches!(err, sqlx::Error::RowNotFound) { + return not_found; + } + if let sqlx::Error::Database(ref db_err) = err + && db_err.code().as_deref() == Some(SQLSTATE_UNIQUE_VIOLATION) + { + let constraint_match = + unique_constraint.is_none_or(|expected| db_err.constraint() == Some(expected)); + if constraint_match { + return unique; + } + } + IdentityError::from(err) +} + +/// Crate-wide result type defaulting to [`IdentityError`]. +pub type Result = std::result::Result; + +impl axum::response::IntoResponse for IdentityError { + #[allow(clippy::too_many_lines)] // taxonomic match is one big switch by design + fn into_response(self) -> axum::response::Response { + use axum::http::StatusCode; + + let (status, retry_after_secs): (StatusCode, Option) = match &self { + // Client-side validation failures. + Self::PasswordTooShort { .. } + | Self::PasswordTooLong { .. } + | Self::PasswordBreached + | Self::InvalidEmail + | Self::TokenPrefixMismatch { .. } + | Self::TokenExpired + | Self::TokenAlreadyUsed + | Self::MalformedToken(_) + | Self::InvalidApiTokenRequest { .. } + | Self::InvalidServiceTokenRequest { .. } + | Self::InvalidScope { .. } + | Self::OidcAmbiguousIdp + | Self::OidcConfigInvalid { .. } + | Self::InvalidDomain { .. } + | Self::PublicEmailDomainCannotBeClaimed => (StatusCode::BAD_REQUEST, None), + + // 422 — semantic-validation failures the request shape was + // syntactically right but the operation could not complete + // (e.g. DNS TXT verification rejected). Distinct from 400 + // so the SPA can branch ("retry verify" vs "fix the form"). + Self::DomainVerificationFailed { .. } => (StatusCode::UNPROCESSABLE_ENTITY, None), + + // Authentication failures (deliberately uniform — never + // disclose which sub-cause). The OIDC callback failures + // share the same uniform surface so an attacker cannot + // distinguish "wrong state" from "wrong nonce" from "wrong + // signature" from "expired pending row" — every branch + // returns the same `unauthorized` envelope; sub-cause + // lands in the audit event only. `OidcEmailNotVerified` + // and `OidcJitDisabled` collapse onto the same envelope so + // an attacker cannot enumerate which IdP marks an account + // unverified or which org has JIT off. + Self::InvalidCredentials + | Self::AccountDisabled + | Self::EmailNotVerified + | Self::OidcCookieMissing + | Self::OidcCookieMalformed(_) + | Self::OidcStateMismatch + | Self::OidcReplay + | Self::OidcExpired + | Self::OidcIssMismatch + | Self::OidcIdTokenInvalid(_) + | Self::OidcJwksThumbprintMismatch + | Self::OidcEmailNotVerified + | Self::OidcJitDisabled + | Self::OidcDiscoveryFailed(_) => (StatusCode::UNAUTHORIZED, None), + + // Insufficient scope: caller is authenticated but lacks + // the required capability for the route. + Self::InsufficientScope { .. } => (StatusCode::FORBIDDEN, None), + + // Conflict / state surface. `OidcAccountAlreadyExists` is + // the documented exception to the uniform OIDC failure + // shape: callers MUST receive `account_already_exists` so + // the support workflow (admin merge) can fire. + Self::EmailAlreadyExists + | Self::OrgSlugAlreadyExists + | Self::MembershipAlreadyExists + | Self::FederatedIdentityTombstoned + | Self::AssertionReplay + | Self::RefreshChainReplay + | Self::OptimisticLockConflict + | Self::OidcAccountAlreadyExists + | Self::GroupDisplayNameExists => (StatusCode::CONFLICT, None), + + // Precondition (`If-Match`) failed. Surfaces from the SCIM + // ETag concurrency-control path; reaching the standard + // identity envelope is a misroute (SCIM handlers convert + // to `ScimError` first), but the safe default keeps the + // status code authoritative when leaks occur. + Self::ScimPreconditionFailed => (StatusCode::PRECONDITION_FAILED, None), + + // Resource not found. + Self::UserNotFound + | Self::OrgNotFound + | Self::TokenNotFound + | Self::OidcIdpNotFound + | Self::GroupNotFound => (StatusCode::NOT_FOUND, None), + + // Service unavailable — surface a Retry-After. Both the + // breach-list outage and a Valkey-backed rate-limit outage + // emit the same shape: 503 + 60-second hint. + Self::BreachlistUnavailable | Self::RateLimiterUnavailable(_) => { + (StatusCode::SERVICE_UNAVAILABLE, Some(60)) + } + + // Rate-limited — surface a Retry-After computed from the + // bucket's wall-clock reset rounded up to the nearest + // whole second (RFC 6585). + Self::RateLimited { retry_after, .. } | Self::LockedOut { retry_after, .. } => ( + StatusCode::TOO_MANY_REQUESTS, + Some(retry_after_secs_rounded_up(*retry_after)), + ), + + // Internal errors that MUST NOT reach the client verbatim. + Self::Config(_) + | Self::MissingSecretsKey + | Self::MalformedSecretsKey { .. } + | Self::MissingValkeyUrl + | Self::IntegrityError + | Self::MalformedEnvelope(_) + | Self::UnknownKeyId(_) + | Self::Database(_) + | Self::Argon2ProfileTooSlow { .. } + | Self::Argon2Internal(_) + | Self::MalformedRateLimit { .. } + | Self::MalformedSessionConfig { .. } + | Self::MalformedDnsConfig { .. } + | Self::EmailTransportConfig { .. } => (StatusCode::INTERNAL_SERVER_ERROR, None), + }; + + let code = match self { + Self::InvalidScope { .. } => "invalid_scope", + Self::InsufficientScope { .. } => "insufficient_scope", + Self::InvalidApiTokenRequest { .. } | Self::InvalidServiceTokenRequest { .. } => { + "invalid_request" + } + Self::OidcAmbiguousIdp => "idp_ambiguous", + Self::OidcConfigInvalid { .. } => "oidc_config_invalid", + Self::OidcAccountAlreadyExists => "account_already_exists", + Self::InvalidDomain { .. } => "invalid_domain", + Self::PublicEmailDomainCannotBeClaimed => "public_email_domain_cannot_be_claimed", + Self::DomainVerificationFailed { .. } => "verification_failed", + // `OidcEmailNotVerified` no longer leaks via a distinct + // public code; collapsed onto `oidc_callback_failed` so + // attackers cannot enumerate "this IdP marks this account + // unverified" as a side channel. Audit `sub_reason` still + // distinguishes for ops dashboards. + Self::OidcEmailNotVerified + | Self::OidcJitDisabled + | Self::OidcCookieMissing + | Self::OidcCookieMalformed(_) + | Self::OidcStateMismatch + | Self::OidcReplay + | Self::OidcExpired + | Self::OidcIssMismatch + | Self::OidcIdTokenInvalid(_) + | Self::OidcJwksThumbprintMismatch + | Self::OidcDiscoveryFailed(_) => "oidc_callback_failed", + _ => match status { + StatusCode::BAD_REQUEST => "bad_request", + StatusCode::UNAUTHORIZED => "unauthorized", + StatusCode::FORBIDDEN => "forbidden", + StatusCode::CONFLICT => "conflict", + StatusCode::NOT_FOUND => "not_found", + StatusCode::PRECONDITION_FAILED => "precondition_failed", + StatusCode::UNPROCESSABLE_ENTITY => "unprocessable_entity", + StatusCode::SERVICE_UNAVAILABLE => "service_unavailable", + StatusCode::TOO_MANY_REQUESTS => "rate_limited", + _ => "internal_error", + }, + }; + let body = serde_json::json!({ + "error": { + "code": code, + "message": status_message(status), + } + }); + + let mut response = (status, axum::Json(body)).into_response(); + if let Some(secs) = retry_after_secs + && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string()) + { + response + .headers_mut() + .insert(axum::http::header::RETRY_AFTER, value); + } + response + } +} + +const fn status_message(status: axum::http::StatusCode) -> &'static str { + match status { + axum::http::StatusCode::BAD_REQUEST => "request rejected", + axum::http::StatusCode::UNAUTHORIZED => "authentication failed", + axum::http::StatusCode::FORBIDDEN => "forbidden", + axum::http::StatusCode::CONFLICT => "resource conflict", + axum::http::StatusCode::NOT_FOUND => "not found", + axum::http::StatusCode::PRECONDITION_FAILED => "precondition failed", + axum::http::StatusCode::UNPROCESSABLE_ENTITY => "verification failed", + axum::http::StatusCode::SERVICE_UNAVAILABLE => "temporarily unavailable", + axum::http::StatusCode::TOO_MANY_REQUESTS => "rate limit exceeded", + _ => "internal error", + } +} + +/// Round a [`std::time::Duration`] up to the next whole second so the +/// `Retry-After` header never advises a value that is too small to +/// satisfy the underlying bucket reset. +fn retry_after_secs_rounded_up(d: std::time::Duration) -> u32 { + let secs = d.as_secs(); + let extra = u64::from(d.subsec_nanos() != 0); + let total = secs.saturating_add(extra); + u32::try_from(total).unwrap_or(u32::MAX) +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(IdentityError: Send, Sync); + + #[test] + fn display_renders_missing_secrets_key() { + let err = IdentityError::MissingSecretsKey; + let rendered = format!("{err}"); + assert!(rendered.contains("ZAGROSI_SECRETS_KEY")); + assert!(rendered.contains("32-byte base64")); + } + + #[test] + fn display_renders_malformed_secrets_key_with_reason() { + let err = IdentityError::MalformedSecretsKey { + reason: "not valid base64".into(), + }; + let rendered = format!("{err}"); + assert!(rendered.contains("malformed")); + assert!(rendered.contains("not valid base64")); + } + + #[test] + fn display_renders_missing_valkey_url() { + let err = IdentityError::MissingValkeyUrl; + let rendered = format!("{err}"); + assert!(rendered.contains("ZAGROSI_VALKEY_URL")); + } + + #[test] + fn display_renders_config_variant() { + let figment_err = figment::Error::from("synthetic figment failure".to_string()); + let err = IdentityError::Config(Box::new(figment_err)); + let rendered = format!("{err}"); + assert!(rendered.starts_with("configuration error:")); + } + + #[test] + fn debug_renders_for_all_variants() { + let _ = format!("{:?}", IdentityError::MissingSecretsKey); + let _ = format!( + "{:?}", + IdentityError::MalformedSecretsKey { reason: "x".into() } + ); + let _ = format!("{:?}", IdentityError::MissingValkeyUrl); + let figment_err = figment::Error::from("x".to_string()); + let _ = format!("{:?}", IdentityError::Config(Box::new(figment_err))); + } + + #[test] + fn from_figment_error_produces_config_variant() { + let figment_err = figment::Error::from("boom".to_string()); + let identity_err: IdentityError = figment_err.into(); + match identity_err { + IdentityError::Config(_) => {} + other => panic!("expected Config variant, got {other:?}"), + } + } + + #[test] + fn result_alias_uses_identity_error_default() { + fn returns_result() -> Result { + Err(IdentityError::MissingSecretsKey) + } + assert!(returns_result().is_err()); + } +} diff --git a/crates/zagrosi-identity/src/http/admin.rs b/crates/zagrosi-identity/src/http/admin.rs new file mode 100644 index 0000000..54f7a04 --- /dev/null +++ b/crates/zagrosi-identity/src/http/admin.rs @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! Admin-only HTTP routes. +//! +//! The current surface is the per-account unlock endpoint; later work +//! will pile additional admin surfaces (impersonation, SCIM-token +//! revocation, etc.) onto the same prefix. +//! +//! ## Authentication +//! +//! Authentication for `/v1/admin/*` is the responsibility of the +//! mounter (the gateway) until a dedicated admin console lands. +//! Direct exposure on a public listener would leak the unlock +//! primitive. The handler accepts no caller-identity proof and +//! relies on its mounter to enforce one. + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use uuid::Uuid; +use zagrosi_core::{ + AuditActor, AuditEvent, AuditEventKind, AuditEventV1, AuditPayload, AuditResource, RateLimitKey, +}; + +use crate::error::Result; +use crate::http::IdentityState; +use crate::service::signin::SIGNIN_SCOPE; + +/// `POST /v1/admin/users/{id}/unlock` — clear the per-account +/// exponential-lockout state for one user. +/// +/// On success returns `204 No Content`. Emits an +/// [`AuditEventKind::AccountUnlocked`] event so audit can correlate +/// the unlock with the originating admin action recorded by the +/// admin-console mounter. +/// +/// # Errors +/// +/// - [`crate::error::IdentityError::RateLimiterUnavailable`] when the +/// Valkey-backed limiter cannot acknowledge the unlock. The +/// response uses the standard 503 mapping; the lockout key carries +/// a TTL so a transient outage does not strand the user +/// indefinitely. +pub async fn unlock_user( + State(state): State, + Path(user_id): Path, +) -> Result { + let key = RateLimitKey::PerAccount { + user_id, + scope: SIGNIN_SCOPE, + }; + state.service.rate_limiter.unlock(&key).await?; + + state + .service + .auditor + .record(AuditEvent::V1(AuditEventV1::new( + AuditEventKind::AccountUnlocked, + // Until a dedicated admin console wires authenticated + // actors, the actor is the server itself. The mounter + // is expected to attribute the human-driven action via + // its own audit row. + AuditActor::System, + AuditResource::User { user_id }, + Uuid::now_v7(), + Uuid::nil(), + AuditPayload::new(serde_json::json!({ + "scope": SIGNIN_SCOPE, + "user_id": user_id, + })), + ))) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/zagrosi-identity/src/http/api_tokens.rs b/crates/zagrosi-identity/src/http/api_tokens.rs new file mode 100644 index 0000000..183bf1c --- /dev/null +++ b/crates/zagrosi-identity/src/http/api_tokens.rs @@ -0,0 +1,243 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Personal-access-token HTTP surface. +//! +//! Routes assume the gateway has already resolved the bearer / cookie +//! credential and attached an [`AuthContext`] via +//! [`axum::Extension`] before reaching these handlers, exactly the +//! same contract as the session-lifecycle routes shipped in +//! `crate::http::sessions`. +//! +//! Routes: +//! +//! - `POST /v1/api-tokens` mints a new PAT for the caller. +//! - `GET /v1/api-tokens` lists the caller's live PATs. +//! - `GET /v1/api-tokens/{id}` fetches one of the caller's PATs. +//! - `DELETE /v1/api-tokens/{id}` revokes one of the caller's PATs. +//! +//! ## Scope enforcement +//! +//! When the caller authenticated via a PAT +//! ([`AuthMethod::ApiToken`]), the handler enforces the matching +//! scope: +//! +//! - `tokens:read` for `GET /v1/api-tokens[/{id}]` +//! - `tokens:write` for `POST /v1/api-tokens` and +//! `DELETE /v1/api-tokens/{id}` +//! +//! Session-based auth (browser cookie, OIDC callback, SAML ACS) +//! skips the scope check because sessions derive capabilities from +//! the upcoming RBAC layer rather than scope strings on the token. + +use std::sync::Arc; + +use axum::Extension; +use axum::Json; +use axum::Router; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::routing::{delete, get, post}; +use uuid::Uuid; +use zagrosi_core::{AuthContext, AuthMethod}; + +use crate::api_tokens::{ + ApiTokenService, ApiTokenView, CreateApiTokenRequest, IssueApiTokenInput, + IssuedApiTokenResponse, SCOPE_TOKENS_READ, SCOPE_TOKENS_WRITE, +}; +use crate::error::{IdentityError, Result}; + +/// Shared application state for the PAT axum handlers. Cheap to +/// clone; every field is an `Arc` handle. +#[derive(Clone)] +pub struct ApiTokensState { + /// Composed PAT service (CRUD + audit + cache eviction). + pub service: Arc, +} + +impl ApiTokensState { + /// Construct a fresh state handle. + #[must_use] + pub const fn new(service: Arc) -> Self { + Self { service } + } +} + +/// `POST /v1/api-tokens`: mint a new PAT for the caller. +/// +/// # Errors +/// +/// - [`IdentityError::InsufficientScope`] when the caller is +/// authenticated via a PAT and lacks `tokens:write`. +/// - [`IdentityError::InvalidApiTokenRequest`] for malformed body. +/// - [`IdentityError::InvalidScope`] for unknown scope strings. +/// - [`IdentityError::Database`] for any underlying sqlx failure. +pub async fn create_api_token( + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result<(StatusCode, Json)> { + require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_WRITE)?; + + let issued = state + .service + .issue(IssueApiTokenInput { + caller_user_id: ctx.subject_id(), + caller_org_id: ctx.org_id(), + request: body, + correlation_id: ctx.correlation_id(), + }) + .await?; + let response = IssuedApiTokenResponse { + id: issued.token.id, + display_name: issued.token.display_name, + scopes: issued.token.scopes, + expires_at: issued.token.expires_at, + created_at: issued.token.created_at, + token: issued.raw_token, + }; + Ok((StatusCode::CREATED, Json(response))) +} + +/// `GET /v1/api-tokens`: list the caller's live PATs. +pub async fn list_api_tokens( + State(state): State, + Extension(ctx): Extension, +) -> Result>> { + require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_READ)?; + let rows = state.service.list(ctx.subject_id(), ctx.org_id()).await?; + Ok(Json(rows)) +} + +/// `GET /v1/api-tokens/{id}`: fetch one of the caller's PATs. +pub async fn get_api_token( + State(state): State, + Extension(ctx): Extension, + Path(token_id): Path, +) -> Result> { + require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_READ)?; + let view = state + .service + .get(ctx.subject_id(), ctx.org_id(), token_id) + .await?; + Ok(Json(view)) +} + +/// `DELETE /v1/api-tokens/{id}`: revoke a PAT. +/// +/// PAT-authenticated callers MUST hold `tokens:write`. Self-revoke +/// (revoking the bearer token itself) succeeds when `tokens:write` +/// is present; the next request with the same token will then +/// resolve to `401` because the row's `revoked_at` is set before +/// the response returns. +pub async fn revoke_api_token( + State(state): State, + Extension(ctx): Extension, + Path(token_id): Path, +) -> Result { + require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_WRITE)?; + state + .service + .revoke( + ctx.subject_id(), + ctx.org_id(), + token_id, + ctx.correlation_id(), + ) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +/// Build the api-tokens router. +/// +/// Mounted at the root; each route carries its full `/v1/api-tokens` +/// path. The gateway composes this router alongside the session +/// router behind the same bearer-token middleware that produces +/// [`AuthContext`]. +pub fn router(state: ApiTokensState) -> Router<()> { + Router::new() + .route("/v1/api-tokens", post(create_api_token)) + .route("/v1/api-tokens", get(list_api_tokens)) + .route("/v1/api-tokens/{id}", get(get_api_token)) + .route("/v1/api-tokens/{id}", delete(revoke_api_token)) + .with_state(state) +} + +/// Enforce a PAT scope only when the caller authenticated via a PAT. +/// +/// Browser sessions, OIDC callbacks, and SAML ACS all leave the +/// scope list empty (capabilities come from RBAC); the +/// `tokens:read` / `tokens:write` chip applies only to PAT-bearer +/// requests. +fn require_scope_for_pat_caller(ctx: &AuthContext, scope: &'static str) -> Result<()> { + if matches!(ctx.auth_method(), AuthMethod::ApiToken) && !ctx.has_scope(scope) { + return Err(IdentityError::InsufficientScope { needed: scope }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use static_assertions::assert_impl_all; + + assert_impl_all!(ApiTokensState: Send, Sync, Clone); + + fn build_pat_ctx(scopes: Vec) -> AuthContext { + let now = chrono::Utc::now(); + let ctx = AuthContext::new( + Uuid::from_bytes([1; 16]), + Uuid::from_bytes([2; 16]), + Uuid::from_bytes([3; 16]), + AuthMethod::ApiToken, + zagrosi_core::TokenClass::PersonalAccessToken, + vec!["pat".into()], + None, + now, + now + chrono::Duration::hours(1), + Uuid::from_bytes([4; 16]), + ) + .expect("build pat ctx"); + ctx.with_scopes(scopes) + } + + fn build_session_ctx() -> AuthContext { + let now = chrono::Utc::now(); + AuthContext::new( + Uuid::from_bytes([1; 16]), + Uuid::from_bytes([2; 16]), + Uuid::from_bytes([3; 16]), + AuthMethod::Password, + zagrosi_core::TokenClass::Session, + vec!["pwd".into()], + None, + now, + now + chrono::Duration::hours(1), + Uuid::from_bytes([4; 16]), + ) + .expect("build session ctx") + } + + #[test] + fn pat_with_required_scope_passes() { + let ctx = build_pat_ctx(vec!["tokens:read".into()]); + assert!(require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_READ).is_ok()); + } + + #[test] + fn pat_missing_scope_returns_insufficient_scope() { + let ctx = build_pat_ctx(vec!["me:read".into()]); + let err = require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_WRITE) + .expect_err("should require tokens:write"); + assert!( + matches!(err, IdentityError::InsufficientScope { needed } if needed == SCOPE_TOKENS_WRITE) + ); + } + + #[test] + fn session_caller_skips_scope_check() { + let ctx = build_session_ctx(); + // No scopes on a session ctx; must still pass the gate. + assert!(require_scope_for_pat_caller(&ctx, SCOPE_TOKENS_WRITE).is_ok()); + } +} diff --git a/crates/zagrosi-identity/src/http/auth.rs b/crates/zagrosi-identity/src/http/auth.rs new file mode 100644 index 0000000..5a7c0fc --- /dev/null +++ b/crates/zagrosi-identity/src/http/auth.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Sign-up / sign-in / sign-out HTTP handlers. + +use std::net::IpAddr; + +use axum::Json; +use axum::extract::{ConnectInfo, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use uuid::Uuid; + +use crate::error::Result; +use crate::http::IdentityState; +use crate::service::signin::SignInRequest; +use crate::service::signup::{SignUpRequest, SignUpResponse}; + +/// JSON body for `POST /v1/auth/sign-up`. +#[derive(Debug, serde::Deserialize)] +pub struct SignUpBody { + /// Display-case email submitted by the caller. + pub email: String, + /// Display name for the new user. + pub display_name: String, + /// Cleartext password. + pub password: String, +} + +/// `POST /v1/auth/sign-up` handler. +pub async fn sign_up( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> Result<(StatusCode, Json)> { + let response = state + .service + .sign_up(SignUpRequest { + email: body.email, + display_name: body.display_name, + password: body.password, + ip: addr.ip(), + correlation_id: Uuid::now_v7(), + }) + .await?; + Ok((StatusCode::CREATED, Json(response))) +} + +/// JSON body for `POST /v1/auth/sign-in`. +#[derive(Debug, serde::Deserialize)] +pub struct SignInBody { + /// Display-case email submitted by the caller. + pub email: String, + /// Cleartext password. + pub password: String, +} + +/// JSON response for `POST /v1/auth/sign-in`. +#[derive(Debug, serde::Serialize)] +pub struct SignInResponse { + /// Always `"ok"`. + pub status: &'static str, + /// Issued session id (for client-side telemetry; cookie is what + /// authorises subsequent requests). + pub session_id: Uuid, +} + +/// `POST /v1/auth/sign-in` handler. +pub async fn sign_in( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> Result> { + let session = state + .service + .sign_in(SignInRequest { + email: body.email, + password: body.password, + ip: addr.ip(), + correlation_id: Uuid::now_v7(), + }) + .await?; + Ok(Json(SignInResponse { + status: "ok", + session_id: session.id, + })) +} + +/// JSON body for `POST /v1/auth/sign-out`. +#[derive(Debug, serde::Deserialize)] +pub struct SignOutBody { + /// Session id to revoke. + pub session_id: Uuid, +} + +/// `POST /v1/auth/sign-out` handler. +pub async fn sign_out( + State(state): State, + ConnectInfo(addr): ConnectInfo, + Json(body): Json, +) -> Result { + let _ip: IpAddr = addr.ip(); + state + .service + .sign_out(body.session_id, None, Some(addr.ip()), Uuid::now_v7()) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +#[allow(dead_code)] +fn dummy_into_response_use(_: T) {} diff --git a/crates/zagrosi-identity/src/http/csrf.rs b/crates/zagrosi-identity/src/http/csrf.rs new file mode 100644 index 0000000..9e51a1e --- /dev/null +++ b/crates/zagrosi-identity/src/http/csrf.rs @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +//! CSRF double-submit middleware for browser auth routes. +//! +//! Browser callers (those that present the +//! [`crate::session::cookie::SESSION_COOKIE_NAME`] cookie) MUST also +//! echo the [`crate::session::cookie::CSRF_COOKIE_NAME`] cookie's +//! value via the [`crate::session::cookie::CSRF_HEADER_NAME`] header +//! on every unsafe request. The middleware compares the two with a +//! constant-time comparison and rejects mismatches with `403`. +//! +//! Skipped for: +//! - Requests that carry no session cookie (bearer-only API / MCP). +//! - Routes mounted by federated-auth callbacks (OIDC / SAML) which +//! carry their own state-binding mechanism (`state` parameter, +//! signed `RelayState`). +//! +//! Mount the middleware at the auth-router level only; the gateway +//! mounts the same middleware shape at its router boundary. + +use axum::body::Body; +use axum::extract::Request; +use axum::http::{HeaderMap, Method, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use subtle::ConstantTimeEq; +use tracing::warn; + +use crate::session::cookie::{CSRF_COOKIE_NAME, CSRF_HEADER_NAME, SESSION_COOKIE_NAME}; + +/// Routes that opt out of CSRF middleware. Federated-auth callbacks +/// own their own state-binding mechanism and cannot rely on a +/// pre-existing browser cookie because `SameSite=Lax` blocks the +/// cookie on cross-site form POSTs. +const CSRF_EXEMPT_PATH_PREFIXES: &[&str] = &["/v1/auth/oidc/", "/v1/auth/saml/"]; + +/// CSRF double-submit middleware. +/// +/// Pass-through for safe methods (`GET` / `HEAD` / `OPTIONS`), +/// bearer-only requests (no session cookie present), and the +/// federated-auth callback exemption list. Every other request +/// must echo the CSRF cookie value via the header; failure returns +/// `403 Forbidden` with empty body. +pub async fn csrf_middleware(req: Request, next: Next) -> Response { + if matches!(*req.method(), Method::GET | Method::HEAD | Method::OPTIONS) { + return next.run(req).await; + } + if CSRF_EXEMPT_PATH_PREFIXES + .iter() + .any(|prefix| req.uri().path().starts_with(prefix)) + { + return next.run(req).await; + } + let headers = req.headers(); + let Some(cookie_value) = extract_cookie(headers, SESSION_COOKIE_NAME) else { + // No browser session cookie → bearer / MCP path. The CSRF + // double-submit doesn't apply (the bearer credential lives + // in the `Authorization` header, not in a cookie that a + // cross-site form could replay). + return next.run(req).await; + }; + let path = req.uri().path().to_owned(); + let method = req.method().clone(); + let _ = cookie_value; + // Sanity: a request that carries the session cookie but no CSRF + // cookie is structurally inconsistent — reject it. + let Some(csrf_cookie) = extract_cookie(headers, CSRF_COOKIE_NAME) else { + warn!(reason = "missing_csrf_cookie", %path, %method, "csrf_validation_failed"); + return forbidden(); + }; + let Some(csrf_header) = headers.get(CSRF_HEADER_NAME).and_then(|v| v.to_str().ok()) else { + warn!(reason = "missing_csrf_header", %path, %method, "csrf_validation_failed"); + return forbidden(); + }; + if csrf_header.as_bytes().ct_eq(csrf_cookie.as_bytes()).into() { + next.run(req).await + } else { + warn!(reason = "csrf_mismatch", %path, %method, "csrf_validation_failed"); + forbidden() + } +} + +fn extract_cookie(headers: &HeaderMap, name: &str) -> Option { + let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + cookie::Cookie::split_parse(raw) + .filter_map(Result::ok) + .find(|c| c.name() == name) + .map(|c| c.value().to_owned()) +} + +fn forbidden() -> Response { + Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::FORBIDDEN.into_response()) +} + +// `into_response` for StatusCode in scope. +use axum::response::IntoResponse; + +#[cfg(test)] +mod tests { + use super::*; + use axum::http::HeaderValue; + + fn headers_with(cookies: &str, csrf_header: Option<&str>) -> HeaderMap { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::COOKIE, + HeaderValue::from_str(cookies).expect("cookie header"), + ); + if let Some(value) = csrf_header { + h.insert( + CSRF_HEADER_NAME, + HeaderValue::from_str(value).expect("csrf header"), + ); + } + h + } + + #[test] + fn extract_cookie_resolves_named_value() { + let h = headers_with( + "__Host-zagrosi_sid=sid_abc; __Host-zagrosi_csrf=csrf_xyz", + None, + ); + assert_eq!( + extract_cookie(&h, SESSION_COOKIE_NAME).as_deref(), + Some("sid_abc") + ); + assert_eq!( + extract_cookie(&h, CSRF_COOKIE_NAME).as_deref(), + Some("csrf_xyz") + ); + } + + #[test] + fn extract_cookie_returns_none_for_missing_name() { + let h = headers_with("__Host-zagrosi_sid=sid_abc", None); + assert!(extract_cookie(&h, CSRF_COOKIE_NAME).is_none()); + } + + #[test] + fn exempt_prefix_match_blocks_oidc_callback() { + assert!( + CSRF_EXEMPT_PATH_PREFIXES + .iter() + .any(|p| "/v1/auth/oidc/google/callback".starts_with(p)) + ); + assert!( + CSRF_EXEMPT_PATH_PREFIXES + .iter() + .any(|p| "/v1/auth/saml/acme/acs".starts_with(p)) + ); + assert!( + !CSRF_EXEMPT_PATH_PREFIXES + .iter() + .any(|p| "/v1/auth/sign-in".starts_with(p)) + ); + } + + #[test] + fn forbidden_response_carries_403_status() { + let resp = forbidden(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/zagrosi-identity/src/http/email_verify.rs b/crates/zagrosi-identity/src/http/email_verify.rs new file mode 100644 index 0000000..18adb91 --- /dev/null +++ b/crates/zagrosi-identity/src/http/email_verify.rs @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Email-verification HTTP handlers. + +use axum::Json; +use axum::extract::{Query, State}; +use axum::http::StatusCode; +use axum::response::Response; +use serde::Deserialize; +use uuid::Uuid; + +use crate::error::Result; +use crate::http::{IdentityState, landing}; +use crate::service::email_verify::EmailVerifyConfirmRequest; + +/// JSON body for `POST /v1/auth/email-verifications/confirm`. +#[derive(Debug, Deserialize)] +pub struct ConfirmBody { + /// Raw `vrf_*` token. + pub token: String, +} + +/// `POST /v1/auth/email-verifications/confirm` handler. +pub async fn confirm( + State(state): State, + Json(body): Json, +) -> Result { + state + .service + .email_verify_confirm(EmailVerifyConfirmRequest { + raw_token: body.token, + correlation_id: Uuid::now_v7(), + }) + .await?; + Ok(StatusCode::OK) +} + +/// Query string for `GET /v1/auth/email-verifications/landing`. +#[derive(Debug, Deserialize)] +pub struct LandingQuery { + /// Raw `vrf_*` token. + pub token: String, +} + +/// `GET /v1/auth/email-verifications/landing` handler. +pub async fn landing(Query(q): Query) -> Response { + landing::render_landing("/v1/auth/email-verifications/confirm", &q.token) +} diff --git a/crates/zagrosi-identity/src/http/landing.rs b/crates/zagrosi-identity/src/http/landing.rs new file mode 100644 index 0000000..ad9b1a4 --- /dev/null +++ b/crates/zagrosi-identity/src/http/landing.rs @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +#![allow(clippy::doc_markdown, clippy::too_long_first_doc_paragraph)] +//! Shared landing-page renderer. +//! +//! The verify-email and password-reset email links land on a `GET` +//! page that does NOT mutate state. The page renders an auto-POST +//! form pointing at the canonical confirm URL with the token in the +//! form body. This strips the token from browser history + the +//! `Referer` header on subsequent navigation. +//! +//! Required headers: +//! - `Referrer-Policy: no-referrer` +//! - `Content-Security-Policy: default-src 'none'; form-action 'self'; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'` +//! +//! The page MUST NOT load any third-party assets (no CDN fonts, no +//! analytics, no external favicon). The auto-POST script is inline. + +use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +/// Render an auto-POST landing page for `confirm_url` with the +/// supplied `token` rendered into a hidden form input. +/// +/// Returns a fully-built [`Response`] carrying the documented +/// security headers + an HTML body that submits to `confirm_url` on +/// `window.onload`. Users without JavaScript see a "Continue" button +/// (graceful degradation). +#[must_use] +pub fn render_landing(confirm_url: &str, token: &str) -> Response { + let body = format!( + concat!( + "", + "Continuing…", + "", + "", + "
", + "", + "", + "
", + "", + "", + ), + confirm_url = html_escape(confirm_url), + token = html_escape(token), + ); + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + HeaderValue::from_static("text/html; charset=utf-8"), + ); + headers.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static( + "default-src 'none'; form-action 'self'; \ + style-src 'self' 'unsafe-inline'; \ + script-src 'self' 'unsafe-inline'", + ), + ); + (StatusCode::OK, headers, body).into_response() +} + +fn html_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for ch in s.chars() { + match ch { + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + '\'' => out.push_str("'"), + '&' => out.push_str("&"), + _ => out.push(ch), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_form_with_token() { + let resp = render_landing("/confirm", "rst_abc"); + let headers = resp.headers().clone(); + assert_eq!( + headers + .get(header::REFERRER_POLICY) + .map(|v| v.to_str().unwrap_or("")), + Some("no-referrer"), + ); + let csp = headers + .get(header::CONTENT_SECURITY_POLICY) + .map_or("", |v| v.to_str().unwrap_or("")); + assert!(csp.contains("default-src 'none'")); + assert!(csp.contains("form-action 'self'")); + } + + #[test] + fn html_escape_round_trip() { + assert_eq!(html_escape("