ciris-agent 1.7.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ciris_adapters/README.md +113 -0
- ciris_adapters/__init__.py +30 -0
- ciris_adapters/ciris_covenant_metrics/README.md +144 -0
- ciris_adapters/ciris_covenant_metrics/__init__.py +36 -0
- ciris_adapters/ciris_covenant_metrics/adapter.py +249 -0
- ciris_adapters/ciris_covenant_metrics/manifest.json +152 -0
- ciris_adapters/ciris_covenant_metrics/services.py +403 -0
- ciris_adapters/ciris_hosted_tools/__init__.py +24 -0
- ciris_adapters/ciris_hosted_tools/adapter.py +169 -0
- ciris_adapters/ciris_hosted_tools/manifest.json +94 -0
- ciris_adapters/ciris_hosted_tools/services.py +744 -0
- ciris_adapters/external_data_sql/README.md +559 -0
- ciris_adapters/external_data_sql/__init__.py +43 -0
- ciris_adapters/external_data_sql/adapter.py +144 -0
- ciris_adapters/external_data_sql/configurable.py +315 -0
- ciris_adapters/external_data_sql/dialects/__init__.py +37 -0
- ciris_adapters/external_data_sql/dialects/base.py +133 -0
- ciris_adapters/external_data_sql/dialects/mysql.py +63 -0
- ciris_adapters/external_data_sql/dialects/postgresql.py +59 -0
- ciris_adapters/external_data_sql/dialects/sqlite.py +62 -0
- ciris_adapters/external_data_sql/example_config.json +88 -0
- ciris_adapters/external_data_sql/example_privacy_schema.yaml +127 -0
- ciris_adapters/external_data_sql/manifest.json +195 -0
- ciris_adapters/external_data_sql/privacy_schema_loader.py +189 -0
- ciris_adapters/external_data_sql/protocol.py +101 -0
- ciris_adapters/external_data_sql/schemas.py +146 -0
- ciris_adapters/external_data_sql/service.py +1547 -0
- ciris_adapters/external_data_sql/service_old.py +492 -0
- ciris_adapters/home_assistant/__init__.py +63 -0
- ciris_adapters/home_assistant/adapter.py +201 -0
- ciris_adapters/home_assistant/communication_service.py +347 -0
- ciris_adapters/home_assistant/configurable.py +667 -0
- ciris_adapters/home_assistant/manifest.json +203 -0
- ciris_adapters/home_assistant/schemas.py +129 -0
- ciris_adapters/home_assistant/service.py +751 -0
- ciris_adapters/home_assistant/tool_service.py +441 -0
- ciris_adapters/mcp_client/__init__.py +82 -0
- ciris_adapters/mcp_client/adapter.py +847 -0
- ciris_adapters/mcp_client/config.py +280 -0
- ciris_adapters/mcp_client/configurable.py +422 -0
- ciris_adapters/mcp_client/manifest.json +185 -0
- ciris_adapters/mcp_client/mcp_communication_service.py +393 -0
- ciris_adapters/mcp_client/mcp_tool_service.py +463 -0
- ciris_adapters/mcp_client/mcp_wise_service.py +394 -0
- ciris_adapters/mcp_client/schemas.py +149 -0
- ciris_adapters/mcp_client/security.py +592 -0
- ciris_adapters/mcp_common/__init__.py +44 -0
- ciris_adapters/mcp_common/manifest.json +25 -0
- ciris_adapters/mcp_common/protocol.py +315 -0
- ciris_adapters/mcp_common/schemas.py +225 -0
- ciris_adapters/mcp_server/__init__.py +47 -0
- ciris_adapters/mcp_server/adapter.py +581 -0
- ciris_adapters/mcp_server/config.py +260 -0
- ciris_adapters/mcp_server/configurable.py +393 -0
- ciris_adapters/mcp_server/handlers.py +663 -0
- ciris_adapters/mcp_server/manifest.json +211 -0
- ciris_adapters/mcp_server/security.py +500 -0
- ciris_adapters/mock_llm/README.md +117 -0
- ciris_adapters/mock_llm/__init__.py +21 -0
- ciris_adapters/mock_llm/adapter.py +131 -0
- ciris_adapters/mock_llm/configurable.py +237 -0
- ciris_adapters/mock_llm/manifest.json +106 -0
- ciris_adapters/mock_llm/protocol.py +37 -0
- ciris_adapters/mock_llm/responses.py +520 -0
- ciris_adapters/mock_llm/responses_action_selection.py +1041 -0
- ciris_adapters/mock_llm/responses_epistemic.py +17 -0
- ciris_adapters/mock_llm/responses_feedback.py +27 -0
- ciris_adapters/mock_llm/schemas.py +35 -0
- ciris_adapters/mock_llm/service.py +294 -0
- ciris_adapters/navigation/__init__.py +21 -0
- ciris_adapters/navigation/adapter.py +129 -0
- ciris_adapters/navigation/configurable.py +239 -0
- ciris_adapters/navigation/manifest.json +104 -0
- ciris_adapters/navigation/service.py +487 -0
- ciris_adapters/reddit/README.md +132 -0
- ciris_adapters/reddit/REDDIT_ADAPTER_ANALYSIS.md +715 -0
- ciris_adapters/reddit/REDDIT_ADAPTER_SUMMARY.txt +278 -0
- ciris_adapters/reddit/REDDIT_ANALYSIS_INDEX.md +307 -0
- ciris_adapters/reddit/REDDIT_PRODUCTION_READINESS_PLAN.md +518 -0
- ciris_adapters/reddit/__init__.py +15 -0
- ciris_adapters/reddit/adapter.py +189 -0
- ciris_adapters/reddit/configurable.py +274 -0
- ciris_adapters/reddit/error_handler.py +307 -0
- ciris_adapters/reddit/manifest.json +218 -0
- ciris_adapters/reddit/observer.py +532 -0
- ciris_adapters/reddit/protocol.py +34 -0
- ciris_adapters/reddit/schemas.py +433 -0
- ciris_adapters/reddit/service.py +1471 -0
- ciris_adapters/sample_adapter/README.md +474 -0
- ciris_adapters/sample_adapter/__init__.py +45 -0
- ciris_adapters/sample_adapter/adapter.py +208 -0
- ciris_adapters/sample_adapter/configurable.py +469 -0
- ciris_adapters/sample_adapter/manifest.json +247 -0
- ciris_adapters/sample_adapter/services.py +486 -0
- ciris_adapters/weather/__init__.py +16 -0
- ciris_adapters/weather/adapter.py +130 -0
- ciris_adapters/weather/configurable.py +240 -0
- ciris_adapters/weather/manifest.json +156 -0
- ciris_adapters/weather/service.py +600 -0
- ciris_agent-1.7.7.dist-info/METADATA +284 -0
- ciris_agent-1.7.7.dist-info/RECORD +986 -0
- ciris_agent-1.7.7.dist-info/WHEEL +5 -0
- ciris_agent-1.7.7.dist-info/entry_points.txt +15 -0
- ciris_agent-1.7.7.dist-info/licenses/LICENSE +205 -0
- ciris_agent-1.7.7.dist-info/licenses/NOTICE +82 -0
- ciris_agent-1.7.7.dist-info/top_level.txt +4 -0
- ciris_engine/__init__.py +15 -0
- ciris_engine/ciris_templates/ally.yaml +632 -0
- ciris_engine/ciris_templates/default.yaml +411 -0
- ciris_engine/ciris_templates/echo-core.yaml +629 -0
- ciris_engine/ciris_templates/echo-speculative.yaml +764 -0
- ciris_engine/ciris_templates/echo.yaml +647 -0
- ciris_engine/ciris_templates/sage.yaml +332 -0
- ciris_engine/ciris_templates/scout.yaml +338 -0
- ciris_engine/ciris_templates/test.yaml +168 -0
- ciris_engine/cli.py +42 -0
- ciris_engine/config/CIRIS_SERVICES.json +19 -0
- ciris_engine/config/MODEL_CAPABILITIES.json +419 -0
- ciris_engine/config/PRICING_DATA.json +179 -0
- ciris_engine/config/__init__.py +50 -0
- ciris_engine/config/ciris_services.py +113 -0
- ciris_engine/config/model_capabilities.py +388 -0
- ciris_engine/config/pricing_models.py +276 -0
- ciris_engine/constants.py +35 -0
- ciris_engine/data/__init__.py +1 -0
- ciris_engine/data/covenant_1.0b.txt +978 -0
- ciris_engine/gui_static/11steps.svg +107 -0
- ciris_engine/gui_static/2x-schematics.png +0 -0
- ciris_engine/gui_static/404/index.html +1 -0
- ciris_engine/gui_static/404.html +1 -0
- ciris_engine/gui_static/_next/static/0edhkwDxd5UccTsCmtaBi/_buildManifest.js +1 -0
- ciris_engine/gui_static/_next/static/0edhkwDxd5UccTsCmtaBi/_ssgManifest.js +1 -0
- ciris_engine/gui_static/_next/static/U-3xTQao7hc2wnAi-Uekm/_buildManifest.js +1 -0
- ciris_engine/gui_static/_next/static/U-3xTQao7hc2wnAi-Uekm/_ssgManifest.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/3297-60e86ba0f8a7b040.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/3835-2aad4b7f5f8e4643.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/4499-99a0bc47de0b8975.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/4534-af88cd4ba6e99bff.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/4541-84b455f9e0dc4cfe.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/4789-61412711484754bb.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/6539-c6398bc9d7018430.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/704-8e827b26cc8c2d32.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/704-fb45d630f3192c6f.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/8072-de4952a2e6d2b33f.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/8315-b91d03a3949db0af.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/8386-f93a83ccbd789bd9.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/87c73c54-781a7f35148d5433.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/8903-fefea3339a02d41b.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/9090-e66485adf8d9d990.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/_not-found/page-a67d9808462c23b1.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/api-keys/page-2d7ee1583bbbd02e.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/api-keys/page-6a3c2bae6fe92b7b.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/consent/page-2ed3a035136bc4e8.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/consent/page-b2f5c91844a32422.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/page-25b90f89af3ea58c.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/page-b65d16c94ecaf69c.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/privacy/page-675b6d05c8f9184f.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/privacy/page-cbee2e1c8ab52145.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/settings/page-0f44da06697cf9f0.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/account/settings/page-563420253577edbf.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/adapters/page-1854631018bc32be.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/agents/page-8353752c176a7c70.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/agents/page-f61a529f110a6040.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/api-demo/page-7f19b9d20d39be28.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/api-demo/page-d1063938f249b8bd.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/audit/page-321b6728b8fff0bb.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/audit/page-ebac35ca961a1277.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/billing/page-6f3dc3bd02924f8e.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/billing/page-fa4a469f814c821a.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/comms/page-0d4f734269addd8f.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/comms/page-79227d426050089c.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/config/page-018d21d683b6e5bc.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/config/page-2aa5a5363ca2a371.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/consent/page-198373205fd316e2.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/consent/page-f2ca39e7713b13f8.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/dashboard/page-1dd5a196f643c60d.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/dashboard/page-530a04d3abbb8cda.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/docs/page-3193b06d094ab654.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/docs/page-330e996dedb87aba.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/layout-0a70f5fc460298b1.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/layout-21f2f99dd5b336e9.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/login/page-33240e6c6034a49d.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/login/page-68ffab6d54a7fdcd.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/logs/page-8a6167aecc4a475c.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/memory/page-9ca8c5d0056de3ff.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/memory/page-e961226941c18f81.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/page-6fdb065a787a4974.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/page-89f87d431be6064a.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/runtime/page-2e728b9c43aa164d.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/runtime/page-c7dd033dc40a72f0.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/services/page-ae9f0bdf11d01a95.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/services/page-b10feb79ca5d75e5.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/sessions/page-13ebe7ef1c16ae11.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/sessions/page-e6c82b16d617f785.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/setup/page-0beb5f5b5a5c20fc.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/setup/page-2595e729eae30c0e.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/status-dashboard/page-1037c987aecc3653.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/status-dashboard/page-2ffd147f6d3162ff.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/system/page-2c5798d58cafcd91.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/system/page-505b1ba4eceb01c3.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-auth/page-b0cad31d5cb1b2fa.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-auth/page-f3ecd7a8012df230.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-login/page-f35117fdc4105801.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-login/page-fb583a7924114906.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-sdk/page-50f116fd76935563.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/test-sdk/page-c37d8aa5ba623a44.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/tools/page-429aec7a707777ef.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/tools/page-5f705aad60e0c04e.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/users/page-13476b8b0f3808cc.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/users/page-7e500d154ed5bba4.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/wa/page-cc4a9d8a5cb44d08.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/app/wa/page-ec3e429efbc79230.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/framework-9d29490f5ba089ba.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/main-1f554952e47a82c4.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/main-app-26fa8aed029082e5.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/main-app-97b0486ef6bcef25.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/pages/_app-6ce685456e616eb2.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/pages/_error-d4bce98d93fe21e7.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- ciris_engine/gui_static/_next/static/chunks/webpack-fcebd240b7f8477d.js +1 -0
- ciris_engine/gui_static/_next/static/css/16b94b1fe0cc6e37.css +3 -0
- ciris_engine/gui_static/_next/static/css/77a24ceaae86deff.css +3 -0
- ciris_engine/gui_static/_next/static/media/4cf2300e9c8272f7-s.p.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/747892c23ea88013-s.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/8d697b304b401681-s.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/93f479601ee12b01-s.p.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/9610d9e46709d722-s.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/ba015fad6dcf6784-s.woff2 +0 -0
- ciris_engine/gui_static/_next/static/media/d8298875641ec7d4-s.p.woff2 +0 -0
- ciris_engine/gui_static/account/api-keys/index.html +1 -0
- ciris_engine/gui_static/account/api-keys/index.txt +27 -0
- ciris_engine/gui_static/account/consent/index.html +1 -0
- ciris_engine/gui_static/account/consent/index.txt +27 -0
- ciris_engine/gui_static/account/index.html +1 -0
- ciris_engine/gui_static/account/index.txt +27 -0
- ciris_engine/gui_static/account/privacy/index.html +1 -0
- ciris_engine/gui_static/account/privacy/index.txt +27 -0
- ciris_engine/gui_static/account/settings/index.html +1 -0
- ciris_engine/gui_static/account/settings/index.txt +27 -0
- ciris_engine/gui_static/adapters/index.html +1 -0
- ciris_engine/gui_static/adapters/index.txt +27 -0
- ciris_engine/gui_static/agents/index.html +1 -0
- ciris_engine/gui_static/agents/index.txt +27 -0
- ciris_engine/gui_static/andrew-roberts-euBRXcx57T4-unsplash.jpg +0 -0
- ciris_engine/gui_static/api-demo/index.html +1 -0
- ciris_engine/gui_static/api-demo/index.txt +27 -0
- ciris_engine/gui_static/audit/index.html +1 -0
- ciris_engine/gui_static/audit/index.txt +27 -0
- ciris_engine/gui_static/billing/index.html +1 -0
- ciris_engine/gui_static/billing/index.txt +27 -0
- ciris_engine/gui_static/blurryinfo.png +0 -0
- ciris_engine/gui_static/chip-vincent-PkQDwfl9Flc-unsplash.jpg +0 -0
- ciris_engine/gui_static/ciris-architecture.svg +338 -0
- ciris_engine/gui_static/comms/index.html +1 -0
- ciris_engine/gui_static/comms/index.txt +27 -0
- ciris_engine/gui_static/config/index.html +1 -0
- ciris_engine/gui_static/config/index.txt +27 -0
- ciris_engine/gui_static/consent/index.html +1 -0
- ciris_engine/gui_static/consent/index.txt +27 -0
- ciris_engine/gui_static/dashboard/index.html +1 -0
- ciris_engine/gui_static/dashboard/index.txt +27 -0
- ciris_engine/gui_static/docs/index.html +1 -0
- ciris_engine/gui_static/docs/index.txt +27 -0
- ciris_engine/gui_static/eric.png +0 -0
- ciris_engine/gui_static/file.svg +1 -0
- ciris_engine/gui_static/globe.svg +1 -0
- ciris_engine/gui_static/index.html +1 -0
- ciris_engine/gui_static/index.txt +27 -0
- ciris_engine/gui_static/infogfx-1@2x.png +0 -0
- ciris_engine/gui_static/infogfx-2.png +0 -0
- ciris_engine/gui_static/infogfx-dark-1.png +0 -0
- ciris_engine/gui_static/kelly-vohs-soSTXmIxTDU-unsplash.jpg +0 -0
- ciris_engine/gui_static/login/index.html +1 -0
- ciris_engine/gui_static/login/index.txt +27 -0
- ciris_engine/gui_static/logs/index.html +1 -0
- ciris_engine/gui_static/logs/index.txt +27 -0
- ciris_engine/gui_static/memory/index.html +1 -0
- ciris_engine/gui_static/memory/index.txt +27 -0
- ciris_engine/gui_static/nathan-farrish-ArcTfEoBgzs-unsplash.jpg +0 -0
- ciris_engine/gui_static/next.svg +1 -0
- ciris_engine/gui_static/overview.svg +512 -0
- ciris_engine/gui_static/overview1.svg +407 -0
- ciris_engine/gui_static/overview2.svg +370 -0
- ciris_engine/gui_static/pipeline-visualization.svg +278 -0
- ciris_engine/gui_static/privacy-policy.html +160 -0
- ciris_engine/gui_static/runtime/index.html +8 -0
- ciris_engine/gui_static/runtime/index.txt +27 -0
- ciris_engine/gui_static/services/index.html +1 -0
- ciris_engine/gui_static/services/index.txt +27 -0
- ciris_engine/gui_static/sessions/index.html +1 -0
- ciris_engine/gui_static/sessions/index.txt +27 -0
- ciris_engine/gui_static/setup/index.html +1 -0
- ciris_engine/gui_static/setup/index.txt +27 -0
- ciris_engine/gui_static/status-dashboard/index.html +1 -0
- ciris_engine/gui_static/status-dashboard/index.txt +27 -0
- ciris_engine/gui_static/system/index.html +1 -0
- ciris_engine/gui_static/system/index.txt +27 -0
- ciris_engine/gui_static/terms-of-service.html +174 -0
- ciris_engine/gui_static/test-auth/index.html +1 -0
- ciris_engine/gui_static/test-auth/index.txt +27 -0
- ciris_engine/gui_static/test-login/index.html +1 -0
- ciris_engine/gui_static/test-login/index.txt +27 -0
- ciris_engine/gui_static/test-sdk/index.html +1 -0
- ciris_engine/gui_static/test-sdk/index.txt +27 -0
- ciris_engine/gui_static/tools/index.html +1 -0
- ciris_engine/gui_static/tools/index.txt +27 -0
- ciris_engine/gui_static/users/index.html +1 -0
- ciris_engine/gui_static/users/index.txt +27 -0
- ciris_engine/gui_static/vercel.svg +1 -0
- ciris_engine/gui_static/videos/video1.mp4 +0 -0
- ciris_engine/gui_static/videos/video3.mp4 +0 -0
- ciris_engine/gui_static/wa/index.html +1 -0
- ciris_engine/gui_static/wa/index.txt +27 -0
- ciris_engine/gui_static/window.svg +1 -0
- ciris_engine/logic/__init__.py +8 -0
- ciris_engine/logic/adapters/__init__.py +74 -0
- ciris_engine/logic/adapters/api/__init__.py +5 -0
- ciris_engine/logic/adapters/api/adapter.py +1037 -0
- ciris_engine/logic/adapters/api/api_communication.py +370 -0
- ciris_engine/logic/adapters/api/api_document.py +330 -0
- ciris_engine/logic/adapters/api/api_observer.py +24 -0
- ciris_engine/logic/adapters/api/api_runtime_control.py +388 -0
- ciris_engine/logic/adapters/api/api_tools.py +299 -0
- ciris_engine/logic/adapters/api/api_vision.py +215 -0
- ciris_engine/logic/adapters/api/app.py +272 -0
- ciris_engine/logic/adapters/api/auth.py +159 -0
- ciris_engine/logic/adapters/api/config.py +101 -0
- ciris_engine/logic/adapters/api/constants.py +55 -0
- ciris_engine/logic/adapters/api/dependencies/__init__.py +1 -0
- ciris_engine/logic/adapters/api/dependencies/auth.py +260 -0
- ciris_engine/logic/adapters/api/endpoints/__init__.py +1 -0
- ciris_engine/logic/adapters/api/endpoints/emergency.py +86 -0
- ciris_engine/logic/adapters/api/middleware/__init__.py +1 -0
- ciris_engine/logic/adapters/api/middleware/rate_limiter.py +302 -0
- ciris_engine/logic/adapters/api/models.py +29 -0
- ciris_engine/logic/adapters/api/routes/__init__.py +52 -0
- ciris_engine/logic/adapters/api/routes/agent.py +1762 -0
- ciris_engine/logic/adapters/api/routes/audit.py +707 -0
- ciris_engine/logic/adapters/api/routes/auth.py +1745 -0
- ciris_engine/logic/adapters/api/routes/billing.py +895 -0
- ciris_engine/logic/adapters/api/routes/config.py +329 -0
- ciris_engine/logic/adapters/api/routes/connectors.py +534 -0
- ciris_engine/logic/adapters/api/routes/consent.py +637 -0
- ciris_engine/logic/adapters/api/routes/dsar.py +637 -0
- ciris_engine/logic/adapters/api/routes/dsar_multi_source.py +484 -0
- ciris_engine/logic/adapters/api/routes/emergency.py +302 -0
- ciris_engine/logic/adapters/api/routes/memory.py +733 -0
- ciris_engine/logic/adapters/api/routes/memory_filters.py +230 -0
- ciris_engine/logic/adapters/api/routes/memory_models.py +112 -0
- ciris_engine/logic/adapters/api/routes/memory_queries.py +236 -0
- ciris_engine/logic/adapters/api/routes/memory_query_helpers.py +394 -0
- ciris_engine/logic/adapters/api/routes/memory_visualization.py +359 -0
- ciris_engine/logic/adapters/api/routes/memory_visualization_helpers.py +110 -0
- ciris_engine/logic/adapters/api/routes/partnership.py +541 -0
- ciris_engine/logic/adapters/api/routes/setup.py +1374 -0
- ciris_engine/logic/adapters/api/routes/system.py +3049 -0
- ciris_engine/logic/adapters/api/routes/system_extensions.py +952 -0
- ciris_engine/logic/adapters/api/routes/telemetry.py +1987 -0
- ciris_engine/logic/adapters/api/routes/telemetry_converters.py +141 -0
- ciris_engine/logic/adapters/api/routes/telemetry_helpers.py +111 -0
- ciris_engine/logic/adapters/api/routes/telemetry_logs_reader.py +280 -0
- ciris_engine/logic/adapters/api/routes/telemetry_metrics.py +131 -0
- ciris_engine/logic/adapters/api/routes/telemetry_models.py +190 -0
- ciris_engine/logic/adapters/api/routes/telemetry_otlp.py +878 -0
- ciris_engine/logic/adapters/api/routes/telemetry_resource_helpers.py +191 -0
- ciris_engine/logic/adapters/api/routes/tickets.py +541 -0
- ciris_engine/logic/adapters/api/routes/tools.py +556 -0
- ciris_engine/logic/adapters/api/routes/transparency.py +281 -0
- ciris_engine/logic/adapters/api/routes/users.py +981 -0
- ciris_engine/logic/adapters/api/routes/verification.py +373 -0
- ciris_engine/logic/adapters/api/routes/wa.py +369 -0
- ciris_engine/logic/adapters/api/service_configuration.py +177 -0
- ciris_engine/logic/adapters/api/services/__init__.py +1 -0
- ciris_engine/logic/adapters/api/services/auth_service.py +1417 -0
- ciris_engine/logic/adapters/api/services/oauth_security.py +68 -0
- ciris_engine/logic/adapters/base.py +141 -0
- ciris_engine/logic/adapters/base_adapter.py +73 -0
- ciris_engine/logic/adapters/base_observer.py +1141 -0
- ciris_engine/logic/adapters/base_vision.py +312 -0
- ciris_engine/logic/adapters/cirisnode_client.py +307 -0
- ciris_engine/logic/adapters/cli/__init__.py +3 -0
- ciris_engine/logic/adapters/cli/adapter.py +207 -0
- ciris_engine/logic/adapters/cli/cli_adapter.py +902 -0
- ciris_engine/logic/adapters/cli/cli_observer.py +268 -0
- ciris_engine/logic/adapters/cli/cli_tools.py +427 -0
- ciris_engine/logic/adapters/cli/cli_wa_service.py +134 -0
- ciris_engine/logic/adapters/cli/config.py +73 -0
- ciris_engine/logic/adapters/discord/__init__.py +3 -0
- ciris_engine/logic/adapters/discord/adapter.py +783 -0
- ciris_engine/logic/adapters/discord/ciris_discord_client.py +159 -0
- ciris_engine/logic/adapters/discord/config.py +177 -0
- ciris_engine/logic/adapters/discord/constants.py +185 -0
- ciris_engine/logic/adapters/discord/discord-stubs.pyi +50 -0
- ciris_engine/logic/adapters/discord/discord_adapter.py +1584 -0
- ciris_engine/logic/adapters/discord/discord_audit.py +150 -0
- ciris_engine/logic/adapters/discord/discord_channel_manager.py +351 -0
- ciris_engine/logic/adapters/discord/discord_connection_manager.py +313 -0
- ciris_engine/logic/adapters/discord/discord_embed_formatter.py +369 -0
- ciris_engine/logic/adapters/discord/discord_error_classifier.py +302 -0
- ciris_engine/logic/adapters/discord/discord_error_handler.py +316 -0
- ciris_engine/logic/adapters/discord/discord_guidance_handler.py +460 -0
- ciris_engine/logic/adapters/discord/discord_message_handler.py +207 -0
- ciris_engine/logic/adapters/discord/discord_observer.py +670 -0
- ciris_engine/logic/adapters/discord/discord_rate_limiter.py +249 -0
- ciris_engine/logic/adapters/discord/discord_reaction_handler.py +278 -0
- ciris_engine/logic/adapters/discord/discord_tool_handler.py +465 -0
- ciris_engine/logic/adapters/discord/discord_tool_service.py +790 -0
- ciris_engine/logic/adapters/discord/discord_tools.py +90 -0
- ciris_engine/logic/adapters/discord/discord_vision_helper.py +148 -0
- ciris_engine/logic/adapters/discord/py.typed +0 -0
- ciris_engine/logic/adapters/document_parser.py +320 -0
- ciris_engine/logic/audit/__init__.py +10 -0
- ciris_engine/logic/audit/hash_chain.py +313 -0
- ciris_engine/logic/audit/signature_manager.py +352 -0
- ciris_engine/logic/audit/verifier.py +408 -0
- ciris_engine/logic/buses/__init__.py +21 -0
- ciris_engine/logic/buses/base_bus.py +178 -0
- ciris_engine/logic/buses/bus_manager.py +121 -0
- ciris_engine/logic/buses/communication_bus.py +387 -0
- ciris_engine/logic/buses/llm_bus.py +722 -0
- ciris_engine/logic/buses/memory_bus.py +577 -0
- ciris_engine/logic/buses/prohibitions.py +502 -0
- ciris_engine/logic/buses/runtime_control_bus.py +539 -0
- ciris_engine/logic/buses/tool_bus.py +482 -0
- ciris_engine/logic/buses/wise_bus.py +684 -0
- ciris_engine/logic/config/__init__.py +25 -0
- ciris_engine/logic/config/bootstrap.py +255 -0
- ciris_engine/logic/config/config_accessor.py +202 -0
- ciris_engine/logic/config/db_paths.py +194 -0
- ciris_engine/logic/config/env_utils.py +39 -0
- ciris_engine/logic/conscience/__init__.py +16 -0
- ciris_engine/logic/conscience/build_deferral_package.py +0 -0
- ciris_engine/logic/conscience/core.py +688 -0
- ciris_engine/logic/conscience/interface.py +33 -0
- ciris_engine/logic/conscience/registry.py +76 -0
- ciris_engine/logic/conscience/thought_depth_guardrail.py +231 -0
- ciris_engine/logic/conscience/updated_status_conscience.py +156 -0
- ciris_engine/logic/context/__init__.py +10 -0
- ciris_engine/logic/context/batch_context.py +550 -0
- ciris_engine/logic/context/builder.py +149 -0
- ciris_engine/logic/context/channel_resolution.py +136 -0
- ciris_engine/logic/context/secrets_snapshot.py +52 -0
- ciris_engine/logic/context/system_snapshot.py +116 -0
- ciris_engine/logic/context/system_snapshot_helpers.py +1651 -0
- ciris_engine/logic/covenant/__init__.py +33 -0
- ciris_engine/logic/covenant/executor.py +303 -0
- ciris_engine/logic/covenant/extractor.py +382 -0
- ciris_engine/logic/covenant/handler.py +241 -0
- ciris_engine/logic/covenant/verifier.py +383 -0
- ciris_engine/logic/dma/__init__.py +15 -0
- ciris_engine/logic/dma/action_selection/__init__.py +11 -0
- ciris_engine/logic/dma/action_selection/action_instruction_generator.py +444 -0
- ciris_engine/logic/dma/action_selection/context_builder.py +508 -0
- ciris_engine/logic/dma/action_selection/faculty_integration.py +193 -0
- ciris_engine/logic/dma/action_selection/special_cases.py +132 -0
- ciris_engine/logic/dma/action_selection_pdma.py +365 -0
- ciris_engine/logic/dma/base_dma.py +335 -0
- ciris_engine/logic/dma/csdma.py +239 -0
- ciris_engine/logic/dma/dma_executor.py +575 -0
- ciris_engine/logic/dma/dsdma_base.py +410 -0
- ciris_engine/logic/dma/exceptions.py +4 -0
- ciris_engine/logic/dma/factory.py +150 -0
- ciris_engine/logic/dma/pdma.py +120 -0
- ciris_engine/logic/dma/prompt_loader.py +189 -0
- ciris_engine/logic/dma/prompts/action_selection_pdma.yml +58 -0
- ciris_engine/logic/dma/prompts/csdma_common_sense.yml +28 -0
- ciris_engine/logic/dma/prompts/dsdma_base.yml +17 -0
- ciris_engine/logic/dma/prompts/pdma_ethical.yml +42 -0
- ciris_engine/logic/formatters/__init__.py +26 -0
- ciris_engine/logic/formatters/crisis_resources.py +80 -0
- ciris_engine/logic/formatters/escalation.py +21 -0
- ciris_engine/logic/formatters/identity.py +224 -0
- ciris_engine/logic/formatters/prompt_blocks.py +64 -0
- ciris_engine/logic/formatters/system_snapshot.py +193 -0
- ciris_engine/logic/formatters/user_profiles.py +108 -0
- ciris_engine/logic/handlers/__init__.py +1 -0
- ciris_engine/logic/handlers/control/__init__.py +1 -0
- ciris_engine/logic/handlers/control/defer_handler.py +195 -0
- ciris_engine/logic/handlers/control/ponder_handler.py +154 -0
- ciris_engine/logic/handlers/control/reject_handler.py +81 -0
- ciris_engine/logic/handlers/external/__init__.py +1 -0
- ciris_engine/logic/handlers/external/observe_handler.py +154 -0
- ciris_engine/logic/handlers/external/speak_handler.py +250 -0
- ciris_engine/logic/handlers/external/tool_handler.py +148 -0
- ciris_engine/logic/handlers/memory/__init__.py +1 -0
- ciris_engine/logic/handlers/memory/forget_handler.py +107 -0
- ciris_engine/logic/handlers/memory/memorize_handler.py +391 -0
- ciris_engine/logic/handlers/memory/recall_handler.py +213 -0
- ciris_engine/logic/handlers/terminal/__init__.py +1 -0
- ciris_engine/logic/handlers/terminal/task_complete_handler.py +299 -0
- ciris_engine/logic/infrastructure/__init__.py +1 -0
- ciris_engine/logic/infrastructure/handlers/__init__.py +8 -0
- ciris_engine/logic/infrastructure/handlers/action_dispatcher.py +382 -0
- ciris_engine/logic/infrastructure/handlers/base_handler.py +450 -0
- ciris_engine/logic/infrastructure/handlers/exceptions.py +2 -0
- ciris_engine/logic/infrastructure/handlers/handler_registry.py +59 -0
- ciris_engine/logic/infrastructure/handlers/helpers.py +55 -0
- ciris_engine/logic/infrastructure/step_streaming.py +149 -0
- ciris_engine/logic/infrastructure/sub_services/__init__.py +1 -0
- ciris_engine/logic/infrastructure/sub_services/identity_variance_monitor.py +1035 -0
- ciris_engine/logic/infrastructure/sub_services/pattern_analysis_loop.py +758 -0
- ciris_engine/logic/infrastructure/sub_services/wa_cli_bootstrap.py +229 -0
- ciris_engine/logic/infrastructure/sub_services/wa_cli_display.py +176 -0
- ciris_engine/logic/infrastructure/sub_services/wa_cli_oauth.py +404 -0
- ciris_engine/logic/infrastructure/sub_services/wa_cli_wizard.py +181 -0
- ciris_engine/logic/persistence/__init__.py +130 -0
- ciris_engine/logic/persistence/analytics.py +97 -0
- ciris_engine/logic/persistence/db/__init__.py +28 -0
- ciris_engine/logic/persistence/db/core.py +520 -0
- ciris_engine/logic/persistence/db/dialect.py +380 -0
- ciris_engine/logic/persistence/db/execution_helpers.py +216 -0
- ciris_engine/logic/persistence/db/migration_runner.py +191 -0
- ciris_engine/logic/persistence/db/operations.py +313 -0
- ciris_engine/logic/persistence/db/query_builder.py +232 -0
- ciris_engine/logic/persistence/db/retry.py +154 -0
- ciris_engine/logic/persistence/db/setup.py +18 -0
- ciris_engine/logic/persistence/migrations/postgres/001_initial_schema.sql +4 -0
- ciris_engine/logic/persistence/migrations/postgres/002_add_retry_status.sql +3 -0
- ciris_engine/logic/persistence/migrations/postgres/003_add_task_update_tracking.sql +8 -0
- ciris_engine/logic/persistence/migrations/postgres/004_add_occurrence_id.sql +54 -0
- ciris_engine/logic/persistence/migrations/postgres/005_add_consolidation_locks.sql +22 -0
- ciris_engine/logic/persistence/migrations/postgres/006_add_correlation_id_unique_index.sql +16 -0
- ciris_engine/logic/persistence/migrations/postgres/007_add_dsar_tickets.sql +39 -0
- ciris_engine/logic/persistence/migrations/postgres/008_rename_to_tickets_add_sop.sql +123 -0
- ciris_engine/logic/persistence/migrations/postgres/009_add_ticket_status_columns.sql +39 -0
- ciris_engine/logic/persistence/migrations/postgres/010_add_images_to_tasks.sql +5 -0
- ciris_engine/logic/persistence/migrations/sqlite/001_initial_schema.sql +357 -0
- ciris_engine/logic/persistence/migrations/sqlite/002_add_retry_status.sql +3 -0
- ciris_engine/logic/persistence/migrations/sqlite/003_add_task_update_tracking.sql +8 -0
- ciris_engine/logic/persistence/migrations/sqlite/004_add_occurrence_id.sql +45 -0
- ciris_engine/logic/persistence/migrations/sqlite/005_add_consolidation_locks.sql +22 -0
- ciris_engine/logic/persistence/migrations/sqlite/006_add_correlation_id_unique_index.sql +16 -0
- ciris_engine/logic/persistence/migrations/sqlite/007_add_dsar_tickets.sql +39 -0
- ciris_engine/logic/persistence/migrations/sqlite/008_rename_to_tickets_add_sop.sql +120 -0
- ciris_engine/logic/persistence/migrations/sqlite/009_add_ticket_status_columns.sql +129 -0
- ciris_engine/logic/persistence/migrations/sqlite/010_add_images_to_tasks.sql +17 -0
- ciris_engine/logic/persistence/models/__init__.py +141 -0
- ciris_engine/logic/persistence/models/correlations.py +881 -0
- ciris_engine/logic/persistence/models/deferral.py +68 -0
- ciris_engine/logic/persistence/models/dsar.py +286 -0
- ciris_engine/logic/persistence/models/graph.py +362 -0
- ciris_engine/logic/persistence/models/identity.py +264 -0
- ciris_engine/logic/persistence/models/queue_status.py +139 -0
- ciris_engine/logic/persistence/models/tasks.py +1043 -0
- ciris_engine/logic/persistence/models/thoughts.py +400 -0
- ciris_engine/logic/persistence/models/tickets.py +518 -0
- ciris_engine/logic/persistence/stores/__init__.py +13 -0
- ciris_engine/logic/persistence/stores/auth_helpers.py +117 -0
- ciris_engine/logic/persistence/stores/authentication_store.py +414 -0
- ciris_engine/logic/persistence/utils.py +212 -0
- ciris_engine/logic/processors/__init__.py +30 -0
- ciris_engine/logic/processors/core/__init__.py +1 -0
- ciris_engine/logic/processors/core/base_processor.py +280 -0
- ciris_engine/logic/processors/core/main_processor.py +1777 -0
- ciris_engine/logic/processors/core/step_decorators.py +1583 -0
- ciris_engine/logic/processors/core/thought_processor/__init__.py +20 -0
- ciris_engine/logic/processors/core/thought_processor/action_execution.py +49 -0
- ciris_engine/logic/processors/core/thought_processor/conscience_execution.py +382 -0
- ciris_engine/logic/processors/core/thought_processor/finalize_action.py +66 -0
- ciris_engine/logic/processors/core/thought_processor/gather_context.py +120 -0
- ciris_engine/logic/processors/core/thought_processor/main.py +920 -0
- ciris_engine/logic/processors/core/thought_processor/perform_aspdma.py +86 -0
- ciris_engine/logic/processors/core/thought_processor/perform_dmas.py +106 -0
- ciris_engine/logic/processors/core/thought_processor/recursive_processing.py +237 -0
- ciris_engine/logic/processors/core/thought_processor/round_complete.py +52 -0
- ciris_engine/logic/processors/core/thought_processor/start_round.py +64 -0
- ciris_engine/logic/processors/exceptions.py +59 -0
- ciris_engine/logic/processors/states/__init__.py +1 -0
- ciris_engine/logic/processors/states/dream_processor.py +1381 -0
- ciris_engine/logic/processors/states/play_processor.py +141 -0
- ciris_engine/logic/processors/states/shutdown_processor.py +623 -0
- ciris_engine/logic/processors/states/solitude_processor.py +305 -0
- ciris_engine/logic/processors/states/wakeup_processor.py +802 -0
- ciris_engine/logic/processors/states/work_processor.py +742 -0
- ciris_engine/logic/processors/support/__init__.py +1 -0
- ciris_engine/logic/processors/support/dma_orchestrator.py +336 -0
- ciris_engine/logic/processors/support/processing_queue.py +133 -0
- ciris_engine/logic/processors/support/shutdown_condition_evaluator.py +294 -0
- ciris_engine/logic/processors/support/state_manager.py +358 -0
- ciris_engine/logic/processors/support/task_manager.py +303 -0
- ciris_engine/logic/processors/support/thought_escalation.py +116 -0
- ciris_engine/logic/processors/support/thought_manager.py +328 -0
- ciris_engine/logic/processors/support/thought_manager_enhanced.py +105 -0
- ciris_engine/logic/registries/__init__.py +34 -0
- ciris_engine/logic/registries/base.py +653 -0
- ciris_engine/logic/registries/circuit_breaker.py +275 -0
- ciris_engine/logic/registries/typed_registries.py +184 -0
- ciris_engine/logic/runtime/__init__.py +7 -0
- ciris_engine/logic/runtime/adapter_loader.py +261 -0
- ciris_engine/logic/runtime/adapter_manager.py +1053 -0
- ciris_engine/logic/runtime/ciris_runtime.py +2342 -0
- ciris_engine/logic/runtime/ciris_runtime_helpers.py +923 -0
- ciris_engine/logic/runtime/component_builder.py +361 -0
- ciris_engine/logic/runtime/identity_manager.py +219 -0
- ciris_engine/logic/runtime/module_loader.py +207 -0
- ciris_engine/logic/runtime/prevent_sideeffects.py +30 -0
- ciris_engine/logic/runtime/runtime_interface.py +23 -0
- ciris_engine/logic/runtime/service_initializer.py +1623 -0
- ciris_engine/logic/secrets/__init__.py +30 -0
- ciris_engine/logic/secrets/encryption.py +175 -0
- ciris_engine/logic/secrets/filter.py +295 -0
- ciris_engine/logic/secrets/service.py +652 -0
- ciris_engine/logic/secrets/store.py +669 -0
- ciris_engine/logic/services/__init__.py +1 -0
- ciris_engine/logic/services/adaptation/__init__.py +3 -0
- ciris_engine/logic/services/base_graph_service.py +142 -0
- ciris_engine/logic/services/base_infrastructure_service.py +69 -0
- ciris_engine/logic/services/base_scheduled_service.py +136 -0
- ciris_engine/logic/services/base_service.py +247 -0
- ciris_engine/logic/services/governance/__init__.py +3 -0
- ciris_engine/logic/services/governance/adaptive_filter/__init__.py +14 -0
- ciris_engine/logic/services/governance/adaptive_filter/service.py +818 -0
- ciris_engine/logic/services/governance/consent/__init__.py +53 -0
- ciris_engine/logic/services/governance/consent/air.py +403 -0
- ciris_engine/logic/services/governance/consent/decay.py +324 -0
- ciris_engine/logic/services/governance/consent/dsar_automation.py +589 -0
- ciris_engine/logic/services/governance/consent/exceptions.py +106 -0
- ciris_engine/logic/services/governance/consent/metrics.py +270 -0
- ciris_engine/logic/services/governance/consent/partnership.py +533 -0
- ciris_engine/logic/services/governance/consent/service.py +1256 -0
- ciris_engine/logic/services/governance/dsar/__init__.py +29 -0
- ciris_engine/logic/services/governance/dsar/orchestrator.py +977 -0
- ciris_engine/logic/services/governance/dsar/schemas.py +141 -0
- ciris_engine/logic/services/governance/dsar/signature_service.py +283 -0
- ciris_engine/logic/services/governance/self_observation/__init__.py +20 -0
- ciris_engine/logic/services/governance/self_observation/service.py +1153 -0
- ciris_engine/logic/services/governance/visibility/__init__.py +17 -0
- ciris_engine/logic/services/governance/visibility/service.py +512 -0
- ciris_engine/logic/services/governance/wise_authority/__init__.py +15 -0
- ciris_engine/logic/services/governance/wise_authority/service.py +827 -0
- ciris_engine/logic/services/graph/__init__.py +5 -0
- ciris_engine/logic/services/graph/audit_service/__init__.py +5 -0
- ciris_engine/logic/services/graph/audit_service/service.py +1675 -0
- ciris_engine/logic/services/graph/base.py +208 -0
- ciris_engine/logic/services/graph/config_service/__init__.py +5 -0
- ciris_engine/logic/services/graph/config_service/service.py +372 -0
- ciris_engine/logic/services/graph/incident_service/__init__.py +5 -0
- ciris_engine/logic/services/graph/incident_service/service.py +803 -0
- ciris_engine/logic/services/graph/memory_service.py +1120 -0
- ciris_engine/logic/services/graph/telemetry_service/__init__.py +5 -0
- ciris_engine/logic/services/graph/telemetry_service/exceptions.py +104 -0
- ciris_engine/logic/services/graph/telemetry_service/helpers.py +1337 -0
- ciris_engine/logic/services/graph/telemetry_service/service.py +2429 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/__init__.py +17 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/aggregation_helpers.py +355 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/cleanup_helpers.py +438 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/compressor.py +260 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/__init__.py +27 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/audit.py +326 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/conversation.py +291 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/memory.py +197 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/metrics.py +251 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/task.py +257 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/consolidators/trace.py +363 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/data_converter.py +545 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/date_calculation_helpers.py +193 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/db_query_helpers.py +296 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/edge_helpers.py +92 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/edge_manager.py +896 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/extensive_helpers.py +322 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/period_manager.py +152 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/profound_helpers.py +277 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/query_manager.py +812 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/service.py +1692 -0
- ciris_engine/logic/services/graph/tsdb_consolidation/sql_builders.py +363 -0
- ciris_engine/logic/services/infrastructure/__init__.py +1 -0
- ciris_engine/logic/services/infrastructure/authentication/__init__.py +5 -0
- ciris_engine/logic/services/infrastructure/authentication/service.py +1634 -0
- ciris_engine/logic/services/infrastructure/database_maintenance/__init__.py +15 -0
- ciris_engine/logic/services/infrastructure/database_maintenance/service.py +764 -0
- ciris_engine/logic/services/infrastructure/resource_monitor/__init__.py +7 -0
- ciris_engine/logic/services/infrastructure/resource_monitor/ciris_billing_provider.py +755 -0
- ciris_engine/logic/services/infrastructure/resource_monitor/service.py +409 -0
- ciris_engine/logic/services/infrastructure/resource_monitor/simple_credit_provider.py +129 -0
- ciris_engine/logic/services/lifecycle/__init__.py +3 -0
- ciris_engine/logic/services/lifecycle/initialization/__init__.py +10 -0
- ciris_engine/logic/services/lifecycle/initialization/service.py +312 -0
- ciris_engine/logic/services/lifecycle/scheduler/__init__.py +5 -0
- ciris_engine/logic/services/lifecycle/scheduler/service.py +607 -0
- ciris_engine/logic/services/lifecycle/shutdown/__init__.py +9 -0
- ciris_engine/logic/services/lifecycle/shutdown/service.py +378 -0
- ciris_engine/logic/services/lifecycle/time/__init__.py +15 -0
- ciris_engine/logic/services/lifecycle/time/service.py +259 -0
- ciris_engine/logic/services/memory_service/__init__.py +8 -0
- ciris_engine/logic/services/mixins/__init__.py +13 -0
- ciris_engine/logic/services/mixins/example_usage.py +200 -0
- ciris_engine/logic/services/mixins/request_metrics.py +179 -0
- ciris_engine/logic/services/runtime/__init__.py +3 -0
- ciris_engine/logic/services/runtime/adapter_configuration/__init__.py +16 -0
- ciris_engine/logic/services/runtime/adapter_configuration/service.py +674 -0
- ciris_engine/logic/services/runtime/adapter_configuration/session.py +67 -0
- ciris_engine/logic/services/runtime/control_service/__init__.py +5 -0
- ciris_engine/logic/services/runtime/control_service/service.py +2269 -0
- ciris_engine/logic/services/runtime/llm_service/__init__.py +14 -0
- ciris_engine/logic/services/runtime/llm_service/pricing_calculator.py +279 -0
- ciris_engine/logic/services/runtime/llm_service/service.py +930 -0
- ciris_engine/logic/services/tools/__init__.py +5 -0
- ciris_engine/logic/services/tools/core_tool_service/__init__.py +8 -0
- ciris_engine/logic/services/tools/core_tool_service/service.py +852 -0
- ciris_engine/logic/setup/__init__.py +1 -0
- ciris_engine/logic/setup/first_run.py +250 -0
- ciris_engine/logic/setup/wizard.py +327 -0
- ciris_engine/logic/telemetry/__init__.py +46 -0
- ciris_engine/logic/telemetry/core.py +239 -0
- ciris_engine/logic/telemetry/hot_cold_config.py +133 -0
- ciris_engine/logic/telemetry/log_collector.py +190 -0
- ciris_engine/logic/telemetry/resource_monitor.py +7 -0
- ciris_engine/logic/telemetry/security.py +79 -0
- ciris_engine/logic/utils/__init__.py +18 -0
- ciris_engine/logic/utils/channel_utils.py +75 -0
- ciris_engine/logic/utils/consent/__init__.py +1 -0
- ciris_engine/logic/utils/consent/partnership_utils.py +172 -0
- ciris_engine/logic/utils/constants.py +92 -0
- ciris_engine/logic/utils/context_utils.py +145 -0
- ciris_engine/logic/utils/directory_setup.py +533 -0
- ciris_engine/logic/utils/graphql_context_provider.py +152 -0
- ciris_engine/logic/utils/identity_resolution.py +843 -0
- ciris_engine/logic/utils/incident_capture_handler.py +303 -0
- ciris_engine/logic/utils/initialization_manager.py +74 -0
- ciris_engine/logic/utils/jsondict_helpers.py +290 -0
- ciris_engine/logic/utils/log_sanitizer.py +97 -0
- ciris_engine/logic/utils/logging_config.py +151 -0
- ciris_engine/logic/utils/observability_decorators.py +544 -0
- ciris_engine/logic/utils/occurrence_utils.py +155 -0
- ciris_engine/logic/utils/path_resolution.py +281 -0
- ciris_engine/logic/utils/platform_detection.py +286 -0
- ciris_engine/logic/utils/privacy.py +266 -0
- ciris_engine/logic/utils/profile_loader.py +124 -0
- ciris_engine/logic/utils/profile_manager.py +16 -0
- ciris_engine/logic/utils/runtime_utils.py +69 -0
- ciris_engine/logic/utils/shutdown_manager.py +107 -0
- ciris_engine/logic/utils/task_formatters.py +60 -0
- ciris_engine/logic/utils/task_thought_factory.py +404 -0
- ciris_engine/logic/utils/thought_utils.py +54 -0
- ciris_engine/logic/utils/user_utils.py +70 -0
- ciris_engine/protocols/__init__.py +0 -0
- ciris_engine/protocols/adapters/__init__.py +35 -0
- ciris_engine/protocols/adapters/base.py +149 -0
- ciris_engine/protocols/adapters/configurable.py +265 -0
- ciris_engine/protocols/adapters/message.py +90 -0
- ciris_engine/protocols/audit/__init__.py +1 -0
- ciris_engine/protocols/buses/__init__.py +1 -0
- ciris_engine/protocols/config/__init__.py +1 -0
- ciris_engine/protocols/conscience/__init__.py +1 -0
- ciris_engine/protocols/consent.py +88 -0
- ciris_engine/protocols/context/__init__.py +1 -0
- ciris_engine/protocols/data/__init__.py +1 -0
- ciris_engine/protocols/dma/__init__.py +1 -0
- ciris_engine/protocols/dma/base.py +107 -0
- ciris_engine/protocols/faculties.py +34 -0
- ciris_engine/protocols/formatters/__init__.py +1 -0
- ciris_engine/protocols/handlers/__init__.py +1 -0
- ciris_engine/protocols/infrastructure/__init__.py +25 -0
- ciris_engine/protocols/infrastructure/base.py +377 -0
- ciris_engine/protocols/persistence/__init__.py +1 -0
- ciris_engine/protocols/pipeline_control.py +609 -0
- ciris_engine/protocols/processors/__init__.py +19 -0
- ciris_engine/protocols/processors/agent.py +299 -0
- ciris_engine/protocols/processors/base.py +130 -0
- ciris_engine/protocols/processors/orchestration.py +62 -0
- ciris_engine/protocols/registries/__init__.py +1 -0
- ciris_engine/protocols/runtime/__init__.py +1 -0
- ciris_engine/protocols/runtime/base.py +163 -0
- ciris_engine/protocols/secrets/__init__.py +1 -0
- ciris_engine/protocols/services/__init__.py +80 -0
- ciris_engine/protocols/services/adaptation/__init__.py +7 -0
- ciris_engine/protocols/services/adaptation/self_observation.py +265 -0
- ciris_engine/protocols/services/governance/__init__.py +20 -0
- ciris_engine/protocols/services/governance/communication.py +58 -0
- ciris_engine/protocols/services/governance/filter.py +56 -0
- ciris_engine/protocols/services/governance/visibility.py +32 -0
- ciris_engine/protocols/services/governance/wa_auth.py +192 -0
- ciris_engine/protocols/services/governance/wise_authority.py +75 -0
- ciris_engine/protocols/services/graph/__init__.py +19 -0
- ciris_engine/protocols/services/graph/audit.py +92 -0
- ciris_engine/protocols/services/graph/config.py +54 -0
- ciris_engine/protocols/services/graph/incident_management.py +103 -0
- ciris_engine/protocols/services/graph/memory.py +110 -0
- ciris_engine/protocols/services/graph/telemetry.py +51 -0
- ciris_engine/protocols/services/graph/tsdb_consolidation.py +87 -0
- ciris_engine/protocols/services/infrastructure/__init__.py +11 -0
- ciris_engine/protocols/services/infrastructure/authentication.py +159 -0
- ciris_engine/protocols/services/infrastructure/credit_gate.py +46 -0
- ciris_engine/protocols/services/infrastructure/database_maintenance.py +25 -0
- ciris_engine/protocols/services/infrastructure/resource_monitor.py +83 -0
- ciris_engine/protocols/services/lifecycle/__init__.py +13 -0
- ciris_engine/protocols/services/lifecycle/initialization.py +41 -0
- ciris_engine/protocols/services/lifecycle/scheduler.py +42 -0
- ciris_engine/protocols/services/lifecycle/shutdown.py +50 -0
- ciris_engine/protocols/services/lifecycle/time.py +31 -0
- ciris_engine/protocols/services/runtime/__init__.py +13 -0
- ciris_engine/protocols/services/runtime/llm.py +50 -0
- ciris_engine/protocols/services/runtime/runtime_control.py +193 -0
- ciris_engine/protocols/services/runtime/secrets.py +100 -0
- ciris_engine/protocols/services/runtime/tool.py +123 -0
- ciris_engine/protocols/telemetry/__init__.py +1 -0
- ciris_engine/protocols/utils/__init__.py +1 -0
- ciris_engine/schemas/__init__.py +112 -0
- ciris_engine/schemas/actions/__init__.py +37 -0
- ciris_engine/schemas/actions/parameters.py +137 -0
- ciris_engine/schemas/adapters/__init__.py +13 -0
- ciris_engine/schemas/adapters/cirisnode.py +135 -0
- ciris_engine/schemas/adapters/cli.py +97 -0
- ciris_engine/schemas/adapters/cli_tools.py +98 -0
- ciris_engine/schemas/adapters/discord.py +125 -0
- ciris_engine/schemas/adapters/graphql_core.py +144 -0
- ciris_engine/schemas/adapters/registration.py +47 -0
- ciris_engine/schemas/adapters/runtime_context.py +48 -0
- ciris_engine/schemas/adapters/tool_execution.py +45 -0
- ciris_engine/schemas/adapters/tools.py +96 -0
- ciris_engine/schemas/api/__init__.py +1 -0
- ciris_engine/schemas/api/agent.py +50 -0
- ciris_engine/schemas/api/audit.py +38 -0
- ciris_engine/schemas/api/auth.py +351 -0
- ciris_engine/schemas/api/config_security.py +242 -0
- ciris_engine/schemas/api/emergency.py +111 -0
- ciris_engine/schemas/api/responses.py +72 -0
- ciris_engine/schemas/api/runtime.py +26 -0
- ciris_engine/schemas/api/telemetry.py +109 -0
- ciris_engine/schemas/api/wa.py +90 -0
- ciris_engine/schemas/audit/__init__.py +13 -0
- ciris_engine/schemas/audit/core.py +139 -0
- ciris_engine/schemas/audit/hash_chain.py +58 -0
- ciris_engine/schemas/audit/verification.py +131 -0
- ciris_engine/schemas/buses/__init__.py +1 -0
- ciris_engine/schemas/config/__init__.py +41 -0
- ciris_engine/schemas/config/agent.py +279 -0
- ciris_engine/schemas/config/cognitive_state_behaviors.py +194 -0
- ciris_engine/schemas/config/default_dsar_sops.py +178 -0
- ciris_engine/schemas/config/essential.py +195 -0
- ciris_engine/schemas/config/tickets.py +86 -0
- ciris_engine/schemas/conscience/__init__.py +25 -0
- ciris_engine/schemas/conscience/context.py +34 -0
- ciris_engine/schemas/conscience/core.py +145 -0
- ciris_engine/schemas/conscience/results.py +24 -0
- ciris_engine/schemas/consent/__init__.py +5 -0
- ciris_engine/schemas/consent/core.py +404 -0
- ciris_engine/schemas/context/__init__.py +1 -0
- ciris_engine/schemas/covenant.py +382 -0
- ciris_engine/schemas/data/__init__.py +1 -0
- ciris_engine/schemas/dma/__init__.py +16 -0
- ciris_engine/schemas/dma/core.py +199 -0
- ciris_engine/schemas/dma/faculty.py +192 -0
- ciris_engine/schemas/dma/prompts.py +172 -0
- ciris_engine/schemas/dma/results.py +103 -0
- ciris_engine/schemas/formatters/__init__.py +1 -0
- ciris_engine/schemas/handlers/__init__.py +10 -0
- ciris_engine/schemas/handlers/context.py +119 -0
- ciris_engine/schemas/handlers/contexts.py +100 -0
- ciris_engine/schemas/handlers/core.py +167 -0
- ciris_engine/schemas/handlers/memory_schemas.py +67 -0
- ciris_engine/schemas/handlers/schemas.py +95 -0
- ciris_engine/schemas/identity.py +149 -0
- ciris_engine/schemas/infrastructure/__init__.py +1 -0
- ciris_engine/schemas/infrastructure/base.py +256 -0
- ciris_engine/schemas/infrastructure/behavioral_patterns.py +129 -0
- ciris_engine/schemas/infrastructure/feedback_loop.py +57 -0
- ciris_engine/schemas/infrastructure/identity_variance.py +141 -0
- ciris_engine/schemas/infrastructure/oauth.py +175 -0
- ciris_engine/schemas/infrastructure/wa_cli_wizard.py +54 -0
- ciris_engine/schemas/persistence/__init__.py +34 -0
- ciris_engine/schemas/persistence/core.py +140 -0
- ciris_engine/schemas/persistence/correlations.py +73 -0
- ciris_engine/schemas/persistence/postgres/__init__.py +1 -0
- ciris_engine/schemas/persistence/postgres/tables.py +280 -0
- ciris_engine/schemas/persistence/sqlite/__init__.py +1 -0
- ciris_engine/schemas/persistence/sqlite/tables.py +281 -0
- ciris_engine/schemas/platform.py +149 -0
- ciris_engine/schemas/processors/__init__.py +26 -0
- ciris_engine/schemas/processors/base.py +130 -0
- ciris_engine/schemas/processors/cognitive.py +77 -0
- ciris_engine/schemas/processors/context.py +35 -0
- ciris_engine/schemas/processors/core.py +152 -0
- ciris_engine/schemas/processors/dma.py +105 -0
- ciris_engine/schemas/processors/error.py +122 -0
- ciris_engine/schemas/processors/main.py +109 -0
- ciris_engine/schemas/processors/phase_results.py +21 -0
- ciris_engine/schemas/processors/results.py +99 -0
- ciris_engine/schemas/processors/solitude.py +79 -0
- ciris_engine/schemas/processors/state.py +202 -0
- ciris_engine/schemas/processors/state_example.py +177 -0
- ciris_engine/schemas/processors/states.py +21 -0
- ciris_engine/schemas/processors/status.py +34 -0
- ciris_engine/schemas/registries/__init__.py +1 -0
- ciris_engine/schemas/registries/base.py +66 -0
- ciris_engine/schemas/resources/__init__.py +15 -0
- ciris_engine/schemas/resources/crisis.py +315 -0
- ciris_engine/schemas/runtime/__init__.py +42 -0
- ciris_engine/schemas/runtime/adapter_management.py +186 -0
- ciris_engine/schemas/runtime/api.py +58 -0
- ciris_engine/schemas/runtime/audit.py +50 -0
- ciris_engine/schemas/runtime/bootstrap.py +33 -0
- ciris_engine/schemas/runtime/contexts.py +61 -0
- ciris_engine/schemas/runtime/core.py +161 -0
- ciris_engine/schemas/runtime/enums.py +167 -0
- ciris_engine/schemas/runtime/extended.py +232 -0
- ciris_engine/schemas/runtime/manifest.py +311 -0
- ciris_engine/schemas/runtime/memory.py +60 -0
- ciris_engine/schemas/runtime/messages.py +108 -0
- ciris_engine/schemas/runtime/models.py +156 -0
- ciris_engine/schemas/runtime/processing_context.py +43 -0
- ciris_engine/schemas/runtime/protocols_core.py +96 -0
- ciris_engine/schemas/runtime/resources.py +33 -0
- ciris_engine/schemas/runtime/system_context.py +417 -0
- ciris_engine/schemas/secrets/__init__.py +1 -0
- ciris_engine/schemas/secrets/core.py +267 -0
- ciris_engine/schemas/secrets/service.py +95 -0
- ciris_engine/schemas/services/__init__.py +33 -0
- ciris_engine/schemas/services/audit_summary_node.py +172 -0
- ciris_engine/schemas/services/authority/__init__.py +39 -0
- ciris_engine/schemas/services/authority/jwt.py +158 -0
- ciris_engine/schemas/services/authority/wa_updates.py +138 -0
- ciris_engine/schemas/services/authority/wise_authority.py +163 -0
- ciris_engine/schemas/services/authority_core.py +370 -0
- ciris_engine/schemas/services/capabilities.py +72 -0
- ciris_engine/schemas/services/community_core.py +95 -0
- ciris_engine/schemas/services/context.py +111 -0
- ciris_engine/schemas/services/conversation_summary_node.py +189 -0
- ciris_engine/schemas/services/core/__init__.py +153 -0
- ciris_engine/schemas/services/core/runtime.py +262 -0
- ciris_engine/schemas/services/core/runtime_config.py +117 -0
- ciris_engine/schemas/services/core/secrets.py +65 -0
- ciris_engine/schemas/services/correlation_node.py +179 -0
- ciris_engine/schemas/services/credit_gate.py +92 -0
- ciris_engine/schemas/services/discord_nodes.py +299 -0
- ciris_engine/schemas/services/feedback_core.py +131 -0
- ciris_engine/schemas/services/filters_core.py +270 -0
- ciris_engine/schemas/services/governance.py +26 -0
- ciris_engine/schemas/services/graph/__init__.py +26 -0
- ciris_engine/schemas/services/graph/attributes.py +254 -0
- ciris_engine/schemas/services/graph/audit.py +98 -0
- ciris_engine/schemas/services/graph/consolidation.py +338 -0
- ciris_engine/schemas/services/graph/edge_types.py +43 -0
- ciris_engine/schemas/services/graph/edges.py +88 -0
- ciris_engine/schemas/services/graph/incident.py +312 -0
- ciris_engine/schemas/services/graph/memory.py +84 -0
- ciris_engine/schemas/services/graph/node_data.py +174 -0
- ciris_engine/schemas/services/graph/query_results.py +82 -0
- ciris_engine/schemas/services/graph/telemetry.py +250 -0
- ciris_engine/schemas/services/graph/tsdb_consolidation.py +27 -0
- ciris_engine/schemas/services/graph/tsdb_models.py +107 -0
- ciris_engine/schemas/services/graph_core.py +196 -0
- ciris_engine/schemas/services/graph_typed_nodes.py +194 -0
- ciris_engine/schemas/services/infrastructure/__init__.py +1 -0
- ciris_engine/schemas/services/infrastructure/resource_monitor.py +20 -0
- ciris_engine/schemas/services/lifecycle/__init__.py +9 -0
- ciris_engine/schemas/services/lifecycle/initialization.py +33 -0
- ciris_engine/schemas/services/lifecycle/time.py +50 -0
- ciris_engine/schemas/services/llm.py +187 -0
- ciris_engine/schemas/services/metadata.py +43 -0
- ciris_engine/schemas/services/nodes.py +704 -0
- ciris_engine/schemas/services/operations.py +126 -0
- ciris_engine/schemas/services/requests.py +128 -0
- ciris_engine/schemas/services/resources_core.py +182 -0
- ciris_engine/schemas/services/runtime_control.py +1010 -0
- ciris_engine/schemas/services/shutdown.py +88 -0
- ciris_engine/schemas/services/special/__init__.py +0 -0
- ciris_engine/schemas/services/special/self_observation.py +396 -0
- ciris_engine/schemas/services/trace_summary_node.py +199 -0
- ciris_engine/schemas/services/visibility.py +98 -0
- ciris_engine/schemas/streaming/__init__.py +10 -0
- ciris_engine/schemas/streaming/reasoning_stream.py +95 -0
- ciris_engine/schemas/telemetry/__init__.py +0 -0
- ciris_engine/schemas/telemetry/collector.py +67 -0
- ciris_engine/schemas/telemetry/core.py +252 -0
- ciris_engine/schemas/telemetry/unified.py +59 -0
- ciris_engine/schemas/tools.py +72 -0
- ciris_engine/schemas/types.py +47 -0
- ciris_engine/schemas/utils/__init__.py +1 -0
- ciris_engine/schemas/utils/config_validator.py +54 -0
- ciris_engine/utils/__init__.py +1 -0
- ciris_engine/utils/serialization.py +35 -0
- ciris_sdk/__init__.py +124 -0
- ciris_sdk/auth_store.py +261 -0
- ciris_sdk/client.py +261 -0
- ciris_sdk/exceptions.py +73 -0
- ciris_sdk/model_types.py +258 -0
- ciris_sdk/models.py +354 -0
- ciris_sdk/pagination.py +214 -0
- ciris_sdk/rate_limiter.py +188 -0
- ciris_sdk/setup.py +17 -0
- ciris_sdk/telemetry_models.py +257 -0
- ciris_sdk/telemetry_responses.py +199 -0
- ciris_sdk/transport.py +177 -0
- ciris_sdk/websocket.py +400 -0
- main.py +766 -0
|
@@ -0,0 +1,1745 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication API routes for CIRIS.
|
|
3
|
+
|
|
4
|
+
Implements session management endpoints:
|
|
5
|
+
- POST /v1/auth/login - Authenticate user
|
|
6
|
+
- POST /v1/auth/logout - End session
|
|
7
|
+
- GET /v1/auth/me - Current user info (includes permissions)
|
|
8
|
+
- POST /v1/auth/refresh - Refresh token
|
|
9
|
+
|
|
10
|
+
Note: OAuth endpoints are in api_auth_v2.py
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import os
|
|
15
|
+
import secrets
|
|
16
|
+
from datetime import datetime, timedelta, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional, Set
|
|
19
|
+
|
|
20
|
+
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
21
|
+
from fastapi.responses import RedirectResponse
|
|
22
|
+
from pydantic import BaseModel, Field
|
|
23
|
+
|
|
24
|
+
from ciris_engine.logic.adapters.api.services.auth_service import OAuthUser
|
|
25
|
+
from ciris_engine.logic.adapters.api.services.oauth_security import validate_oauth_picture_url
|
|
26
|
+
from ciris_engine.schemas.api.auth import (
|
|
27
|
+
APIKeyCreateRequest,
|
|
28
|
+
APIKeyInfo,
|
|
29
|
+
APIKeyListResponse,
|
|
30
|
+
APIKeyResponse,
|
|
31
|
+
AuthContext,
|
|
32
|
+
LoginRequest,
|
|
33
|
+
LoginResponse,
|
|
34
|
+
TokenRefreshRequest,
|
|
35
|
+
UserInfo,
|
|
36
|
+
UserRole,
|
|
37
|
+
)
|
|
38
|
+
from ciris_engine.schemas.runtime.api import APIRole
|
|
39
|
+
|
|
40
|
+
from ..dependencies.auth import check_permissions, get_auth_context, get_auth_service, optional_auth
|
|
41
|
+
from ..services.auth_service import APIAuthService
|
|
42
|
+
|
|
43
|
+
# Constants
|
|
44
|
+
OAUTH_CONFIG_PATH = Path("/home/ciris/shared/oauth/oauth.json")
|
|
45
|
+
OAUTH_CONFIG_DIR = ".ciris"
|
|
46
|
+
OAUTH_CONFIG_FILE = "oauth.json"
|
|
47
|
+
PROVIDER_NAME_DESC = "Provider name"
|
|
48
|
+
# Get agent ID from environment, default to 'datum' if not set
|
|
49
|
+
AGENT_ID = os.getenv("CIRIS_AGENT_ID", "datum")
|
|
50
|
+
OAUTH_CALLBACK_PATH = f"/v1/auth/oauth/{AGENT_ID}/{{provider}}/callback"
|
|
51
|
+
DEFAULT_OAUTH_BASE_URL = "https://agents.ciris.ai"
|
|
52
|
+
# Error messages
|
|
53
|
+
FETCH_USER_INFO_ERROR = "Failed to fetch user info"
|
|
54
|
+
|
|
55
|
+
# OAuth Frontend Redirect Configuration
|
|
56
|
+
# These environment variables control where users are redirected after OAuth and what parameters are included
|
|
57
|
+
OAUTH_FRONTEND_URL = os.getenv("OAUTH_FRONTEND_URL") # e.g., https://scout.ciris.ai
|
|
58
|
+
OAUTH_FRONTEND_PATH = os.getenv("OAUTH_FRONTEND_PATH", "/oauth-complete.html") # Default: /oauth-complete.html
|
|
59
|
+
# Comma-separated list of parameters to include in redirect
|
|
60
|
+
# Default includes all ScoutGUI requirements
|
|
61
|
+
OAUTH_REDIRECT_PARAMS = os.getenv(
|
|
62
|
+
"OAUTH_REDIRECT_PARAMS", "access_token,token_type,role,user_id,expires_in,email,marketing_opt_in,agent,provider"
|
|
63
|
+
).split(",")
|
|
64
|
+
# Comma-separated list of allowed redirect domains for OAuth (security: prevents open redirect attacks)
|
|
65
|
+
# Always includes OAUTH_FRONTEND_URL if set. Relative paths (starting with /) are always allowed.
|
|
66
|
+
OAUTH_ALLOWED_REDIRECT_DOMAINS = os.getenv("OAUTH_ALLOWED_REDIRECT_DOMAINS", "").split(",")
|
|
67
|
+
OAUTH_ALLOWED_REDIRECT_DOMAINS = [d.strip().lower() for d in OAUTH_ALLOWED_REDIRECT_DOMAINS if d.strip()]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Helper functions
|
|
71
|
+
def get_oauth_callback_url(provider: str, base_url: Optional[str] = None) -> str:
|
|
72
|
+
"""Get the OAuth callback URL for a specific provider."""
|
|
73
|
+
if base_url is None:
|
|
74
|
+
base_url = os.getenv("OAUTH_CALLBACK_BASE_URL", DEFAULT_OAUTH_BASE_URL)
|
|
75
|
+
return base_url + OAUTH_CALLBACK_PATH.replace("{provider}", provider)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def extract_query_params(url: str) -> Dict[str, str]:
|
|
79
|
+
"""Extract query parameters from a URL."""
|
|
80
|
+
import urllib.parse
|
|
81
|
+
|
|
82
|
+
parsed = urllib.parse.urlparse(url)
|
|
83
|
+
return dict(urllib.parse.parse_qsl(parsed.query))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_private_network_host(host: str) -> bool:
|
|
87
|
+
"""
|
|
88
|
+
Check if a host is on a private/local network.
|
|
89
|
+
|
|
90
|
+
Allows HTTP for local development and Home Assistant on local networks.
|
|
91
|
+
"""
|
|
92
|
+
import ipaddress
|
|
93
|
+
|
|
94
|
+
# Remove port if present
|
|
95
|
+
hostname = host.split(":")[0].lower()
|
|
96
|
+
|
|
97
|
+
# Check for localhost variants
|
|
98
|
+
if hostname in ("localhost", "127.0.0.1", "::1"):
|
|
99
|
+
return True
|
|
100
|
+
|
|
101
|
+
# Check for .local mDNS domains (common for Home Assistant)
|
|
102
|
+
if hostname.endswith(".local"):
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
# Check for private IP ranges
|
|
106
|
+
try:
|
|
107
|
+
ip = ipaddress.ip_address(hostname)
|
|
108
|
+
return ip.is_private or ip.is_loopback
|
|
109
|
+
except ValueError:
|
|
110
|
+
# Not a valid IP address, check if it looks like a local hostname
|
|
111
|
+
pass
|
|
112
|
+
|
|
113
|
+
return False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def validate_redirect_uri(redirect_uri: Optional[str]) -> Optional[str]:
|
|
117
|
+
"""
|
|
118
|
+
Validate redirect_uri to prevent open redirect attacks.
|
|
119
|
+
|
|
120
|
+
Security: Only allows:
|
|
121
|
+
- Relative paths (starting with /)
|
|
122
|
+
- URLs matching OAUTH_FRONTEND_URL domain
|
|
123
|
+
- URLs matching domains in OAUTH_ALLOWED_REDIRECT_DOMAINS
|
|
124
|
+
- HTTP allowed for private/local networks (Home Assistant, local dev)
|
|
125
|
+
|
|
126
|
+
Returns the redirect_uri if valid, None if invalid/untrusted.
|
|
127
|
+
"""
|
|
128
|
+
import urllib.parse
|
|
129
|
+
|
|
130
|
+
if not redirect_uri:
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
# Relative paths are always safe (same-origin)
|
|
134
|
+
if redirect_uri.startswith("/"):
|
|
135
|
+
# Prevent path traversal tricks like //evil.com
|
|
136
|
+
if redirect_uri.startswith("//"):
|
|
137
|
+
logger.warning(f"Rejected redirect_uri with protocol-relative path: {redirect_uri[:50]}")
|
|
138
|
+
return None
|
|
139
|
+
return redirect_uri
|
|
140
|
+
|
|
141
|
+
# Parse the URL to extract domain
|
|
142
|
+
try:
|
|
143
|
+
parsed = urllib.parse.urlparse(redirect_uri)
|
|
144
|
+
if not parsed.scheme or not parsed.netloc:
|
|
145
|
+
logger.warning(f"Rejected malformed redirect_uri: {redirect_uri[:50]}")
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
scheme = parsed.scheme.lower()
|
|
149
|
+
is_private = _is_private_network_host(parsed.netloc)
|
|
150
|
+
|
|
151
|
+
# Allow HTTP only for private/local networks (Home Assistant, local dev)
|
|
152
|
+
# Require HTTPS for all public URLs
|
|
153
|
+
if scheme == "http":
|
|
154
|
+
if not is_private:
|
|
155
|
+
logger.warning(f"Rejected HTTP redirect_uri to public host: {redirect_uri[:50]}")
|
|
156
|
+
return None
|
|
157
|
+
# HTTP to private network is allowed
|
|
158
|
+
logger.debug(f"Allowing HTTP redirect to private network: {parsed.netloc}")
|
|
159
|
+
elif scheme != "https":
|
|
160
|
+
logger.warning(f"Rejected redirect_uri with unsupported scheme: {scheme}")
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
redirect_domain = parsed.netloc.lower()
|
|
164
|
+
|
|
165
|
+
# Private network hosts are always allowed (Home Assistant, local dev)
|
|
166
|
+
# This enables OAuth callbacks to local Home Assistant instances
|
|
167
|
+
if is_private:
|
|
168
|
+
logger.debug(f"Allowing redirect to private network host: {redirect_domain}")
|
|
169
|
+
return redirect_uri
|
|
170
|
+
|
|
171
|
+
# Build list of allowed domains for public URLs
|
|
172
|
+
allowed_domains: Set[str] = set(OAUTH_ALLOWED_REDIRECT_DOMAINS)
|
|
173
|
+
|
|
174
|
+
# Always allow OAUTH_FRONTEND_URL domain if configured
|
|
175
|
+
if OAUTH_FRONTEND_URL:
|
|
176
|
+
frontend_parsed = urllib.parse.urlparse(OAUTH_FRONTEND_URL)
|
|
177
|
+
if frontend_parsed.netloc:
|
|
178
|
+
allowed_domains.add(frontend_parsed.netloc.lower())
|
|
179
|
+
|
|
180
|
+
# Check if redirect domain is allowed
|
|
181
|
+
if redirect_domain in allowed_domains:
|
|
182
|
+
return redirect_uri
|
|
183
|
+
|
|
184
|
+
# Check for subdomain matches (e.g., allow *.ciris.ai if ciris.ai is in allowed)
|
|
185
|
+
for allowed in allowed_domains:
|
|
186
|
+
if redirect_domain == allowed or redirect_domain.endswith("." + allowed):
|
|
187
|
+
return redirect_uri
|
|
188
|
+
|
|
189
|
+
logger.warning(
|
|
190
|
+
f"Rejected redirect_uri to untrusted domain: {redirect_domain}. "
|
|
191
|
+
f"Allowed domains: {allowed_domains or '(none configured)'}"
|
|
192
|
+
)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
except Exception as e:
|
|
196
|
+
logger.warning(f"Failed to parse redirect_uri: {e}")
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
logger = logging.getLogger(__name__)
|
|
201
|
+
|
|
202
|
+
router = APIRouter(tags=["Authentication"])
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@router.post("/auth/login", response_model=LoginResponse)
|
|
206
|
+
async def login(
|
|
207
|
+
request: LoginRequest, req: Request, auth_service: APIAuthService = Depends(get_auth_service)
|
|
208
|
+
) -> LoginResponse:
|
|
209
|
+
"""
|
|
210
|
+
Authenticate with username/password.
|
|
211
|
+
|
|
212
|
+
Currently supports system admin user only. In production, this would
|
|
213
|
+
integrate with a proper user database.
|
|
214
|
+
"""
|
|
215
|
+
getattr(req.app.state, "config_service", None)
|
|
216
|
+
|
|
217
|
+
# Verify username and password using secure bcrypt verification
|
|
218
|
+
user = await auth_service.verify_user_password(request.username, request.password)
|
|
219
|
+
|
|
220
|
+
if not user:
|
|
221
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
222
|
+
|
|
223
|
+
# Generate API key based on user's role
|
|
224
|
+
api_key = f"ciris_{user.api_role.value.lower()}_{secrets.token_urlsafe(32)}"
|
|
225
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
|
|
226
|
+
|
|
227
|
+
# Map APIRole to UserRole for API key storage
|
|
228
|
+
user_role_map = {
|
|
229
|
+
APIRole.OBSERVER: UserRole.OBSERVER,
|
|
230
|
+
APIRole.ADMIN: UserRole.ADMIN,
|
|
231
|
+
APIRole.AUTHORITY: UserRole.AUTHORITY,
|
|
232
|
+
APIRole.SYSTEM_ADMIN: UserRole.SYSTEM_ADMIN,
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# Store API key
|
|
236
|
+
auth_service.store_api_key(
|
|
237
|
+
key=api_key,
|
|
238
|
+
user_id=user.wa_id,
|
|
239
|
+
role=user_role_map[user.api_role],
|
|
240
|
+
expires_at=expires_at,
|
|
241
|
+
description="Login session",
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
logger.info(f"User {user.name} logged in successfully")
|
|
245
|
+
|
|
246
|
+
return LoginResponse(
|
|
247
|
+
access_token=api_key,
|
|
248
|
+
token_type="Bearer",
|
|
249
|
+
expires_in=86400, # 24 hours
|
|
250
|
+
role=user_role_map[user.api_role],
|
|
251
|
+
user_id=user.wa_id,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
256
|
+
async def logout(
|
|
257
|
+
auth: AuthContext = Depends(get_auth_context), auth_service: APIAuthService = Depends(get_auth_service)
|
|
258
|
+
) -> None:
|
|
259
|
+
"""
|
|
260
|
+
End the current session by revoking the API key.
|
|
261
|
+
|
|
262
|
+
This endpoint invalidates the current authentication token,
|
|
263
|
+
effectively logging out the user.
|
|
264
|
+
"""
|
|
265
|
+
if auth.api_key_id:
|
|
266
|
+
auth_service.revoke_api_key(auth.api_key_id)
|
|
267
|
+
# Don't log sensitive API key ID
|
|
268
|
+
logger.info(f"User {auth.user_id} logged out, API key revoked")
|
|
269
|
+
|
|
270
|
+
return None
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@router.get("/auth/me", response_model=UserInfo)
|
|
274
|
+
async def get_current_user(
|
|
275
|
+
auth: AuthContext = Depends(get_auth_context), auth_service: APIAuthService = Depends(get_auth_service)
|
|
276
|
+
) -> UserInfo:
|
|
277
|
+
"""
|
|
278
|
+
Get current authenticated user information.
|
|
279
|
+
|
|
280
|
+
Returns details about the currently authenticated user including
|
|
281
|
+
their role and all permissions based on that role.
|
|
282
|
+
"""
|
|
283
|
+
# Use permissions from the auth context which includes custom permissions
|
|
284
|
+
permissions = [p.value for p in auth.permissions]
|
|
285
|
+
|
|
286
|
+
# Fetch actual username from auth service
|
|
287
|
+
user = auth_service.get_user(auth.user_id)
|
|
288
|
+
username = user.name if user else auth.user_id # Fallback to user_id if not found
|
|
289
|
+
|
|
290
|
+
return UserInfo(
|
|
291
|
+
user_id=auth.user_id,
|
|
292
|
+
username=username,
|
|
293
|
+
role=auth.role,
|
|
294
|
+
permissions=permissions,
|
|
295
|
+
created_at=auth.authenticated_at, # Use auth time as proxy
|
|
296
|
+
last_login=auth.authenticated_at,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@router.post("/auth/refresh", response_model=LoginResponse)
|
|
301
|
+
async def refresh_token(
|
|
302
|
+
request: TokenRefreshRequest,
|
|
303
|
+
auth: Optional[AuthContext] = Depends(optional_auth),
|
|
304
|
+
auth_service: APIAuthService = Depends(get_auth_service),
|
|
305
|
+
) -> LoginResponse:
|
|
306
|
+
"""
|
|
307
|
+
Refresh access token.
|
|
308
|
+
|
|
309
|
+
Creates a new access token and revokes the old one. Supports both
|
|
310
|
+
API key and OAuth refresh flows. The user must be authenticated
|
|
311
|
+
to refresh their token.
|
|
312
|
+
"""
|
|
313
|
+
# For now, we require the user to be authenticated to refresh
|
|
314
|
+
# In a full implementation, we'd validate the refresh token separately
|
|
315
|
+
if not auth:
|
|
316
|
+
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required to refresh token")
|
|
317
|
+
|
|
318
|
+
# Generate new API key
|
|
319
|
+
new_api_key = f"ciris_{auth.role.value.lower()}_{secrets.token_urlsafe(32)}"
|
|
320
|
+
|
|
321
|
+
# Set expiration based on role
|
|
322
|
+
if auth.role == UserRole.SYSTEM_ADMIN:
|
|
323
|
+
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
|
|
324
|
+
expires_in = 86400 # 24 hours
|
|
325
|
+
else:
|
|
326
|
+
expires_at = datetime.now(timezone.utc) + timedelta(days=30)
|
|
327
|
+
expires_in = 2592000 # 30 days
|
|
328
|
+
|
|
329
|
+
# Store new API key
|
|
330
|
+
auth_service.store_api_key(
|
|
331
|
+
key=new_api_key, user_id=auth.user_id, role=auth.role, expires_at=expires_at, description="Refreshed token"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
# Revoke old API key if it exists
|
|
335
|
+
if auth.api_key_id:
|
|
336
|
+
auth_service.revoke_api_key(auth.api_key_id)
|
|
337
|
+
|
|
338
|
+
logger.info(f"Token refreshed for user {auth.user_id}")
|
|
339
|
+
|
|
340
|
+
return LoginResponse(
|
|
341
|
+
access_token=new_api_key, token_type="Bearer", expires_in=expires_in, role=auth.role, user_id=auth.user_id
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
# ========== OAuth Management Endpoints ==========
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class OAuthProviderInfo(BaseModel):
|
|
349
|
+
"""OAuth provider information."""
|
|
350
|
+
|
|
351
|
+
provider: str = Field(..., description=PROVIDER_NAME_DESC)
|
|
352
|
+
client_id: str = Field(..., description="OAuth client ID")
|
|
353
|
+
created: Optional[str] = Field(None, description="Creation timestamp")
|
|
354
|
+
callback_url: str = Field(..., description="OAuth callback URL")
|
|
355
|
+
metadata: Dict[str, str] = Field(default_factory=dict, description="Additional metadata")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class OAuthProvidersResponse(BaseModel):
|
|
359
|
+
"""OAuth providers list response."""
|
|
360
|
+
|
|
361
|
+
providers: List[OAuthProviderInfo] = Field(default_factory=list, description="List of configured providers")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@router.get("/auth/oauth/providers", response_model=OAuthProvidersResponse)
|
|
365
|
+
async def list_oauth_providers(
|
|
366
|
+
request: Request,
|
|
367
|
+
auth: AuthContext = Depends(get_auth_context),
|
|
368
|
+
_: None = Depends(check_permissions(["users.write"])), # SYSTEM_ADMIN only
|
|
369
|
+
) -> OAuthProvidersResponse:
|
|
370
|
+
"""
|
|
371
|
+
List configured OAuth providers.
|
|
372
|
+
|
|
373
|
+
Requires: users.write permission (SYSTEM_ADMIN only)
|
|
374
|
+
"""
|
|
375
|
+
import json
|
|
376
|
+
from pathlib import Path
|
|
377
|
+
|
|
378
|
+
# Check shared volume first (managed mode), then fall back to local (standalone)
|
|
379
|
+
oauth_config_file = OAUTH_CONFIG_PATH
|
|
380
|
+
if not oauth_config_file.exists():
|
|
381
|
+
oauth_config_file = Path.home() / OAUTH_CONFIG_DIR / OAUTH_CONFIG_FILE
|
|
382
|
+
logger.debug(f"Using local OAuth config: {oauth_config_file}")
|
|
383
|
+
else:
|
|
384
|
+
logger.debug(f"Using shared OAuth config: {oauth_config_file}")
|
|
385
|
+
|
|
386
|
+
if not oauth_config_file.exists():
|
|
387
|
+
return OAuthProvidersResponse(providers=[])
|
|
388
|
+
|
|
389
|
+
try:
|
|
390
|
+
config = json.loads(oauth_config_file.read_text())
|
|
391
|
+
providers = []
|
|
392
|
+
|
|
393
|
+
for provider, settings in config.items():
|
|
394
|
+
providers.append(
|
|
395
|
+
OAuthProviderInfo(
|
|
396
|
+
provider=provider,
|
|
397
|
+
client_id=settings.get("client_id", ""),
|
|
398
|
+
created=settings.get("created"),
|
|
399
|
+
callback_url=f"{request.headers.get('x-forwarded-proto', request.url.scheme)}://{request.headers.get('host', 'localhost')}{OAUTH_CALLBACK_PATH}",
|
|
400
|
+
metadata=settings.get("metadata", {}),
|
|
401
|
+
)
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return OAuthProvidersResponse(providers=providers)
|
|
405
|
+
except Exception as e:
|
|
406
|
+
logger.error(f"Failed to read OAuth config: {e}")
|
|
407
|
+
raise HTTPException(
|
|
408
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to read OAuth configuration"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class ConfigureOAuthProviderRequest(BaseModel):
|
|
413
|
+
"""Request to configure an OAuth provider."""
|
|
414
|
+
|
|
415
|
+
provider: str = Field(..., description=PROVIDER_NAME_DESC)
|
|
416
|
+
client_id: str = Field(..., description="OAuth client ID")
|
|
417
|
+
client_secret: str = Field(..., description="OAuth client secret")
|
|
418
|
+
metadata: Optional[Dict[str, str]] = Field(None, description="Additional metadata")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
class ConfigureOAuthProviderResponse(BaseModel):
|
|
422
|
+
"""Response from OAuth provider configuration."""
|
|
423
|
+
|
|
424
|
+
provider: str = Field(..., description=PROVIDER_NAME_DESC)
|
|
425
|
+
callback_url: str = Field(..., description="OAuth callback URL")
|
|
426
|
+
message: str = Field(..., description="Status message")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
@router.post("/auth/oauth/providers", response_model=ConfigureOAuthProviderResponse)
|
|
430
|
+
async def configure_oauth_provider(
|
|
431
|
+
body: ConfigureOAuthProviderRequest,
|
|
432
|
+
request: Request,
|
|
433
|
+
auth: AuthContext = Depends(get_auth_context),
|
|
434
|
+
_: None = Depends(check_permissions(["users.write"])), # SYSTEM_ADMIN only
|
|
435
|
+
) -> ConfigureOAuthProviderResponse:
|
|
436
|
+
"""
|
|
437
|
+
Configure an OAuth provider.
|
|
438
|
+
|
|
439
|
+
Requires: users.write permission (SYSTEM_ADMIN only)
|
|
440
|
+
"""
|
|
441
|
+
import json
|
|
442
|
+
from pathlib import Path
|
|
443
|
+
|
|
444
|
+
# Check shared volume first (managed mode), then fall back to local (standalone)
|
|
445
|
+
oauth_config_file = OAUTH_CONFIG_PATH
|
|
446
|
+
if not oauth_config_file.exists():
|
|
447
|
+
oauth_config_file = Path.home() / OAUTH_CONFIG_DIR / OAUTH_CONFIG_FILE
|
|
448
|
+
logger.debug(f"Using local OAuth config: {oauth_config_file}")
|
|
449
|
+
else:
|
|
450
|
+
logger.debug(f"Using shared OAuth config: {oauth_config_file}")
|
|
451
|
+
oauth_config_file.parent.mkdir(exist_ok=True, mode=0o700)
|
|
452
|
+
|
|
453
|
+
# Load existing config
|
|
454
|
+
config = {}
|
|
455
|
+
if oauth_config_file.exists():
|
|
456
|
+
try:
|
|
457
|
+
config = json.loads(oauth_config_file.read_text())
|
|
458
|
+
except (json.JSONDecodeError, IOError, OSError) as e:
|
|
459
|
+
logger.warning(f"Failed to load OAuth config file: {e}")
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
# Add/update provider
|
|
463
|
+
config[body.provider] = {
|
|
464
|
+
"client_id": body.client_id,
|
|
465
|
+
"client_secret": body.client_secret,
|
|
466
|
+
"created": datetime.now(timezone.utc).isoformat(),
|
|
467
|
+
"metadata": body.metadata or {},
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
# Save config
|
|
471
|
+
try:
|
|
472
|
+
oauth_config_file.write_text(json.dumps(config, indent=2))
|
|
473
|
+
oauth_config_file.chmod(0o600)
|
|
474
|
+
|
|
475
|
+
logger.info(f"OAuth provider '{body.provider}' configured by {auth.user_id}")
|
|
476
|
+
|
|
477
|
+
return ConfigureOAuthProviderResponse(
|
|
478
|
+
provider=body.provider,
|
|
479
|
+
callback_url=f"{request.headers.get('x-forwarded-proto', request.url.scheme)}://{request.headers.get('host', 'localhost')}{OAUTH_CALLBACK_PATH}",
|
|
480
|
+
message="OAuth provider configured successfully",
|
|
481
|
+
)
|
|
482
|
+
except Exception as e:
|
|
483
|
+
logger.error(f"Failed to save OAuth config: {e}")
|
|
484
|
+
raise HTTPException(
|
|
485
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save OAuth configuration"
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class OAuthLoginResponse(BaseModel):
|
|
490
|
+
"""OAuth login initiation response."""
|
|
491
|
+
|
|
492
|
+
authorization_url: str = Field(..., description="URL to redirect user to for authorization")
|
|
493
|
+
state: str = Field(..., description="State parameter for CSRF protection")
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
@router.get("/auth/oauth/{provider}/login")
|
|
497
|
+
async def oauth_login(provider: str, request: Request, redirect_uri: Optional[str] = None) -> RedirectResponse:
|
|
498
|
+
"""
|
|
499
|
+
Initiate OAuth login flow.
|
|
500
|
+
|
|
501
|
+
Redirects to the OAuth provider's authorization URL.
|
|
502
|
+
Accepts optional redirect_uri to specify where to send tokens after OAuth.
|
|
503
|
+
"""
|
|
504
|
+
import base64
|
|
505
|
+
import json
|
|
506
|
+
import urllib.parse
|
|
507
|
+
from pathlib import Path
|
|
508
|
+
|
|
509
|
+
# Check shared volume first (managed mode), then fall back to local (standalone)
|
|
510
|
+
oauth_config_file = OAUTH_CONFIG_PATH
|
|
511
|
+
if not oauth_config_file.exists():
|
|
512
|
+
oauth_config_file = Path.home() / OAUTH_CONFIG_DIR / OAUTH_CONFIG_FILE
|
|
513
|
+
logger.debug(f"Using local OAuth config: {oauth_config_file}")
|
|
514
|
+
else:
|
|
515
|
+
logger.debug(f"Using shared OAuth config: {oauth_config_file}")
|
|
516
|
+
|
|
517
|
+
if not oauth_config_file.exists():
|
|
518
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not configured")
|
|
519
|
+
|
|
520
|
+
try:
|
|
521
|
+
config = json.loads(oauth_config_file.read_text())
|
|
522
|
+
if provider not in config:
|
|
523
|
+
raise HTTPException(
|
|
524
|
+
status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not configured"
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
provider_config = config[provider]
|
|
528
|
+
client_id = provider_config["client_id"]
|
|
529
|
+
|
|
530
|
+
# Generate CSRF token
|
|
531
|
+
csrf_token = secrets.token_urlsafe(32)
|
|
532
|
+
|
|
533
|
+
# Validate redirect_uri to prevent open redirect attacks (security)
|
|
534
|
+
validated_redirect_uri = validate_redirect_uri(redirect_uri)
|
|
535
|
+
if redirect_uri and not validated_redirect_uri:
|
|
536
|
+
logger.warning(
|
|
537
|
+
f"OAuth login rejected untrusted redirect_uri from {request.client.host if request.client else 'unknown'}"
|
|
538
|
+
)
|
|
539
|
+
raise HTTPException(
|
|
540
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
541
|
+
detail="Invalid redirect_uri: must be a relative path or trusted domain",
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Encode state with CSRF token and optional redirect_uri
|
|
545
|
+
state_data = {"csrf": csrf_token}
|
|
546
|
+
if validated_redirect_uri:
|
|
547
|
+
state_data["redirect_uri"] = validated_redirect_uri
|
|
548
|
+
logger.info(f"OAuth login initiated with validated redirect_uri: {validated_redirect_uri}")
|
|
549
|
+
|
|
550
|
+
# Base64 encode the state JSON
|
|
551
|
+
state = base64.urlsafe_b64encode(json.dumps(state_data).encode()).decode()
|
|
552
|
+
|
|
553
|
+
# Use OAUTH_CALLBACK_BASE_URL environment variable, or construct from request
|
|
554
|
+
base_url = os.getenv("OAUTH_CALLBACK_BASE_URL")
|
|
555
|
+
if not base_url:
|
|
556
|
+
# Construct from request headers
|
|
557
|
+
base_url = f"{request.headers.get('x-forwarded-proto', request.url.scheme)}://{request.headers.get('host', 'localhost')}"
|
|
558
|
+
|
|
559
|
+
# Always use API callback URL for OAuth providers (this is what's registered in Google Console)
|
|
560
|
+
callback_url = get_oauth_callback_url(provider, base_url)
|
|
561
|
+
|
|
562
|
+
# Build authorization URL based on provider
|
|
563
|
+
if provider == "google":
|
|
564
|
+
auth_url = "https://accounts.google.com/o/oauth2/v2/auth"
|
|
565
|
+
params = {
|
|
566
|
+
"client_id": client_id,
|
|
567
|
+
"redirect_uri": callback_url,
|
|
568
|
+
"response_type": "code",
|
|
569
|
+
"scope": "openid email profile",
|
|
570
|
+
"state": state,
|
|
571
|
+
"access_type": "offline",
|
|
572
|
+
"prompt": "consent",
|
|
573
|
+
}
|
|
574
|
+
elif provider == "github":
|
|
575
|
+
auth_url = "https://github.com/login/oauth/authorize"
|
|
576
|
+
params = {
|
|
577
|
+
"client_id": client_id,
|
|
578
|
+
"redirect_uri": callback_url,
|
|
579
|
+
"scope": "read:user user:email",
|
|
580
|
+
"state": state,
|
|
581
|
+
}
|
|
582
|
+
elif provider == "discord":
|
|
583
|
+
auth_url = "https://discord.com/api/oauth2/authorize"
|
|
584
|
+
params = {
|
|
585
|
+
"client_id": client_id,
|
|
586
|
+
"redirect_uri": callback_url,
|
|
587
|
+
"response_type": "code",
|
|
588
|
+
"scope": "identify email",
|
|
589
|
+
"state": state,
|
|
590
|
+
}
|
|
591
|
+
else:
|
|
592
|
+
raise HTTPException(
|
|
593
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported OAuth provider: {provider}"
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
# Build full URL
|
|
597
|
+
full_url = f"{auth_url}?{urllib.parse.urlencode(params)}"
|
|
598
|
+
|
|
599
|
+
# Redirect user to OAuth provider
|
|
600
|
+
return RedirectResponse(url=full_url, status_code=302)
|
|
601
|
+
|
|
602
|
+
except Exception as e:
|
|
603
|
+
logger.error(f"OAuth login initiation failed: {e}")
|
|
604
|
+
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to initiate OAuth login")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _load_oauth_config(provider: str) -> Dict[str, str]:
|
|
608
|
+
"""Load OAuth configuration for the specified provider."""
|
|
609
|
+
import json
|
|
610
|
+
from pathlib import Path
|
|
611
|
+
|
|
612
|
+
# Check shared volume first (managed mode), then fall back to local (standalone)
|
|
613
|
+
oauth_config_file = OAUTH_CONFIG_PATH
|
|
614
|
+
if not oauth_config_file.exists():
|
|
615
|
+
oauth_config_file = Path.home() / OAUTH_CONFIG_DIR / OAUTH_CONFIG_FILE
|
|
616
|
+
logger.debug(f"Using local OAuth config: {oauth_config_file}")
|
|
617
|
+
else:
|
|
618
|
+
logger.debug(f"Using shared OAuth config: {oauth_config_file}")
|
|
619
|
+
|
|
620
|
+
if not oauth_config_file.exists():
|
|
621
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not configured")
|
|
622
|
+
|
|
623
|
+
config = json.loads(oauth_config_file.read_text())
|
|
624
|
+
if provider not in config:
|
|
625
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"OAuth provider '{provider}' not configured")
|
|
626
|
+
|
|
627
|
+
provider_config: Dict[str, str] = config[provider]
|
|
628
|
+
return provider_config
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
async def _handle_google_oauth(code: str, client_id: str, client_secret: str) -> Dict[str, Optional[str]]:
|
|
632
|
+
"""Handle Google OAuth token exchange and user info retrieval."""
|
|
633
|
+
import httpx
|
|
634
|
+
|
|
635
|
+
async with httpx.AsyncClient() as client:
|
|
636
|
+
# Exchange code for token
|
|
637
|
+
token_response = await client.post(
|
|
638
|
+
"https://oauth2.googleapis.com/token",
|
|
639
|
+
data={
|
|
640
|
+
"code": code,
|
|
641
|
+
"client_id": client_id,
|
|
642
|
+
"client_secret": client_secret,
|
|
643
|
+
"redirect_uri": get_oauth_callback_url("google"),
|
|
644
|
+
"grant_type": "authorization_code",
|
|
645
|
+
},
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
if token_response.status_code != 200:
|
|
649
|
+
raise HTTPException(
|
|
650
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
651
|
+
detail=f"Failed to exchange code for token: {token_response.text}",
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
token_data = token_response.json()
|
|
655
|
+
access_token = token_data["access_token"]
|
|
656
|
+
|
|
657
|
+
# Get user info
|
|
658
|
+
user_response = await client.get(
|
|
659
|
+
"https://www.googleapis.com/oauth2/v2/userinfo", headers={"Authorization": f"Bearer {access_token}"}
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
if user_response.status_code != 200:
|
|
663
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=FETCH_USER_INFO_ERROR)
|
|
664
|
+
|
|
665
|
+
user_info = user_response.json()
|
|
666
|
+
return {
|
|
667
|
+
"external_id": user_info["id"],
|
|
668
|
+
"email": user_info.get("email"),
|
|
669
|
+
"name": user_info.get("name", user_info.get("email")),
|
|
670
|
+
"picture": user_info.get("picture"),
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
async def _handle_github_oauth(code: str, client_id: str, client_secret: str) -> Dict[str, Optional[str]]:
|
|
675
|
+
"""Handle GitHub OAuth token exchange and user info retrieval."""
|
|
676
|
+
import httpx
|
|
677
|
+
|
|
678
|
+
async with httpx.AsyncClient() as client:
|
|
679
|
+
# Exchange code for token
|
|
680
|
+
token_response = await client.post(
|
|
681
|
+
"https://github.com/login/oauth/access_token",
|
|
682
|
+
headers={"Accept": "application/json"},
|
|
683
|
+
data={
|
|
684
|
+
"code": code,
|
|
685
|
+
"client_id": client_id,
|
|
686
|
+
"client_secret": client_secret,
|
|
687
|
+
"redirect_uri": os.getenv("OAUTH_CALLBACK_BASE_URL", DEFAULT_OAUTH_BASE_URL)
|
|
688
|
+
+ OAUTH_CALLBACK_PATH.replace("{provider}", "github"),
|
|
689
|
+
},
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
if token_response.status_code != 200:
|
|
693
|
+
raise HTTPException(
|
|
694
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
695
|
+
detail=f"Failed to exchange code for token: {token_response.text}",
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
token_data = token_response.json()
|
|
699
|
+
access_token = token_data["access_token"]
|
|
700
|
+
|
|
701
|
+
# Get user info
|
|
702
|
+
user_response = await client.get(
|
|
703
|
+
"https://api.github.com/user", headers={"Authorization": f"token {access_token}"}
|
|
704
|
+
)
|
|
705
|
+
|
|
706
|
+
if user_response.status_code != 200:
|
|
707
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=FETCH_USER_INFO_ERROR)
|
|
708
|
+
|
|
709
|
+
user_info = user_response.json()
|
|
710
|
+
external_id = str(user_info["id"])
|
|
711
|
+
email = user_info.get("email")
|
|
712
|
+
name = user_info.get("name", user_info.get("login"))
|
|
713
|
+
picture = user_info.get("avatar_url")
|
|
714
|
+
|
|
715
|
+
# If email is private, fetch from emails endpoint
|
|
716
|
+
if not email:
|
|
717
|
+
emails_response = await client.get(
|
|
718
|
+
"https://api.github.com/user/emails", headers={"Authorization": f"token {access_token}"}
|
|
719
|
+
)
|
|
720
|
+
if emails_response.status_code == 200:
|
|
721
|
+
emails = emails_response.json()
|
|
722
|
+
for e in emails:
|
|
723
|
+
if e.get("primary"):
|
|
724
|
+
email = e["email"]
|
|
725
|
+
break
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
"external_id": external_id,
|
|
729
|
+
"email": email,
|
|
730
|
+
"name": name,
|
|
731
|
+
"picture": picture,
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
async def _handle_discord_oauth(code: str, client_id: str, client_secret: str) -> Dict[str, Optional[str]]:
|
|
736
|
+
"""Handle Discord OAuth token exchange and user info retrieval."""
|
|
737
|
+
import httpx
|
|
738
|
+
|
|
739
|
+
async with httpx.AsyncClient() as client:
|
|
740
|
+
# Exchange code for token
|
|
741
|
+
token_response = await client.post(
|
|
742
|
+
"https://discord.com/api/oauth2/token",
|
|
743
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
744
|
+
data={
|
|
745
|
+
"code": code,
|
|
746
|
+
"client_id": client_id,
|
|
747
|
+
"client_secret": client_secret,
|
|
748
|
+
"redirect_uri": get_oauth_callback_url("discord"),
|
|
749
|
+
"grant_type": "authorization_code",
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
if token_response.status_code != 200:
|
|
754
|
+
raise HTTPException(
|
|
755
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
756
|
+
detail=f"Failed to exchange code for token: {token_response.text}",
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
token_data = token_response.json()
|
|
760
|
+
access_token = token_data["access_token"]
|
|
761
|
+
|
|
762
|
+
# Get user info
|
|
763
|
+
user_response = await client.get(
|
|
764
|
+
"https://discord.com/api/users/@me", headers={"Authorization": f"Bearer {access_token}"}
|
|
765
|
+
)
|
|
766
|
+
|
|
767
|
+
if user_response.status_code != 200:
|
|
768
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=FETCH_USER_INFO_ERROR)
|
|
769
|
+
|
|
770
|
+
user_info = user_response.json()
|
|
771
|
+
external_id = user_info["id"]
|
|
772
|
+
email = user_info.get("email")
|
|
773
|
+
name = user_info.get("username", email)
|
|
774
|
+
|
|
775
|
+
# Construct Discord avatar URL if avatar exists
|
|
776
|
+
avatar_hash = user_info.get("avatar")
|
|
777
|
+
picture = f"https://cdn.discordapp.com/avatars/{external_id}/{avatar_hash}.png" if avatar_hash else None
|
|
778
|
+
|
|
779
|
+
return {
|
|
780
|
+
"external_id": external_id,
|
|
781
|
+
"email": email,
|
|
782
|
+
"name": name,
|
|
783
|
+
"picture": picture,
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
# =============================================================================
|
|
788
|
+
# ROLE DETERMINATION HELPER FUNCTIONS (extracted for cognitive complexity reduction)
|
|
789
|
+
# =============================================================================
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _is_ciris_admin_email(email: Optional[str]) -> bool:
|
|
793
|
+
"""Check if the email is a @ciris.ai domain email (gets automatic ADMIN role)."""
|
|
794
|
+
return email is not None and email.endswith("@ciris.ai")
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _get_oauth_users_dict(auth_service: "APIAuthService") -> Optional[Dict[str, Any]]:
|
|
798
|
+
"""Get the _oauth_users dictionary from auth_service, or None if unavailable."""
|
|
799
|
+
return getattr(auth_service, "_oauth_users", None)
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
def _lookup_existing_user_role(oauth_users: Dict[str, Any], provider: str, external_id: str) -> Optional[UserRole]:
|
|
803
|
+
"""Look up an existing OAuth user and return their role if found.
|
|
804
|
+
|
|
805
|
+
Returns None if user not found.
|
|
806
|
+
"""
|
|
807
|
+
user_id = f"{provider}:{external_id}"
|
|
808
|
+
existing_user = oauth_users.get(user_id)
|
|
809
|
+
|
|
810
|
+
if not existing_user:
|
|
811
|
+
logger.debug(
|
|
812
|
+
f"[AUTH DEBUG] No existing OAuth user found for {user_id}"
|
|
813
|
+
) # NOSONAR - provider:id format, not secret
|
|
814
|
+
logger.debug(f"[AUTH DEBUG] Existing OAuth user count: {len(oauth_users)}")
|
|
815
|
+
return None
|
|
816
|
+
|
|
817
|
+
logger.debug(
|
|
818
|
+
f"[AUTH DEBUG] Found existing OAuth user: {user_id}, role={existing_user.role}"
|
|
819
|
+
) # NOSONAR - role is not sensitive
|
|
820
|
+
role = existing_user.role
|
|
821
|
+
if isinstance(role, UserRole):
|
|
822
|
+
return role
|
|
823
|
+
return UserRole(role) if role else UserRole.OBSERVER
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def _is_first_oauth_user(oauth_users: Optional[Dict[str, Any]]) -> bool:
|
|
827
|
+
"""Check if this would be the first OAuth user (empty oauth_users dict)."""
|
|
828
|
+
return oauth_users is not None and len(oauth_users) == 0
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _check_stored_user_role(auth_service: "APIAuthService", provider: str, external_id: str) -> Optional[UserRole]:
|
|
832
|
+
"""Check for existing user in _users dict and return their role if found."""
|
|
833
|
+
user_id = f"{provider}:{external_id}"
|
|
834
|
+
stored_users = getattr(auth_service, "_users", {})
|
|
835
|
+
stored_user = stored_users.get(user_id)
|
|
836
|
+
|
|
837
|
+
if not stored_user:
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
# User exists in database - preserve their role!
|
|
841
|
+
logger.debug( # NOSONAR - user_id is provider:id, roles are not sensitive
|
|
842
|
+
f"[AUTH DEBUG] Found existing user in _users dict: {user_id}, "
|
|
843
|
+
f"api_role={stored_user.api_role}, wa_role={stored_user.wa_role}"
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
# Convert APIRole to UserRole
|
|
847
|
+
api_role_to_user_role = {
|
|
848
|
+
"OBSERVER": UserRole.OBSERVER,
|
|
849
|
+
"ADMIN": UserRole.ADMIN,
|
|
850
|
+
"AUTHORITY": UserRole.ADMIN, # AUTHORITY maps to ADMIN
|
|
851
|
+
"SYSTEM_ADMIN": UserRole.SYSTEM_ADMIN,
|
|
852
|
+
"SERVICE_ACCOUNT": UserRole.SYSTEM_ADMIN, # Service accounts get full access
|
|
853
|
+
}
|
|
854
|
+
role_str = stored_user.api_role.value if hasattr(stored_user.api_role, "value") else str(stored_user.api_role)
|
|
855
|
+
existing_user_role = api_role_to_user_role.get(role_str.upper(), UserRole.OBSERVER)
|
|
856
|
+
logger.debug(f"[AUTH DEBUG] Mapped API role {role_str} to UserRole {existing_user_role}")
|
|
857
|
+
return existing_user_role
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _check_first_oauth_user_status(
|
|
861
|
+
auth_service: "APIAuthService", oauth_users: Optional[Dict[str, Any]], provider: str, external_id: Optional[str]
|
|
862
|
+
) -> bool:
|
|
863
|
+
"""Check if this is the first OAuth user (setup wizard scenario)."""
|
|
864
|
+
if not _is_first_oauth_user(oauth_users):
|
|
865
|
+
return False
|
|
866
|
+
|
|
867
|
+
# Only grant SYSTEM_ADMIN if BOTH oauth_users AND _users are empty for this OAuth identity
|
|
868
|
+
stored_users = getattr(auth_service, "_users", {})
|
|
869
|
+
user_id_check: Optional[str] = f"{provider}:{external_id}" if external_id else None
|
|
870
|
+
user_in_stored = user_id_check and user_id_check in stored_users
|
|
871
|
+
|
|
872
|
+
return not user_in_stored
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _determine_user_role(
|
|
876
|
+
email: Optional[str],
|
|
877
|
+
auth_service: Optional["APIAuthService"] = None,
|
|
878
|
+
external_id: Optional[str] = None,
|
|
879
|
+
provider: str = "google",
|
|
880
|
+
) -> UserRole:
|
|
881
|
+
"""Determine user role based on email domain, existing user status, and first-user status.
|
|
882
|
+
|
|
883
|
+
For Android/native OAuth flow during setup, the first OAuth user gets
|
|
884
|
+
SYSTEM_ADMIN role so they can see the default API channel history
|
|
885
|
+
where agent wakeup messages are sent.
|
|
886
|
+
|
|
887
|
+
IMPORTANT: If the user already exists with a higher role (e.g., from initial
|
|
888
|
+
login before setup), preserve that role instead of demoting to OBSERVER.
|
|
889
|
+
"""
|
|
890
|
+
masked_email = (email[:3] + "***@" + email.split("@")[-1]) if email and "@" in email else "None"
|
|
891
|
+
logger.debug(
|
|
892
|
+
f"[AUTH DEBUG] _determine_user_role called: email={masked_email}, external_id={external_id}, provider={provider}"
|
|
893
|
+
) # NOSONAR - email masked, external_id is provider ID
|
|
894
|
+
|
|
895
|
+
# @ciris.ai users always get ADMIN
|
|
896
|
+
if _is_ciris_admin_email(email):
|
|
897
|
+
logger.debug("[AUTH DEBUG] Granting ADMIN role to @ciris.ai user")
|
|
898
|
+
return UserRole.ADMIN
|
|
899
|
+
|
|
900
|
+
# No auth service - return default role
|
|
901
|
+
if auth_service is None:
|
|
902
|
+
logger.info("[AUTH DEBUG] No auth_service provided - returning OBSERVER role")
|
|
903
|
+
return UserRole.OBSERVER
|
|
904
|
+
|
|
905
|
+
try:
|
|
906
|
+
oauth_users = _get_oauth_users_dict(auth_service)
|
|
907
|
+
logger.info(f"[AUTH DEBUG] _oauth_users count: {len(oauth_users) if oauth_users else 'None'}")
|
|
908
|
+
|
|
909
|
+
# Check if this user already exists with a role - preserve their existing role
|
|
910
|
+
if external_id and oauth_users:
|
|
911
|
+
existing_role = _lookup_existing_user_role(oauth_users, provider, external_id)
|
|
912
|
+
if existing_role is not None:
|
|
913
|
+
return existing_role
|
|
914
|
+
|
|
915
|
+
# Check _users dict (for users loaded from database via OAuth link during setup)
|
|
916
|
+
if external_id:
|
|
917
|
+
stored_role = _check_stored_user_role(auth_service, provider, external_id)
|
|
918
|
+
if stored_role is not None:
|
|
919
|
+
return stored_role
|
|
920
|
+
|
|
921
|
+
# Check if this is the first OAuth user (setup wizard scenario)
|
|
922
|
+
if _check_first_oauth_user_status(auth_service, oauth_users, provider, external_id):
|
|
923
|
+
logger.info("[AUTH DEBUG] First OAuth user detected - granting SYSTEM_ADMIN role for setup wizard user")
|
|
924
|
+
return UserRole.SYSTEM_ADMIN
|
|
925
|
+
|
|
926
|
+
except (TypeError, AttributeError) as e:
|
|
927
|
+
# Mock objects or missing attributes - fall through to OBSERVER
|
|
928
|
+
logger.warning(f"[AUTH DEBUG] Exception accessing auth_service: {e}")
|
|
929
|
+
|
|
930
|
+
logger.info("[AUTH DEBUG] No special conditions met - returning OBSERVER role")
|
|
931
|
+
return UserRole.OBSERVER
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _store_oauth_profile(auth_service: APIAuthService, user_id: str, name: str, picture: Optional[str]) -> None:
|
|
935
|
+
"""Store OAuth profile data if valid."""
|
|
936
|
+
if not picture:
|
|
937
|
+
return
|
|
938
|
+
|
|
939
|
+
if validate_oauth_picture_url(picture):
|
|
940
|
+
user = auth_service.get_user(user_id)
|
|
941
|
+
if user:
|
|
942
|
+
user.oauth_name = name
|
|
943
|
+
user.oauth_picture = picture
|
|
944
|
+
auth_service._users[user_id] = user
|
|
945
|
+
else:
|
|
946
|
+
logger.warning(f"Invalid OAuth picture URL rejected for user {user_id}: {picture}")
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _update_billing_provider_token(google_id_token: str) -> None:
|
|
950
|
+
"""Update the billing provider with a fresh Google ID token.
|
|
951
|
+
|
|
952
|
+
This is called after native Google token exchange to ensure billing
|
|
953
|
+
is available immediately. The token is stored in the environment
|
|
954
|
+
so the billing provider can use it for credit checks.
|
|
955
|
+
"""
|
|
956
|
+
import os
|
|
957
|
+
|
|
958
|
+
# Update environment variable so billing provider can use it
|
|
959
|
+
os.environ["CIRIS_BILLING_GOOGLE_ID_TOKEN"] = google_id_token
|
|
960
|
+
logger.info("[NativeAuth] Updated CIRIS_BILLING_GOOGLE_ID_TOKEN in environment for billing provider")
|
|
961
|
+
|
|
962
|
+
# Try to reinitialize the billing provider if resource_monitor is available
|
|
963
|
+
# This is done via a background task to not block the login response
|
|
964
|
+
try:
|
|
965
|
+
from ciris_engine.logic.services.infrastructure.resource_monitor import CIRISBillingProvider
|
|
966
|
+
|
|
967
|
+
# Check if we have access to the app state (will be set by FastAPI)
|
|
968
|
+
# The billing provider will be initialized on the next credit check if not done here
|
|
969
|
+
logger.info("[NativeAuth] Billing provider token updated - will be used on next credit check")
|
|
970
|
+
except Exception as e:
|
|
971
|
+
logger.warning(f"[NativeAuth] Could not update billing provider directly: {e}")
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _generate_api_key_and_store(auth_service: APIAuthService, oauth_user: OAuthUser, provider: str) -> str:
|
|
975
|
+
"""Generate API key and store it for the OAuth user."""
|
|
976
|
+
# SYSTEM_ADMIN, ADMIN, and AUTHORITY all get admin prefix (elevated roles)
|
|
977
|
+
# OBSERVER gets observer prefix
|
|
978
|
+
elevated_roles = (UserRole.ADMIN, UserRole.SYSTEM_ADMIN, UserRole.AUTHORITY)
|
|
979
|
+
is_elevated = oauth_user.role in elevated_roles
|
|
980
|
+
role_prefix = "ciris_admin" if is_elevated else "ciris_observer"
|
|
981
|
+
logger.info(
|
|
982
|
+
f"[AUTH DEBUG] Generating API key for user {oauth_user.user_id} with role {oauth_user.role}, prefix: {role_prefix}"
|
|
983
|
+
)
|
|
984
|
+
api_key = f"{role_prefix}_{secrets.token_urlsafe(32)}"
|
|
985
|
+
expires_at = datetime.now(timezone.utc) + timedelta(days=30)
|
|
986
|
+
|
|
987
|
+
auth_service.store_api_key(
|
|
988
|
+
key=api_key,
|
|
989
|
+
user_id=oauth_user.user_id,
|
|
990
|
+
role=oauth_user.role,
|
|
991
|
+
expires_at=expires_at,
|
|
992
|
+
description=f"OAuth login via {provider}",
|
|
993
|
+
)
|
|
994
|
+
|
|
995
|
+
return api_key
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _build_redirect_response(
|
|
999
|
+
api_key: str,
|
|
1000
|
+
oauth_user: OAuthUser,
|
|
1001
|
+
provider: str,
|
|
1002
|
+
redirect_uri: Optional[str] = None,
|
|
1003
|
+
email: Optional[str] = None,
|
|
1004
|
+
marketing_opt_in: Optional[bool] = None,
|
|
1005
|
+
) -> RedirectResponse:
|
|
1006
|
+
"""
|
|
1007
|
+
Build the redirect response for OAuth callback.
|
|
1008
|
+
|
|
1009
|
+
Supports flexible parameter configuration via OAUTH_REDIRECT_PARAMS environment variable.
|
|
1010
|
+
|
|
1011
|
+
Args:
|
|
1012
|
+
api_key: Generated API key for the user
|
|
1013
|
+
oauth_user: OAuth user object with role and user_id
|
|
1014
|
+
provider: OAuth provider name (google, github, discord)
|
|
1015
|
+
redirect_uri: Optional redirect URI from state parameter
|
|
1016
|
+
email: User email from OAuth provider
|
|
1017
|
+
marketing_opt_in: Marketing opt-in preference from redirect_uri
|
|
1018
|
+
|
|
1019
|
+
Environment Variables:
|
|
1020
|
+
OAUTH_FRONTEND_URL: Frontend base URL (e.g., https://scout.ciris.ai)
|
|
1021
|
+
OAUTH_FRONTEND_PATH: Frontend callback path (default: /oauth-complete.html)
|
|
1022
|
+
OAUTH_REDIRECT_PARAMS: Comma-separated list of parameters to include in redirect
|
|
1023
|
+
"""
|
|
1024
|
+
import urllib.parse
|
|
1025
|
+
|
|
1026
|
+
VALID_PROVIDERS = {"google", "github", "discord"}
|
|
1027
|
+
if provider not in VALID_PROVIDERS:
|
|
1028
|
+
# Redirect to a safe default if provider is invalid
|
|
1029
|
+
return RedirectResponse(url="/", status_code=302)
|
|
1030
|
+
|
|
1031
|
+
# Build all available parameters
|
|
1032
|
+
all_params = {
|
|
1033
|
+
"access_token": api_key,
|
|
1034
|
+
"token_type": "Bearer",
|
|
1035
|
+
"expires_in": "2592000", # 30 days
|
|
1036
|
+
"role": oauth_user.role.value,
|
|
1037
|
+
"user_id": oauth_user.user_id,
|
|
1038
|
+
"email": email or "",
|
|
1039
|
+
"marketing_opt_in": str(marketing_opt_in).lower() if marketing_opt_in is not None else "",
|
|
1040
|
+
"agent": AGENT_ID,
|
|
1041
|
+
"provider": provider,
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
# Filter to only include configured parameters
|
|
1045
|
+
redirect_params = {k: v for k, v in all_params.items() if k in OAUTH_REDIRECT_PARAMS and v}
|
|
1046
|
+
|
|
1047
|
+
query_string = urllib.parse.urlencode(redirect_params)
|
|
1048
|
+
|
|
1049
|
+
# Determine redirect URL with priority:
|
|
1050
|
+
# 1. Explicit redirect_uri from state parameter (highest priority)
|
|
1051
|
+
# 2. OAUTH_FRONTEND_URL + OAUTH_FRONTEND_PATH
|
|
1052
|
+
# 3. Relative path fallback (backward compatibility)
|
|
1053
|
+
|
|
1054
|
+
if redirect_uri:
|
|
1055
|
+
# Parse existing query parameters from redirect_uri
|
|
1056
|
+
parsed = urllib.parse.urlparse(redirect_uri)
|
|
1057
|
+
base_redirect_uri = f"{parsed.scheme}://{parsed.netloc}{parsed.path}"
|
|
1058
|
+
|
|
1059
|
+
# Merge existing params with new params (new params take precedence for security)
|
|
1060
|
+
existing_params = dict(urllib.parse.parse_qsl(parsed.query))
|
|
1061
|
+
merged_params = {**existing_params, **redirect_params} # Server params override if conflict
|
|
1062
|
+
|
|
1063
|
+
query_string = urllib.parse.urlencode(merged_params)
|
|
1064
|
+
redirect_url = f"{base_redirect_uri}?{query_string}"
|
|
1065
|
+
logger.info(
|
|
1066
|
+
f"Redirecting OAuth user to provided redirect_uri with {len(existing_params)} existing params: {base_redirect_uri}"
|
|
1067
|
+
)
|
|
1068
|
+
elif OAUTH_FRONTEND_URL:
|
|
1069
|
+
# Use configured frontend URL
|
|
1070
|
+
redirect_url = f"{OAUTH_FRONTEND_URL}{OAUTH_FRONTEND_PATH}?{query_string}"
|
|
1071
|
+
logger.info(f"Redirecting OAuth user to configured frontend: {OAUTH_FRONTEND_URL}{OAUTH_FRONTEND_PATH}")
|
|
1072
|
+
else:
|
|
1073
|
+
# Backward compatibility: relative path
|
|
1074
|
+
gui_callback_url = f"/oauth/{AGENT_ID}/{provider}/callback"
|
|
1075
|
+
redirect_url = f"{gui_callback_url}?{query_string}"
|
|
1076
|
+
# Do NOT log the full redirect_url with sensitive credentials (access_token, api_key)
|
|
1077
|
+
logger.warning(
|
|
1078
|
+
f"No redirect_uri or OAUTH_FRONTEND_URL configured, using relative path: {gui_callback_url} "
|
|
1079
|
+
"(query params redacted for security)"
|
|
1080
|
+
)
|
|
1081
|
+
|
|
1082
|
+
return RedirectResponse(url=redirect_url, status_code=302)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
async def _trigger_billing_credit_check_if_enabled(
|
|
1086
|
+
request: Request,
|
|
1087
|
+
oauth_user: OAuthUser,
|
|
1088
|
+
user_email: Optional[str] = None,
|
|
1089
|
+
marketing_opt_in: Optional[bool] = None,
|
|
1090
|
+
) -> None:
|
|
1091
|
+
"""
|
|
1092
|
+
Trigger billing credit check if billing is enabled.
|
|
1093
|
+
|
|
1094
|
+
This ensures the billing user is created on first OAuth login so the frontend
|
|
1095
|
+
can display available credits immediately. Only runs if resource_monitor with
|
|
1096
|
+
credit_provider is configured.
|
|
1097
|
+
|
|
1098
|
+
Args:
|
|
1099
|
+
request: FastAPI request object
|
|
1100
|
+
oauth_user: OAuth user object with provider, external_id, user_id, role
|
|
1101
|
+
user_email: User email from OAuth provider (REQUIRED for billing backend)
|
|
1102
|
+
marketing_opt_in: Marketing opt-in preference (REQUIRED for billing backend)
|
|
1103
|
+
"""
|
|
1104
|
+
# Check if resource_monitor exists (billing may not be enabled)
|
|
1105
|
+
if not hasattr(request.app.state, "resource_monitor"):
|
|
1106
|
+
logger.debug("No resource_monitor configured - skipping billing credit check")
|
|
1107
|
+
return
|
|
1108
|
+
|
|
1109
|
+
resource_monitor = request.app.state.resource_monitor
|
|
1110
|
+
|
|
1111
|
+
# Check if credit provider is configured
|
|
1112
|
+
if not hasattr(resource_monitor, "credit_provider") or resource_monitor.credit_provider is None:
|
|
1113
|
+
logger.debug("No credit_provider configured - skipping billing credit check")
|
|
1114
|
+
return
|
|
1115
|
+
|
|
1116
|
+
# Perform credit check to ensure billing user is created
|
|
1117
|
+
try:
|
|
1118
|
+
from ciris_engine.schemas.services.credit_gate import CreditAccount, CreditContext
|
|
1119
|
+
|
|
1120
|
+
# Extract provider and external_id from oauth_user.user_id (format: "provider:external_id")
|
|
1121
|
+
oauth_provider = oauth_user.provider
|
|
1122
|
+
external_id = oauth_user.external_id
|
|
1123
|
+
|
|
1124
|
+
account = CreditAccount(
|
|
1125
|
+
provider=f"oauth:{oauth_provider}",
|
|
1126
|
+
account_id=external_id,
|
|
1127
|
+
authority_id=oauth_user.user_id,
|
|
1128
|
+
tenant_id=None,
|
|
1129
|
+
customer_email=user_email, # Pass email to billing backend
|
|
1130
|
+
marketing_opt_in=marketing_opt_in, # Pass marketing preference to billing backend
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
context = CreditContext(
|
|
1134
|
+
agent_id=AGENT_ID,
|
|
1135
|
+
channel_id="oauth:callback",
|
|
1136
|
+
request_id=None,
|
|
1137
|
+
user_role=oauth_user.role.value.lower(), # Pass user role to billing backend
|
|
1138
|
+
)
|
|
1139
|
+
|
|
1140
|
+
result = await resource_monitor.check_credit(account, context)
|
|
1141
|
+
|
|
1142
|
+
logger.info(
|
|
1143
|
+
f"Billing credit check for {oauth_user.user_id}: has_credit={result.has_credit}, "
|
|
1144
|
+
f"email={user_email}, marketing_opt_in={marketing_opt_in}, role={oauth_user.role.value}, "
|
|
1145
|
+
f"provider={resource_monitor.credit_provider.__class__.__name__}"
|
|
1146
|
+
)
|
|
1147
|
+
|
|
1148
|
+
except Exception as e:
|
|
1149
|
+
# Don't fail OAuth login if billing check fails
|
|
1150
|
+
logger.warning(f"Billing credit check failed for {oauth_user.user_id}: {e}")
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
@router.get("/auth/oauth/{provider}/callback")
|
|
1154
|
+
async def oauth_callback(
|
|
1155
|
+
provider: str,
|
|
1156
|
+
code: str,
|
|
1157
|
+
state: str,
|
|
1158
|
+
request: Request,
|
|
1159
|
+
auth_service: APIAuthService = Depends(get_auth_service),
|
|
1160
|
+
marketing_opt_in: bool = False,
|
|
1161
|
+
) -> RedirectResponse:
|
|
1162
|
+
"""
|
|
1163
|
+
Handle OAuth callback.
|
|
1164
|
+
|
|
1165
|
+
Exchanges authorization code for tokens and creates/updates user.
|
|
1166
|
+
Extracts marketing_opt_in from redirect_uri if present.
|
|
1167
|
+
"""
|
|
1168
|
+
try:
|
|
1169
|
+
# Decode state parameter to extract redirect_uri
|
|
1170
|
+
import base64
|
|
1171
|
+
import json
|
|
1172
|
+
|
|
1173
|
+
redirect_uri = None
|
|
1174
|
+
marketing_opt_in_from_uri = None
|
|
1175
|
+
|
|
1176
|
+
try:
|
|
1177
|
+
state_json = base64.urlsafe_b64decode(state.encode()).decode()
|
|
1178
|
+
state_data = json.loads(state_json)
|
|
1179
|
+
redirect_uri = state_data.get("redirect_uri")
|
|
1180
|
+
|
|
1181
|
+
# Defense-in-depth: Re-validate redirect_uri even from state
|
|
1182
|
+
# (state could theoretically be tampered with)
|
|
1183
|
+
redirect_uri = validate_redirect_uri(redirect_uri)
|
|
1184
|
+
|
|
1185
|
+
# Extract marketing_opt_in from redirect_uri query parameters
|
|
1186
|
+
if redirect_uri:
|
|
1187
|
+
uri_params = extract_query_params(redirect_uri)
|
|
1188
|
+
marketing_opt_in_str = uri_params.get("marketing_opt_in", "").lower()
|
|
1189
|
+
if marketing_opt_in_str in ("true", "1", "yes"):
|
|
1190
|
+
marketing_opt_in_from_uri = True
|
|
1191
|
+
elif marketing_opt_in_str in ("false", "0", "no"):
|
|
1192
|
+
marketing_opt_in_from_uri = False
|
|
1193
|
+
|
|
1194
|
+
logger.debug(f"Decoded state: redirect_uri={redirect_uri}, marketing_opt_in={marketing_opt_in_from_uri}")
|
|
1195
|
+
except Exception as e:
|
|
1196
|
+
# If state decode fails, log but continue (backward compatibility)
|
|
1197
|
+
logger.warning(f"Failed to decode state parameter: {e}. Using default redirect.")
|
|
1198
|
+
|
|
1199
|
+
# Use marketing_opt_in from redirect_uri if available, otherwise use query param
|
|
1200
|
+
final_marketing_opt_in = (
|
|
1201
|
+
marketing_opt_in_from_uri if marketing_opt_in_from_uri is not None else marketing_opt_in
|
|
1202
|
+
)
|
|
1203
|
+
|
|
1204
|
+
# Load OAuth configuration
|
|
1205
|
+
provider_config = _load_oauth_config(provider)
|
|
1206
|
+
client_id = provider_config["client_id"]
|
|
1207
|
+
client_secret = provider_config["client_secret"]
|
|
1208
|
+
|
|
1209
|
+
# Handle provider-specific OAuth flow
|
|
1210
|
+
if provider == "google":
|
|
1211
|
+
user_data = await _handle_google_oauth(code, client_id, client_secret)
|
|
1212
|
+
elif provider == "github":
|
|
1213
|
+
user_data = await _handle_github_oauth(code, client_id, client_secret)
|
|
1214
|
+
elif provider == "discord":
|
|
1215
|
+
user_data = await _handle_discord_oauth(code, client_id, client_secret)
|
|
1216
|
+
else:
|
|
1217
|
+
raise HTTPException(
|
|
1218
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unsupported OAuth provider: {provider}"
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
# Validate required fields first (need external_id for role determination)
|
|
1222
|
+
external_id = user_data["external_id"]
|
|
1223
|
+
if not external_id:
|
|
1224
|
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="OAuth provider did not return user ID")
|
|
1225
|
+
|
|
1226
|
+
# Determine user role (preserves existing role if user already exists)
|
|
1227
|
+
user_email = user_data["email"]
|
|
1228
|
+
user_role = _determine_user_role(user_email, auth_service, external_id=external_id, provider=provider)
|
|
1229
|
+
|
|
1230
|
+
oauth_user = auth_service.create_oauth_user(
|
|
1231
|
+
provider=provider,
|
|
1232
|
+
external_id=external_id,
|
|
1233
|
+
email=user_email,
|
|
1234
|
+
name=user_data["name"],
|
|
1235
|
+
role=user_role,
|
|
1236
|
+
marketing_opt_in=final_marketing_opt_in,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
# Store OAuth profile data
|
|
1240
|
+
name = user_data["name"] or "Unknown"
|
|
1241
|
+
_store_oauth_profile(auth_service, oauth_user.user_id, name, user_data["picture"])
|
|
1242
|
+
|
|
1243
|
+
# Generate API key and store it
|
|
1244
|
+
api_key = _generate_api_key_and_store(auth_service, oauth_user, provider)
|
|
1245
|
+
|
|
1246
|
+
logger.info(f"OAuth user {oauth_user.user_id} logged in successfully via {provider}")
|
|
1247
|
+
|
|
1248
|
+
# Trigger billing credit check if billing is enabled
|
|
1249
|
+
# This ensures billing user is created and credits are initialized
|
|
1250
|
+
# so the frontend can display available credits immediately
|
|
1251
|
+
await _trigger_billing_credit_check_if_enabled(
|
|
1252
|
+
request, oauth_user, user_email=user_email, marketing_opt_in=final_marketing_opt_in
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
# Build and return redirect response with email and marketing preference
|
|
1256
|
+
return _build_redirect_response(
|
|
1257
|
+
api_key=api_key,
|
|
1258
|
+
oauth_user=oauth_user,
|
|
1259
|
+
provider=provider,
|
|
1260
|
+
redirect_uri=redirect_uri,
|
|
1261
|
+
email=user_email,
|
|
1262
|
+
marketing_opt_in=final_marketing_opt_in,
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
except HTTPException:
|
|
1266
|
+
raise
|
|
1267
|
+
except Exception as e:
|
|
1268
|
+
logger.error(f"OAuth callback error: {e}")
|
|
1269
|
+
raise HTTPException(
|
|
1270
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"OAuth callback failed: {str(e)}"
|
|
1271
|
+
)
|
|
1272
|
+
|
|
1273
|
+
|
|
1274
|
+
# ========== Native App Token Exchange Endpoints ==========
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
class NativeTokenRequest(BaseModel):
|
|
1278
|
+
"""Request model for native app token exchange."""
|
|
1279
|
+
|
|
1280
|
+
id_token: str = Field(..., description="Google ID token from native Sign-In")
|
|
1281
|
+
provider: str = Field(default="google", description="OAuth provider (currently only 'google' supported)")
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
class NativeTokenResponse(BaseModel):
|
|
1285
|
+
"""Response model for native app token exchange."""
|
|
1286
|
+
|
|
1287
|
+
access_token: str
|
|
1288
|
+
token_type: str = "bearer"
|
|
1289
|
+
expires_in: int
|
|
1290
|
+
user_id: str
|
|
1291
|
+
role: str
|
|
1292
|
+
email: Optional[str] = None
|
|
1293
|
+
name: Optional[str] = None
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
# =============================================================================
|
|
1297
|
+
# TOKEN VERIFICATION HELPER FUNCTIONS (extracted for cognitive complexity reduction)
|
|
1298
|
+
# =============================================================================
|
|
1299
|
+
|
|
1300
|
+
# Valid Google issuers - constant
|
|
1301
|
+
VALID_GOOGLE_ISSUERS = {"accounts.google.com", "https://accounts.google.com"}
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
def _get_allowed_audiences_from_config() -> Optional[Set[str]]:
|
|
1305
|
+
"""Load allowed audiences from OAuth config.
|
|
1306
|
+
|
|
1307
|
+
Returns None if OAuth is not configured (on-device mode).
|
|
1308
|
+
On-device mode skips audience validation since the Android app
|
|
1309
|
+
has its own client ID and we can't know it ahead of time.
|
|
1310
|
+
"""
|
|
1311
|
+
try:
|
|
1312
|
+
provider_config = _load_oauth_config("google")
|
|
1313
|
+
expected_client_id = provider_config.get("client_id")
|
|
1314
|
+
android_client_id = provider_config.get("android_client_id")
|
|
1315
|
+
allowed_audiences: Set[str] = set()
|
|
1316
|
+
if expected_client_id:
|
|
1317
|
+
allowed_audiences.add(expected_client_id)
|
|
1318
|
+
if android_client_id:
|
|
1319
|
+
allowed_audiences.add(android_client_id)
|
|
1320
|
+
logger.info(
|
|
1321
|
+
f"[NativeAuth] Configured allowed audiences: {allowed_audiences}"
|
|
1322
|
+
) # NOSONAR - client IDs are public config
|
|
1323
|
+
return allowed_audiences if allowed_audiences else None
|
|
1324
|
+
except HTTPException:
|
|
1325
|
+
# On-device mode: OAuth not configured, skip audience validation
|
|
1326
|
+
logger.info("[NativeAuth] No OAuth config found - running in on-device mode, skipping audience validation")
|
|
1327
|
+
return None
|
|
1328
|
+
|
|
1329
|
+
|
|
1330
|
+
def _validate_token_audience(token_aud: Optional[str], allowed_audiences: Optional[Set[str]]) -> None:
|
|
1331
|
+
"""Validate token audience matches our configured client ID.
|
|
1332
|
+
|
|
1333
|
+
If allowed_audiences is None (on-device mode), validation is skipped.
|
|
1334
|
+
Raises HTTPException if validation fails.
|
|
1335
|
+
"""
|
|
1336
|
+
if allowed_audiences is None:
|
|
1337
|
+
# On-device mode: skip audience validation, just log the audience
|
|
1338
|
+
logger.info(f"[NativeAuth] On-device mode: skipping audience validation (aud: {token_aud})")
|
|
1339
|
+
return
|
|
1340
|
+
|
|
1341
|
+
if not token_aud or token_aud not in allowed_audiences:
|
|
1342
|
+
logger.error( # NOSONAR - security audit logging, client IDs are public config
|
|
1343
|
+
f"[NativeAuth] SECURITY: Token audience mismatch! "
|
|
1344
|
+
f"Got: {token_aud}, Expected one of: {allowed_audiences}"
|
|
1345
|
+
)
|
|
1346
|
+
raise HTTPException(
|
|
1347
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1348
|
+
detail="Token was not issued for this application (audience mismatch).",
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def _validate_token_issuer(token_iss: Optional[str]) -> None:
|
|
1353
|
+
"""Validate token issuer is Google.
|
|
1354
|
+
|
|
1355
|
+
Raises HTTPException if validation fails.
|
|
1356
|
+
"""
|
|
1357
|
+
if not token_iss or token_iss not in VALID_GOOGLE_ISSUERS:
|
|
1358
|
+
logger.error(f"[NativeAuth] SECURITY: Invalid issuer! Got: {token_iss}, Expected: {VALID_GOOGLE_ISSUERS}")
|
|
1359
|
+
raise HTTPException(
|
|
1360
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1361
|
+
detail="Token was not issued by Google (issuer mismatch).",
|
|
1362
|
+
)
|
|
1363
|
+
|
|
1364
|
+
|
|
1365
|
+
def _validate_token_expiry(token_exp: Optional[str]) -> None:
|
|
1366
|
+
"""Validate token is not expired.
|
|
1367
|
+
|
|
1368
|
+
Raises HTTPException if validation fails.
|
|
1369
|
+
"""
|
|
1370
|
+
import time
|
|
1371
|
+
|
|
1372
|
+
if not token_exp:
|
|
1373
|
+
return
|
|
1374
|
+
|
|
1375
|
+
try:
|
|
1376
|
+
exp_timestamp = int(token_exp)
|
|
1377
|
+
current_time = int(time.time())
|
|
1378
|
+
if exp_timestamp < current_time:
|
|
1379
|
+
logger.error(f"[NativeAuth] SECURITY: Token expired! exp: {exp_timestamp}, now: {current_time}")
|
|
1380
|
+
raise HTTPException(
|
|
1381
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1382
|
+
detail="Google ID token has expired. Please sign in again.",
|
|
1383
|
+
)
|
|
1384
|
+
except (ValueError, TypeError):
|
|
1385
|
+
logger.error(f"[NativeAuth] Invalid exp claim format: {token_exp}")
|
|
1386
|
+
raise HTTPException(
|
|
1387
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1388
|
+
detail="Token has invalid expiry format.",
|
|
1389
|
+
)
|
|
1390
|
+
|
|
1391
|
+
|
|
1392
|
+
def _validate_token_sub_claim(sub: Optional[str]) -> None:
|
|
1393
|
+
"""Validate that the sub (user ID) claim exists.
|
|
1394
|
+
|
|
1395
|
+
Raises HTTPException if validation fails.
|
|
1396
|
+
"""
|
|
1397
|
+
if not sub:
|
|
1398
|
+
logger.error("[NativeAuth] Token missing required 'sub' claim")
|
|
1399
|
+
raise HTTPException(
|
|
1400
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1401
|
+
detail="Google ID token missing user ID (sub claim).",
|
|
1402
|
+
)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
def _log_email_verification_warning(token_info: Dict[str, Any]) -> None:
|
|
1406
|
+
"""Log a warning if email is not verified."""
|
|
1407
|
+
email_verified = token_info.get("email_verified")
|
|
1408
|
+
if email_verified is not None and str(email_verified).lower() not in ("true", "1"):
|
|
1409
|
+
logger.warning(f"[NativeAuth] Email not verified for user {token_info.get('sub')}")
|
|
1410
|
+
|
|
1411
|
+
|
|
1412
|
+
async def _call_google_tokeninfo_api(id_token: str) -> Dict[str, Any]:
|
|
1413
|
+
"""Call Google's tokeninfo API and return the response JSON."""
|
|
1414
|
+
import httpx
|
|
1415
|
+
|
|
1416
|
+
logger.info("[NativeAuth] Calling Google tokeninfo API...")
|
|
1417
|
+
|
|
1418
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
|
1419
|
+
# Use params dict for proper URL encoding of the token
|
|
1420
|
+
response = await client.get("https://oauth2.googleapis.com/tokeninfo", params={"id_token": id_token})
|
|
1421
|
+
logger.info(f"[NativeAuth] Google tokeninfo response: {response.status_code}")
|
|
1422
|
+
|
|
1423
|
+
if response.status_code != 200:
|
|
1424
|
+
logger.error(f"[NativeAuth] Google API rejected token: {response.status_code} - {response.text}")
|
|
1425
|
+
raise HTTPException(
|
|
1426
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
1427
|
+
detail="Google could not verify this ID token. It may be expired, malformed, or invalid.",
|
|
1428
|
+
)
|
|
1429
|
+
|
|
1430
|
+
token_info: Dict[str, Any] = response.json()
|
|
1431
|
+
return token_info
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _validate_all_token_claims(token_info: Dict[str, Any], allowed_audiences: Optional[Set[str]]) -> None:
|
|
1435
|
+
"""Validate all required token claims (audience, issuer, expiry, sub)."""
|
|
1436
|
+
# SECURITY: Validate all token claims
|
|
1437
|
+
_validate_token_audience(token_info.get("aud"), allowed_audiences)
|
|
1438
|
+
_validate_token_issuer(token_info.get("iss"))
|
|
1439
|
+
_validate_token_expiry(token_info.get("exp"))
|
|
1440
|
+
_log_email_verification_warning(token_info)
|
|
1441
|
+
_validate_token_sub_claim(token_info.get("sub"))
|
|
1442
|
+
|
|
1443
|
+
|
|
1444
|
+
def _extract_user_info_from_token(token_info: Dict[str, Any]) -> Dict[str, Optional[str]]:
|
|
1445
|
+
"""Extract user information from validated token."""
|
|
1446
|
+
sub = token_info.get("sub")
|
|
1447
|
+
logger.info(f"[NativeAuth] Token VERIFIED successfully - sub: {sub}, email: {token_info.get('email')}")
|
|
1448
|
+
|
|
1449
|
+
return {
|
|
1450
|
+
"external_id": sub,
|
|
1451
|
+
"email": token_info.get("email"),
|
|
1452
|
+
"name": token_info.get("name"),
|
|
1453
|
+
"picture": token_info.get("picture"),
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
|
|
1457
|
+
async def _verify_google_id_token(id_token: str) -> Dict[str, Optional[str]]:
|
|
1458
|
+
"""
|
|
1459
|
+
Verify a Google ID token and extract user info.
|
|
1460
|
+
|
|
1461
|
+
This verifies tokens from native Android/iOS Google Sign-In using
|
|
1462
|
+
Google's tokeninfo API with full security validation:
|
|
1463
|
+
- Validates audience (aud) matches our configured client ID
|
|
1464
|
+
- Validates issuer (iss) is accounts.google.com
|
|
1465
|
+
- Validates token is not expired (exp)
|
|
1466
|
+
- Validates email is verified
|
|
1467
|
+
|
|
1468
|
+
SECURITY: No fallback path exists. Tokens MUST be verified by Google
|
|
1469
|
+
with proper audience/issuer/expiry validation before user creation.
|
|
1470
|
+
"""
|
|
1471
|
+
import httpx
|
|
1472
|
+
|
|
1473
|
+
logger.info(f"[NativeAuth] Verifying Google ID token (length: {len(id_token)}, prefix: {id_token[:20]}...)")
|
|
1474
|
+
|
|
1475
|
+
# Load our expected client ID from OAuth config
|
|
1476
|
+
allowed_audiences = _get_allowed_audiences_from_config()
|
|
1477
|
+
|
|
1478
|
+
# Verify with Google's tokeninfo endpoint
|
|
1479
|
+
try:
|
|
1480
|
+
token_info = await _call_google_tokeninfo_api(id_token)
|
|
1481
|
+
|
|
1482
|
+
logger.info(
|
|
1483
|
+
f"[NativeAuth] Token info received - sub: {token_info.get('sub')}, "
|
|
1484
|
+
f"email: {token_info.get('email')}, aud: {token_info.get('aud')}, "
|
|
1485
|
+
f"iss: {token_info.get('iss')}, exp: {token_info.get('exp')}"
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
# Validate all token claims
|
|
1489
|
+
_validate_all_token_claims(token_info, allowed_audiences)
|
|
1490
|
+
|
|
1491
|
+
# Extract and return user info
|
|
1492
|
+
return _extract_user_info_from_token(token_info)
|
|
1493
|
+
|
|
1494
|
+
except HTTPException:
|
|
1495
|
+
raise
|
|
1496
|
+
except httpx.TimeoutException:
|
|
1497
|
+
logger.error("[NativeAuth] Google tokeninfo API timed out")
|
|
1498
|
+
raise HTTPException(
|
|
1499
|
+
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
|
1500
|
+
detail="Google verification service timed out. Please try again.",
|
|
1501
|
+
)
|
|
1502
|
+
except httpx.RequestError as e:
|
|
1503
|
+
logger.error(f"[NativeAuth] Network error calling Google API: {type(e).__name__}: {e}")
|
|
1504
|
+
raise HTTPException(
|
|
1505
|
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
|
1506
|
+
detail="Could not reach Google verification service. Please check your connection.",
|
|
1507
|
+
)
|
|
1508
|
+
except Exception as e:
|
|
1509
|
+
logger.error(f"[NativeAuth] Unexpected error during token verification: {type(e).__name__}: {e}")
|
|
1510
|
+
raise HTTPException(
|
|
1511
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
1512
|
+
detail="Token verification failed due to an internal error.",
|
|
1513
|
+
)
|
|
1514
|
+
|
|
1515
|
+
|
|
1516
|
+
@router.post("/auth/native/google", response_model=NativeTokenResponse)
|
|
1517
|
+
async def native_google_token_exchange(
|
|
1518
|
+
request: NativeTokenRequest,
|
|
1519
|
+
auth_service: APIAuthService = Depends(get_auth_service),
|
|
1520
|
+
) -> NativeTokenResponse:
|
|
1521
|
+
"""
|
|
1522
|
+
Exchange a native Google ID token for a CIRIS API token.
|
|
1523
|
+
|
|
1524
|
+
This endpoint is used by native Android/iOS apps that perform Google Sign-In
|
|
1525
|
+
directly and need to exchange their Google ID token for a CIRIS API token.
|
|
1526
|
+
|
|
1527
|
+
Unlike the web OAuth flow (which uses authorization codes), native apps get
|
|
1528
|
+
ID tokens directly from Google Sign-In SDK and send them here.
|
|
1529
|
+
"""
|
|
1530
|
+
logger.info(f"[NativeAuth] Native Google token exchange request - provider: {request.provider}")
|
|
1531
|
+
|
|
1532
|
+
if request.provider != "google":
|
|
1533
|
+
logger.warning(f"[NativeAuth] Unsupported provider: {request.provider}")
|
|
1534
|
+
raise HTTPException(
|
|
1535
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
1536
|
+
detail="Only 'google' provider is currently supported for native token exchange",
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
try:
|
|
1540
|
+
# Verify the Google ID token and get user info
|
|
1541
|
+
logger.info("[NativeAuth] Starting token verification...")
|
|
1542
|
+
user_data = await _verify_google_id_token(request.id_token)
|
|
1543
|
+
logger.info(f"[NativeAuth] Token verification complete - external_id: {user_data.get('external_id')}")
|
|
1544
|
+
|
|
1545
|
+
external_id = user_data.get("external_id")
|
|
1546
|
+
if not external_id:
|
|
1547
|
+
logger.error("[NativeAuth] No external_id in user_data")
|
|
1548
|
+
raise HTTPException(
|
|
1549
|
+
status_code=status.HTTP_400_BAD_REQUEST, detail="Google ID token did not contain user ID"
|
|
1550
|
+
)
|
|
1551
|
+
|
|
1552
|
+
user_email = user_data.get("email")
|
|
1553
|
+
# Pass external_id to preserve existing user's role (don't demote on re-auth!)
|
|
1554
|
+
user_role = _determine_user_role(user_email, auth_service, external_id=external_id, provider="google")
|
|
1555
|
+
logger.info(f"[NativeAuth] Determined role for {user_email}: {user_role}")
|
|
1556
|
+
|
|
1557
|
+
# Check if this is the first OAuth user (for auto-minting)
|
|
1558
|
+
is_first_oauth_user = user_role == UserRole.SYSTEM_ADMIN
|
|
1559
|
+
|
|
1560
|
+
# Create or get OAuth user
|
|
1561
|
+
logger.info(f"[NativeAuth] Creating/getting OAuth user - external_id: {external_id}, email: {user_email}")
|
|
1562
|
+
oauth_user = auth_service.create_oauth_user(
|
|
1563
|
+
provider="google",
|
|
1564
|
+
external_id=external_id,
|
|
1565
|
+
email=user_email,
|
|
1566
|
+
name=user_data.get("name"),
|
|
1567
|
+
role=user_role,
|
|
1568
|
+
marketing_opt_in=False,
|
|
1569
|
+
)
|
|
1570
|
+
logger.info(f"[NativeAuth] OAuth user created/retrieved - user_id: {oauth_user.user_id}")
|
|
1571
|
+
|
|
1572
|
+
# Store OAuth profile data
|
|
1573
|
+
name = user_data.get("name") or "Unknown"
|
|
1574
|
+
_store_oauth_profile(auth_service, oauth_user.user_id, name, user_data.get("picture"))
|
|
1575
|
+
|
|
1576
|
+
# Auto-mint SYSTEM_ADMIN users as WA with ROOT role so they can handle deferrals
|
|
1577
|
+
# This handles both first-time users and existing users who weren't minted
|
|
1578
|
+
logger.info(
|
|
1579
|
+
f"CIRIS_USER_CREATE: [NativeAuth] Checking auto-mint for {oauth_user.user_id} with role {oauth_user.role}"
|
|
1580
|
+
)
|
|
1581
|
+
if oauth_user.role == UserRole.SYSTEM_ADMIN:
|
|
1582
|
+
# Check if user is already minted by looking up their user record
|
|
1583
|
+
existing_user = auth_service.get_user(oauth_user.user_id)
|
|
1584
|
+
logger.info(f"CIRIS_USER_CREATE: [NativeAuth] existing_user lookup: {existing_user}")
|
|
1585
|
+
if existing_user:
|
|
1586
|
+
logger.info(
|
|
1587
|
+
f"CIRIS_USER_CREATE: [NativeAuth] wa_id={existing_user.wa_id}, wa_role={existing_user.wa_role}"
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
needs_minting = not existing_user or not existing_user.wa_id or existing_user.wa_id == oauth_user.user_id
|
|
1591
|
+
|
|
1592
|
+
if needs_minting:
|
|
1593
|
+
logger.info(
|
|
1594
|
+
f"CIRIS_USER_CREATE: [NativeAuth] Auto-minting SYSTEM_ADMIN user {oauth_user.user_id} as WA with ROOT role"
|
|
1595
|
+
)
|
|
1596
|
+
try:
|
|
1597
|
+
from ciris_engine.schemas.services.authority_core import WARole
|
|
1598
|
+
|
|
1599
|
+
await auth_service.mint_wise_authority(
|
|
1600
|
+
user_id=oauth_user.user_id,
|
|
1601
|
+
wa_role=WARole.ROOT,
|
|
1602
|
+
minted_by="system_auto_mint",
|
|
1603
|
+
)
|
|
1604
|
+
logger.info(
|
|
1605
|
+
f"CIRIS_USER_CREATE: [NativeAuth] ✅ Successfully auto-minted {oauth_user.user_id} as ROOT WA"
|
|
1606
|
+
)
|
|
1607
|
+
except Exception as mint_error:
|
|
1608
|
+
# Don't fail login if minting fails - user can mint manually later
|
|
1609
|
+
logger.warning(
|
|
1610
|
+
f"CIRIS_USER_CREATE: [NativeAuth] Auto-mint failed (user can mint manually): {mint_error}"
|
|
1611
|
+
)
|
|
1612
|
+
else:
|
|
1613
|
+
logger.info(
|
|
1614
|
+
f"CIRIS_USER_CREATE: [NativeAuth] User {oauth_user.user_id} already minted as WA - skipping auto-mint"
|
|
1615
|
+
)
|
|
1616
|
+
else:
|
|
1617
|
+
logger.info(f"CIRIS_USER_CREATE: [NativeAuth] Not SYSTEM_ADMIN, skipping auto-mint")
|
|
1618
|
+
|
|
1619
|
+
# Generate API key
|
|
1620
|
+
logger.info(f"[NativeAuth] Generating API key for user {oauth_user.user_id}")
|
|
1621
|
+
api_key = _generate_api_key_and_store(auth_service, oauth_user, "google")
|
|
1622
|
+
|
|
1623
|
+
# Update billing provider with the Google ID token for credit checks
|
|
1624
|
+
# This ensures billing is available immediately after login
|
|
1625
|
+
_update_billing_provider_token(request.id_token)
|
|
1626
|
+
|
|
1627
|
+
logger.info(f"[NativeAuth] SUCCESS - Native Google user {oauth_user.user_id} logged in, token generated")
|
|
1628
|
+
|
|
1629
|
+
return NativeTokenResponse(
|
|
1630
|
+
access_token=api_key,
|
|
1631
|
+
token_type="bearer",
|
|
1632
|
+
expires_in=2592000, # 30 days in seconds
|
|
1633
|
+
user_id=oauth_user.user_id,
|
|
1634
|
+
role=oauth_user.role.value,
|
|
1635
|
+
email=user_email,
|
|
1636
|
+
name=user_data.get("name"),
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
except HTTPException as e:
|
|
1640
|
+
logger.error(f"[NativeAuth] HTTP error: {e.status_code} - {e.detail}")
|
|
1641
|
+
raise
|
|
1642
|
+
except Exception as e:
|
|
1643
|
+
logger.error(f"[NativeAuth] Unexpected error: {type(e).__name__}: {e}", exc_info=True)
|
|
1644
|
+
raise HTTPException(
|
|
1645
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Native token exchange failed: {str(e)}"
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
# ========== API Key Management Endpoints ==========
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
@router.post("/auth/api-keys", response_model=APIKeyResponse)
|
|
1653
|
+
async def create_api_key(
|
|
1654
|
+
request: APIKeyCreateRequest,
|
|
1655
|
+
auth: AuthContext = Depends(get_auth_context),
|
|
1656
|
+
auth_service: APIAuthService = Depends(get_auth_service),
|
|
1657
|
+
) -> APIKeyResponse:
|
|
1658
|
+
"""
|
|
1659
|
+
Create a new API key for the authenticated user.
|
|
1660
|
+
|
|
1661
|
+
Users can create API keys for their OAuth identity with configurable expiry (30min - 7 days).
|
|
1662
|
+
The key is only shown once and cannot be retrieved later.
|
|
1663
|
+
"""
|
|
1664
|
+
# Calculate expiration based on minutes
|
|
1665
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=request.expires_in_minutes)
|
|
1666
|
+
|
|
1667
|
+
# Generate API key with user's current role
|
|
1668
|
+
api_key = f"ciris_{auth.role.value.lower()}_{secrets.token_urlsafe(32)}"
|
|
1669
|
+
|
|
1670
|
+
# Store API key
|
|
1671
|
+
auth_service.store_api_key(
|
|
1672
|
+
key=api_key,
|
|
1673
|
+
user_id=auth.user_id,
|
|
1674
|
+
role=auth.role,
|
|
1675
|
+
expires_at=expires_at,
|
|
1676
|
+
description=request.description,
|
|
1677
|
+
created_by=auth.user_id,
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
logger.info(f"User {auth.user_id} created API key with {request.expires_in_minutes}min expiry")
|
|
1681
|
+
|
|
1682
|
+
return APIKeyResponse(
|
|
1683
|
+
api_key=api_key,
|
|
1684
|
+
role=auth.role,
|
|
1685
|
+
expires_at=expires_at,
|
|
1686
|
+
description=request.description,
|
|
1687
|
+
created_at=datetime.now(timezone.utc),
|
|
1688
|
+
created_by=auth.user_id,
|
|
1689
|
+
)
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
@router.get("/auth/api-keys", response_model=APIKeyListResponse)
|
|
1693
|
+
async def list_api_keys(
|
|
1694
|
+
auth: AuthContext = Depends(get_auth_context), auth_service: APIAuthService = Depends(get_auth_service)
|
|
1695
|
+
) -> APIKeyListResponse:
|
|
1696
|
+
"""
|
|
1697
|
+
List all API keys for the authenticated user.
|
|
1698
|
+
|
|
1699
|
+
Returns information about all API keys created by the user (excluding the actual key values).
|
|
1700
|
+
"""
|
|
1701
|
+
# Get all keys for this user
|
|
1702
|
+
stored_keys = auth_service.list_user_api_keys(auth.user_id)
|
|
1703
|
+
|
|
1704
|
+
# Convert to response format
|
|
1705
|
+
api_keys = [
|
|
1706
|
+
APIKeyInfo(
|
|
1707
|
+
key_id=key.key_id,
|
|
1708
|
+
role=key.role,
|
|
1709
|
+
expires_at=key.expires_at,
|
|
1710
|
+
description=key.description,
|
|
1711
|
+
created_at=key.created_at,
|
|
1712
|
+
created_by=key.created_by,
|
|
1713
|
+
last_used=key.last_used,
|
|
1714
|
+
is_active=key.is_active,
|
|
1715
|
+
)
|
|
1716
|
+
for key in stored_keys
|
|
1717
|
+
]
|
|
1718
|
+
|
|
1719
|
+
return APIKeyListResponse(api_keys=api_keys, total=len(api_keys))
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
@router.delete("/auth/api-keys/{key_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
1723
|
+
async def delete_api_key(
|
|
1724
|
+
key_id: str,
|
|
1725
|
+
auth: AuthContext = Depends(get_auth_context),
|
|
1726
|
+
auth_service: APIAuthService = Depends(get_auth_service),
|
|
1727
|
+
) -> None:
|
|
1728
|
+
"""
|
|
1729
|
+
Delete an API key.
|
|
1730
|
+
|
|
1731
|
+
Users can only delete their own API keys.
|
|
1732
|
+
"""
|
|
1733
|
+
# Get the key to verify ownership
|
|
1734
|
+
all_keys = auth_service.list_user_api_keys(auth.user_id)
|
|
1735
|
+
key_to_delete = next((k for k in all_keys if k.key_id == key_id), None)
|
|
1736
|
+
|
|
1737
|
+
if not key_to_delete:
|
|
1738
|
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API key not found")
|
|
1739
|
+
|
|
1740
|
+
# Revoke the key
|
|
1741
|
+
auth_service.revoke_api_key(key_id)
|
|
1742
|
+
|
|
1743
|
+
logger.info(f"User {auth.user_id} deleted API key {key_id}")
|
|
1744
|
+
|
|
1745
|
+
return None
|