diff --git a/.git-hooks-binary-allowlist b/.git-hooks-binary-allowlist index 47d5561bf..4dad983f8 100644 --- a/.git-hooks-binary-allowlist +++ b/.git-hooks-binary-allowlist @@ -65,3 +65,7 @@ assets/screenshots/auto-approve/demo.mp4 assets/screenshots/auto-approve/poster.webp docs/releases/assets/v1.1.0-audit-log.png + +assets/screenshots/2026-10-29/01-settings-sso-oidc.png +assets/screenshots/2026-10-29/02-settings-sso-saml.png +assets/screenshots/2026-10-29/03-login-sso-button.png diff --git a/.github/workflows/livereview-gate.yml b/.github/workflows/livereview-gate.yml deleted file mode 100644 index 559a475cd..000000000 --- a/.github/workflows/livereview-gate.yml +++ /dev/null @@ -1,84 +0,0 @@ -name: LiveReview Gate - -on: - pull_request: - types: [opened, synchronize, reopened] - -# This job only calls the LiveReview API via curl -- it never checks out -# the repo or writes back to it, so GITHUB_TOKEN needs no permissions. -permissions: {} - -jobs: - livereview-gate: - runs-on: ubuntu-24.04 - steps: - - name: Evaluate LiveReview CI/CD ruleset - env: - LIVEREVIEW_API_KEY: ${{ secrets.LIVEREVIEW_API_KEY }} - ORG_ID: ${{ secrets.LIVEREVIEW_ORG_ID }} - RULESET_ID: ${{ secrets.LIVEREVIEW_RULESET_ID }} - BASE_URL: https://livereview.hexmos.com - PR_URL: ${{ github.event.pull_request.html_url }} - HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - echo "Triggering LiveReview review for $PR_URL (commit $HEAD_SHA)" - TRIGGER_RESP=$(curl -sf -H "X-API-Key: $LIVEREVIEW_API_KEY" -H "X-Org-Context: $ORG_ID" \ - -H "Content-Type: application/json" \ - -d "{\"url\":\"$PR_URL\"}" \ - "$BASE_URL/api/v1/connectors/trigger-review") - REVIEW_ID=$(echo "$TRIGGER_RESP" | jq -r '.reviewId // empty') - if [ -z "$REVIEW_ID" ]; then - echo "Failed to trigger a LiveReview review: $TRIGGER_RESP" - exit 1 - fi - echo "Triggered review $REVIEW_ID" - - # Poll the SPECIFIC review we just triggered -- not a commit-SHA - # lookup, which would silently reuse a different PR's already- - # completed review of the same commit (e.g. a branch created - # without new commits) instead of this PR's own review. - # 90 attempts x 20s = 30 minutes, enough headroom for a large PR's diff. - MAX_ATTEMPTS=90 - SLEEP_SECONDS=20 - echo "Waiting for review $REVIEW_ID to complete and evaluating the gate..." - for attempt in $(seq 1 "$MAX_ATTEMPTS"); do - RESP=$(curl -s -w '\n%{http_code}' -H "X-API-Key: $LIVEREVIEW_API_KEY" -H "X-Org-Context: $ORG_ID" \ - "$BASE_URL/api/v1/ci-rulesets/$RULESET_ID/evaluate?review_id=$REVIEW_ID") - CODE=$(echo "$RESP" | tail -1) - BODY=$(echo "$RESP" | sed '$d') - - case "$CODE" in - 200) - echo "LiveReview: ALLOW" - echo "$BODY" - exit 0 - ;; - 422) - echo "LiveReview: BLOCK -- a critical or security finding was found" - echo "$BODY" - exit 1 - ;; - 202) - echo " [$attempt/$MAX_ATTEMPTS] review $REVIEW_ID still running, retrying in ${SLEEP_SECONDS}s..." - ;; - 401|403) - echo "LiveReview: authentication failed (HTTP $CODE) -- check the LIVEREVIEW_API_KEY/LIVEREVIEW_ORG_ID secrets" - echo "$BODY" - exit 1 - ;; - 404) - echo "LiveReview: ruleset or review not found (HTTP 404) -- check the LIVEREVIEW_RULESET_ID secret" - echo "$BODY" - exit 1 - ;; - *) - echo " [$attempt/$MAX_ATTEMPTS] unexpected evaluate response ($CODE): $BODY" - ;; - esac - sleep "$SLEEP_SECONDS" - done - - echo "Timed out waiting for the LiveReview review to complete." - exit 1 diff --git a/Makefile b/Makefile index daef9c394..036875566 100644 --- a/Makefile +++ b/Makefile @@ -379,7 +379,7 @@ TEST_PACKAGES := $(shell find . \ SECURITY_GOVULN_PACKAGES := $(filter-out ./scripts ./cmd/onboarding-pdf,$(TEST_PACKAGES)) # Tests and packages that fail today, skipped until fixed. Remove a name once its test passes. -TESTALL_SKIP_TESTS := ^(TestAnalyticsPipelineFinalizeGivesUpAfterRepairFails|TestAnalyticsPipelineProducesExactCounts|TestAnalyticsPipelineProducesFacetedChart|TestAnalyticsPipelineProducesCSV|TestAnalyticsPipelineRepairsThenGivesUp|TestAnalyticsPipelineZeroRowsShortCircuits|TestBitbucketCommentHierarchyFromCapture|TestBuildFileCommentIndex|TestBuildFileCommentTree|TestChatBranchRejectsFabricatedJSON|TestCheckRequiredConfigCloudLiveLowPricingProfileRequiresLowPricingPlanIDs|TestCheckRequiredConfigCloudLiveModeRequiresLiveVars|TestCheckRequiredConfigCloudTestModeRequiresRazorpayTestVars|TestConvertAzureDevOpsCommentEvent_RealPayload|TestCountQueryBranchRejectsTruncatedJSON|TestCreateMonthlyPlan|TestDispatchClassifyFailureDegradesToChat|TestDispatchSwapsSystemPromptPerTurn|TestLawbookCarriesMachineContracts|TestListPaths|TestPostCommentReply_IssueCommentUsesIssueEndpoint|TestReconciliationSweepWorker_OnlyQueuesStaleRepositories|TestRoundTripUnifiedArtifact|TestShowCommentsPerFile|TestShowCommentsPerFileTree|TestShowCommentsPerFileTreeBitbucket|TestShowCommentsPerFileTreeGithub|TestShowCommentsPerFileTreeGitlab|TestSubscriptionIntegration|TestWebhookProcessing)$$ +TESTALL_SKIP_TESTS := ^(TestAnalyticsPipelineFinalizeGivesUpAfterRepairFails|TestAnalyticsPipelineProducesExactCounts|TestAnalyticsPipelineProducesFacetedChart|TestAnalyticsPipelineProducesCSV|TestAnalyticsPipelineRepairsThenGivesUp|TestAnalyticsPipelineZeroRowsShortCircuits|TestBitbucketCommentHierarchyFromCapture|TestChatBranchRejectsFabricatedJSON|TestCheckRequiredConfigCloudLiveLowPricingProfileRequiresLowPricingPlanIDs|TestCheckRequiredConfigCloudLiveModeRequiresLiveVars|TestCheckRequiredConfigCloudTestModeRequiresRazorpayTestVars|TestConvertAzureDevOpsCommentEvent_RealPayload|TestCountQueryBranchRejectsTruncatedJSON|TestCreateMonthlyPlan|TestDispatchClassifyFailureDegradesToChat|TestDispatchSwapsSystemPromptPerTurn|TestLawbookCarriesMachineContracts|TestPostCommentReply_IssueCommentUsesIssueEndpoint|TestReconciliationSweepWorker_OnlyQueuesStaleRepositories|TestSubscriptionIntegration|TestWebhookProcessing)$$ TESTALL_SKIP_PKGS := . ./scripts ./cmd/onboarding-pdf ./internal/license/payment .PHONY: testall @@ -390,6 +390,22 @@ testall: regen-risk-report: python3 scripts/regen_risk_report.py +# Local/CI end-to-end SSO test against a real Keycloak (OIDC + SAML, see +# docs/sso.md). Brings up Keycloak with the checked-in realm fixture, +# runs the headless cookie-jar e2e test, tears the container down either +# way (the `|| true` + explicit `down` guarantees cleanup even on failure). +.PHONY: test-sso-e2e +test-sso-e2e: + docker compose -f docker/docker-compose.sso-test.yml up -d keycloak + @echo "Waiting for Keycloak to become healthy..." + @i=0; until docker compose -f docker/docker-compose.sso-test.yml ps keycloak --format json | grep -q "\"Health\":\"healthy\""; do \ + i=$$((i + 2)); \ + if [ $$i -ge 120 ]; then echo "Keycloak did not become healthy within 120s"; exit 1; fi; \ + sleep 2; \ + done + $(GOTEST) -tags=sso_e2e -count=1 -v ./internal/sso/... || (docker compose -f docker/docker-compose.sso-test.yml down -v; exit 1) + docker compose -f docker/docker-compose.sso-test.yml down -v + .PHONY: security-govulncheck security-govulncheck-json security-osv security-gitleaks security-semgrep security-dependabot security-gh-secret-scanning security-triage # Run Go vulnerability analysis for reachable vulnerabilities. diff --git a/assets/screenshots/2026-10-29/01-settings-sso-oidc.png b/assets/screenshots/2026-10-29/01-settings-sso-oidc.png new file mode 100644 index 000000000..7f7a38d7b Binary files /dev/null and b/assets/screenshots/2026-10-29/01-settings-sso-oidc.png differ diff --git a/assets/screenshots/2026-10-29/02-settings-sso-saml.png b/assets/screenshots/2026-10-29/02-settings-sso-saml.png new file mode 100644 index 000000000..38f0ddf1c Binary files /dev/null and b/assets/screenshots/2026-10-29/02-settings-sso-saml.png differ diff --git a/assets/screenshots/2026-10-29/03-login-sso-button.png b/assets/screenshots/2026-10-29/03-login-sso-button.png new file mode 100644 index 000000000..3d405ae22 Binary files /dev/null and b/assets/screenshots/2026-10-29/03-login-sso-button.png differ diff --git a/scripts/backfill_blast_radius_hunks.go b/cmd/backfill-blast-radius-hunks/main.go similarity index 98% rename from scripts/backfill_blast_radius_hunks.go rename to cmd/backfill-blast-radius-hunks/main.go index e723a40fe..5cd0b3484 100644 --- a/scripts/backfill_blast_radius_hunks.go +++ b/cmd/backfill-blast-radius-hunks/main.go @@ -10,7 +10,7 @@ // whichever backend internal/blobstore is configured for (S3, GCS, Azure, // local filesystem), not just S3. // -// Usage: go run scripts/backfill_blast_radius_hunks.go +// Usage: go run ./cmd/backfill-blast-radius-hunks package main import ( diff --git a/scripts/backfill_chat_chart_stats.go b/cmd/backfill-chat-chart-stats/main.go similarity index 97% rename from scripts/backfill_chat_chart_stats.go rename to cmd/backfill-chat-chart-stats/main.go index c426134e9..b171d8108 100644 --- a/scripts/backfill_chat_chart_stats.go +++ b/cmd/backfill-chat-chart-stats/main.go @@ -5,7 +5,7 @@ // shape chatstats doesn't recognize, which stays NULL on purpose) can be // resumed by running it again. // -// Usage: go run scripts/backfill_chat_chart_stats.go +// Usage: go run ./cmd/backfill-chat-chart-stats package main import ( diff --git a/scripts/create_live_plans.go b/cmd/create-live-plans/main.go similarity index 100% rename from scripts/create_live_plans.go rename to cmd/create-live-plans/main.go diff --git a/scripts/create_test_plans.go b/cmd/create-test-plans/main.go similarity index 100% rename from scripts/create_test_plans.go rename to cmd/create-test-plans/main.go diff --git a/cmd/mrmodel/lib/mrmodel_batch_test.go b/cmd/mrmodel/lib/mrmodel_batch_test.go index 6c8b2a338..28e1eb47f 100644 --- a/cmd/mrmodel/lib/mrmodel_batch_test.go +++ b/cmd/mrmodel/lib/mrmodel_batch_test.go @@ -8,7 +8,7 @@ import ( // TestListPaths tests loading a unified artifact and listing its diff paths func TestListPaths(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -34,7 +34,7 @@ func TestListPaths(t *testing.T) { // TestShowCommentsPerFile tests showing comments organized by file func TestShowCommentsPerFile(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -75,7 +75,7 @@ func TestShowCommentsPerFile(t *testing.T) { // TestBuildFileCommentIndex tests building the efficient file->comments index func TestBuildFileCommentIndex(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -115,7 +115,7 @@ func TestBuildFileCommentIndex(t *testing.T) { // TestBuildFileCommentTree tests building the tree structure per file func TestBuildFileCommentTree(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -158,7 +158,7 @@ func TestBuildFileCommentTree(t *testing.T) { // TestShowCommentsPerFileTree tests showing the tree structure func TestShowCommentsPerFileTree(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -181,7 +181,7 @@ func TestShowCommentsPerFileTree(t *testing.T) { // TestShowCommentsPerFileTreeBitbucket tests with Bitbucket data that has threaded comments func TestShowCommentsPerFileTreeBitbucket(t *testing.T) { - artifactPath := "../../../artifacts/bb_unified.json" + artifactPath := "../../../docs/raw/artifacts/bb_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -230,7 +230,7 @@ func TestShowCommentsPerFileTreeBitbucket(t *testing.T) { // TestShowCommentsPerFileTreeGitlab tests with GitLab data func TestShowCommentsPerFileTreeGitlab(t *testing.T) { - artifactPath := "../../../artifacts/gl_unified.json" + artifactPath := "../../../docs/raw/artifacts/gl_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) @@ -280,7 +280,7 @@ func TestShowCommentsPerFileTreeGitlab(t *testing.T) { // TestShowCommentsPerFileTreeGithub tests with GitHub data func TestShowCommentsPerFileTreeGithub(t *testing.T) { - artifactPath := "../../../artifacts/gh_unified.json" + artifactPath := "../../../docs/raw/artifacts/gh_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) diff --git a/cmd/mrmodel/lib/roundtrip_test.go b/cmd/mrmodel/lib/roundtrip_test.go index 2120d5536..07d1412ca 100644 --- a/cmd/mrmodel/lib/roundtrip_test.go +++ b/cmd/mrmodel/lib/roundtrip_test.go @@ -10,7 +10,7 @@ import ( // TestRoundTripUnifiedArtifact tests loading a saved UnifiedArtifact JSON back into the struct func TestRoundTripUnifiedArtifact(t *testing.T) { - artifactPath := "../../../artifacts/gh_unified.json" + artifactPath := "../../../docs/raw/artifacts/gh_unified.json" // Read the JSON file data, err := os.ReadFile(artifactPath) diff --git a/cmd/test/final_test.go b/cmd/test/final_test.go deleted file mode 100644 index ff98f3e03..000000000 --- a/cmd/test/final_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/livereview/internal/providers/gitlab" -) - -// Test the final implementation for GitLab line comments -func main_final_test() { - fmt.Println("Testing final GitLab line comment implementation") - - // Test token for git.apps.hexmos.com - hardcoded for testing only - token := "REDACTED_GITLAB_PAT_6" - if token == "" { - fmt.Println("Error: GITLAB_TOKEN environment variable not set") - os.Exit(1) - } - - // Configuration - config := gitlab.GitLabConfig{ - URL: "https://git.apps.hexmos.com", - Token: token, - } - - // Create a GitLab client - httpClient := gitlab.NewHTTPClient(config.URL, config.Token) - fmt.Println("Created GitLab HTTP client") - - // Target information for MR 335 and metric_analysis.go line 29 - projectID := "hexmos/liveapi" - mrIID := 335 - filePath := "liveapi-backend/exam/metric_analysis.go" - lineNum := 29 // Target line 29 - - // Create a test comment with timestamp - timestamp := time.Now().Format(time.RFC3339) - comment := fmt.Sprintf("FINAL IMPLEMENTATION TEST: This comment should appear on line %d of metric_analysis.go in the Changes tab. Created at %s", lineNum, timestamp) - - // Try to post the comment - fmt.Println("Posting line comment to:") - fmt.Printf("- Project: %s\n", projectID) - fmt.Printf("- MR: %d\n", mrIID) - fmt.Printf("- File: %s\n", filePath) - fmt.Printf("- Line: %d\n", lineNum) - - err := httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, comment) - if err != nil { - fmt.Printf("Error posting comment: %v\n", err) - os.Exit(1) - } - - fmt.Println("Comment posted successfully! Check GitLab to verify it's attached to the correct line.") -} diff --git a/cmd/test/test_line_comment.go b/cmd/test/test_line_comment.go deleted file mode 100644 index 727581129..000000000 --- a/cmd/test/test_line_comment.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/livereview/internal/providers/gitlab" -) - -// Test script to verify line-specific comments on GitLab -// This tests the fixed implementation of line comments -func main_test_line_comment() { - fmt.Println("Testing fixed GitLab line comment functionality") - - // Test token for git.apps.hexmos.com - hardcoded for testing only - token := "REDACTED_GITLAB_PAT_6" - if token == "" { - fmt.Println("Error: GITLAB_TOKEN environment variable not set") - os.Exit(1) - } - - // Configuration - config := gitlab.GitLabConfig{ - URL: "https://git.apps.hexmos.com", - Token: token, - } - - // Create a GitLab client - httpClient := gitlab.NewHTTPClient(config.URL, config.Token) - fmt.Println("Created GitLab HTTP client") - - // Target information for MR 335 and metric_analysis.go line 29 - projectID := "hexmos/liveapi" - mrIID := 335 - filePath := "liveapi-backend/exam/metric_analysis.go" - lineNum := 29 // Target line 29 (was 19 before) - - // Create a test comment with timestamp - timestamp := time.Now().Format(time.RFC3339) - comment := fmt.Sprintf("FIXED IMPLEMENTATION TEST: This comment should appear on line %d of metric_analysis.go in the Changes tab. Created at %s", lineNum, timestamp) - - // Try to post the comment - fmt.Println("Posting line comment to:") - fmt.Printf("- Project: %s\n", projectID) - fmt.Printf("- MR: %d\n", mrIID) - fmt.Printf("- File: %s\n", filePath) - fmt.Printf("- Line: %d\n", lineNum) - - err := httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, comment) - if err != nil { - fmt.Printf("Error posting comment: %v\n", err) - os.Exit(1) - } - - fmt.Println("Comment posted successfully! Check GitLab to verify it's attached to the correct line.") -} diff --git a/cmd/test/verify_line_comment.go b/cmd/test/verify_line_comment.go deleted file mode 100644 index 61d29a10b..000000000 --- a/cmd/test/verify_line_comment.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/livereview/internal/providers/gitlab" -) - -// Test script to verify line-specific comments on GitLab -func main_verify_line_comment() { - fmt.Println("Testing GitLab line comment functionality") - - // Test token for git.apps.hexmos.com - hardcoded for testing only - token := "REDACTED_GITLAB_PAT_6" - if token == "" { - fmt.Println("Error: GITLAB_TOKEN environment variable not set") - os.Exit(1) - } - - // Configuration - config := gitlab.GitLabConfig{ - URL: "https://git.apps.hexmos.com", - Token: token, - } - - // Create a GitLab client - httpClient := gitlab.NewHTTPClient(config.URL, config.Token) - fmt.Println("Created GitLab HTTP client") - - // Target information - projectID := "hexmos/liveapi" - mrIID := 335 - filePath := "liveapi-backend/exam/metric_analysis.go" - lineNum := 19 // Target line 19 - - // Create a test comment with timestamp - timestamp := time.Now().Format(time.RFC3339) - comment := fmt.Sprintf("FIXED TEST COMMENT: This is a test comment at %s targeting metric_analysis.go line 19", timestamp) - - // Try to post the comment - fmt.Println("Posting line comment to:") - fmt.Printf("- Project: %s\n", projectID) - fmt.Printf("- MR: %d\n", mrIID) - fmt.Printf("- File: %s\n", filePath) - fmt.Printf("- Line: %d\n", lineNum) - - err := httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, comment) - if err != nil { - fmt.Printf("Error posting comment: %v\n", err) - os.Exit(1) - } - - fmt.Println("Comment posted successfully! Check GitLab to verify it's attached to the correct line.") -} diff --git a/config/Makefile.test b/config/Makefile.test index 47ecc8789..3eaa672bc 100644 --- a/config/Makefile.test +++ b/config/Makefile.test @@ -24,17 +24,7 @@ test-gitlab-comments: @echo "Running GitLab comments tests..." go test -v ./internal/providers/gitlab/tests -run TestPostComments -.PHONY: test-pipeline -test-pipeline: - @echo "Running pipeline tests..." - go test -v ./tests -run TestCompletePipeline - -.PHONY: test-mr335 -test-mr335: - @echo "Running MR 335 details test..." - go test -v ./tests -run TestFetchMR335Details - # Run all tests .PHONY: test-all -test-all: test-parser test-prompt test-api test-gitlab-mr test-gitlab-comments test-pipeline +test-all: test-parser test-prompt test-api test-gitlab-mr test-gitlab-comments @echo "All tests completed!" diff --git a/config/osv-scanner.toml b/config/osv-scanner.toml index 42184f51b..9c9ea582c 100644 --- a/config/osv-scanner.toml +++ b/config/osv-scanner.toml @@ -3,6 +3,16 @@ id = "GO-2026-5932" reason = "False positive — golang.org/x/crypto/openpgp sub-package is never imported by this project; only golang.org/x/crypto/bcrypt is used." +[[ignoredVulns]] +id = "GHSA-246p-cpwv-v3jq" +ignoreUntil = 2027-01-28 +reason = "No github.com/labstack/echo/v4 fix exists (only v5.1.0, a different module path). The one place this app used middleware.RateLimiter() with the vulnerable default IdentifierExtractor (internal/api/server.go, subscriptionMutationLimiter) now keys on the authenticated user ID via a custom IdentifierExtractor instead of c.RealIP(), which is immune to X-Forwarded-For/X-Real-IP spoofing. Re-check when a v4.x fix is published." + +[[ignoredVulns]] +id = "GHSA-99jh-6h7p-pp36" +ignoreUntil = 2027-01-28 +reason = "Not applicable - this is about echo's middleware/proxy.go (Proxy()/ProxyWithConfig()) forwarding a spoofed X-Real-IP to an upstream, and this app never uses echo's reverse-proxy middleware anywhere (grep for middleware.Proxy/ProxyWithConfig is empty). Re-check if that ever changes, or when a v4.x fix is published." + [[ignoredVulns]] id = "GHSA-gcqx-w48m-w2h4" ignoreUntil = 2027-01-28 diff --git a/config/plan_catalog.json b/config/plan_catalog.json index 59a6f48f4..6480e0b71 100644 --- a/config/plan_catalog.json +++ b/config/plan_catalog.json @@ -122,7 +122,8 @@ "monthly_loc_limit": -1, "feature_flags": [ "unlimited_reviews", - "hosted_auto_model" + "hosted_auto_model", + "sso" ], "trial_policy": { "enabled": false, diff --git a/db/migrations/20261029104047_create_org_sso_configs.sql b/db/migrations/20261029104047_create_org_sso_configs.sql new file mode 100644 index 000000000..4c528166c --- /dev/null +++ b/db/migrations/20261029104047_create_org_sso_configs.sql @@ -0,0 +1,52 @@ +-- migrate:up + +-- Per-organization SSO (OIDC/SAML) configuration. One org may have at most +-- one OIDC config and one SAML config active at a time. Secrets +-- (oidc_client_secret_enc) are stored AES-GCM encrypted at rest (see +-- internal/sso/secretbox.go) - never plaintext, never logged. +CREATE TABLE IF NOT EXISTS org_sso_configs ( + id BIGSERIAL PRIMARY KEY, + org_id BIGINT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE, + provider_type VARCHAR(16) NOT NULL CHECK (provider_type IN ('oidc', 'saml')), + display_name VARCHAR(255) NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + + -- OIDC fields + oidc_issuer VARCHAR(512), + oidc_client_id VARCHAR(512), + oidc_client_secret_enc TEXT, + oidc_scopes VARCHAR(255), + + -- SAML fields + saml_idp_metadata_url VARCHAR(512), + saml_idp_metadata_xml TEXT, + saml_idp_entity_id VARCHAR(512), + + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() NOT NULL, + created_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL, + + UNIQUE (org_id, provider_type) +); + +CREATE INDEX IF NOT EXISTS idx_org_sso_configs_org_enabled + ON org_sso_configs(org_id) WHERE enabled = TRUE; + +CREATE OR REPLACE FUNCTION org_sso_configs_set_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_org_sso_configs_updated_at ON org_sso_configs; +CREATE TRIGGER trg_org_sso_configs_updated_at +BEFORE UPDATE ON org_sso_configs +FOR EACH ROW +EXECUTE PROCEDURE org_sso_configs_set_updated_at(); + +-- migrate:down +DROP TRIGGER IF EXISTS trg_org_sso_configs_updated_at ON org_sso_configs; +DROP FUNCTION IF EXISTS org_sso_configs_set_updated_at(); +DROP TABLE IF EXISTS org_sso_configs; diff --git a/db/migrations/20261029134500_widen_org_sso_configs_oidc_scopes.sql b/db/migrations/20261029134500_widen_org_sso_configs_oidc_scopes.sql new file mode 100644 index 000000000..7279595ab --- /dev/null +++ b/db/migrations/20261029134500_widen_org_sso_configs_oidc_scopes.sql @@ -0,0 +1,10 @@ +-- migrate:up + +-- oidc_scopes is a space-separated scope list (see internal/sso/oidc.go's +-- strings.Fields(cfg.OIDCScopes)); some enterprise IdPs issue many custom +-- API scopes that can run well past 255 chars. Widen to TEXT, matching the +-- other unbounded fields on this table (saml_idp_metadata_xml). +ALTER TABLE org_sso_configs ALTER COLUMN oidc_scopes TYPE TEXT; + +-- migrate:down +ALTER TABLE org_sso_configs ALTER COLUMN oidc_scopes TYPE VARCHAR(255); diff --git a/db/schema.sql b/db/schema.sql index 41a161639..894fa428b 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,11 +1,12 @@ \restrict dbmate --- Dumped from database version 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) --- Dumped by pg_dump version 16.14 (Ubuntu 16.14-0ubuntu0.24.04.1) +-- Dumped from database version 18.6 (Debian 18.6-1.pgdg13+2) +-- Dumped by pg_dump version 18.6 (Debian 18.6-1.pgdg13+2) SET statement_timeout = 0; SET lock_timeout = 0; SET idle_in_transaction_session_timeout = 0; +SET transaction_timeout = 0; SET client_encoding = 'UTF8'; SET standard_conforming_strings = on; SELECT pg_catalog.set_config('search_path', '', false); @@ -85,6 +86,26 @@ END; $$; +-- +-- Name: audit_events_append_only(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.audit_events_append_only() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + IF TG_OP = 'UPDATE' THEN + RAISE EXCEPTION 'audit_events is append-only: UPDATE is not permitted'; + END IF; + IF TG_OP = 'DELETE' + AND COALESCE(current_setting('app.audit_purge', true), '') <> 'on' THEN + RAISE EXCEPTION 'audit_events is append-only: DELETE is not permitted'; + END IF; + RETURN OLD; +END; +$$; + + -- -- Name: chat_conversations_search_trigger(); Type: FUNCTION; Schema: public; Owner: - -- @@ -190,6 +211,20 @@ END; $$; +-- +-- Name: org_sso_configs_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.org_sso_configs_set_updated_at() RETURNS trigger + LANGUAGE plpgsql + AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$; + + -- -- Name: plan_catalog_set_updated_at(); Type: FUNCTION; Schema: public; Owner: - -- @@ -425,6 +460,8 @@ CREATE TABLE public.api_keys ( created_at timestamp without time zone DEFAULT now() NOT NULL, expires_at timestamp without time zone, revoked_at timestamp without time zone, + kind character varying(20) DEFAULT 'standard'::character varying NOT NULL, + CONSTRAINT chk_api_keys_kind CHECK (((kind)::text = ANY ((ARRAY['standard'::character varying, 'audit_reader'::character varying])::text[]))), CONSTRAINT valid_expiry CHECK (((expires_at IS NULL) OR (expires_at > created_at))) ); @@ -490,6 +527,616 @@ CREATE SEQUENCE public.api_keys_id_seq ALTER SEQUENCE public.api_keys_id_seq OWNED BY public.api_keys.id; +-- +-- Name: audit_archives; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_archives ( + org_id bigint NOT NULL, + month text NOT NULL, + object_key text NOT NULL, + row_count bigint NOT NULL, + sha256 text NOT NULL, + archived_at timestamp with time zone DEFAULT now() NOT NULL +); + + +-- +-- Name: audit_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events ( + id bigint NOT NULL, + occurred_at timestamp with time zone DEFAULT now() NOT NULL, + org_id bigint NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text NOT NULL, + actor_capacity text DEFAULT 'member'::text NOT NULL, + auth_method text, + api_key_id bigint, + action text NOT NULL, + category text NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb NOT NULL, + source text DEFAULT 'api'::text NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +) +PARTITION BY RANGE (occurred_at); + + +-- +-- Name: audit_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.audit_events_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: audit_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.audit_events_id_seq OWNED BY public.audit_events.id; + + +-- +-- Name: audit_events_2026_10; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2026_10 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2026_11; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2026_11 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2026_12; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2026_12 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_01; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_01 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_02; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_02 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_03; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_03 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_04; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_04 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_05; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_05 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_06; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_06 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_07; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_07 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_08; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_08 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_09; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_09 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_10; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_10 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_2027_11; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_2027_11 ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + +-- +-- Name: audit_events_default; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.audit_events_default ( + id bigint DEFAULT nextval('public.audit_events_id_seq'::regclass) CONSTRAINT audit_events_id_not_null NOT NULL, + occurred_at timestamp with time zone DEFAULT now() CONSTRAINT audit_events_occurred_at_not_null NOT NULL, + org_id bigint CONSTRAINT audit_events_org_id_not_null NOT NULL, + actor_user_id bigint, + actor_email text, + actor_name text, + actor_kind text DEFAULT 'system'::text CONSTRAINT audit_events_actor_kind_not_null NOT NULL, + actor_capacity text DEFAULT 'member'::text CONSTRAINT audit_events_actor_capacity_not_null NOT NULL, + auth_method text, + api_key_id bigint, + action text CONSTRAINT audit_events_action_not_null NOT NULL, + category text CONSTRAINT audit_events_category_not_null NOT NULL, + resource_type text, + resource_id text, + outcome text DEFAULT 'success'::text CONSTRAINT audit_events_outcome_not_null NOT NULL, + status_code integer, + severity text DEFAULT 'info'::text CONSTRAINT audit_events_severity_not_null NOT NULL, + ip inet, + user_agent text, + request_id text, + method text, + path text, + details jsonb DEFAULT '{}'::jsonb CONSTRAINT audit_events_details_not_null NOT NULL, + source text DEFAULT 'api'::text CONSTRAINT audit_events_source_not_null NOT NULL, + legacy_id bigint, + seq bigint, + prev_hash text, + hash text +); + + -- -- Name: auth_tokens; Type: TABLE; Schema: public; Owner: - -- @@ -549,7 +1196,7 @@ CREATE TABLE public.auto_approve_policies ( blocking_ruleset_ids bigint[] DEFAULT '{}'::bigint[] NOT NULL, landing_delay_seconds integer DEFAULT 0 NOT NULL, daily_approve_cap integer, - restrict_outcome_reporting_to_admins boolean DEFAULT false NOT NULL, + restrict_outcome_reporting_to_admins boolean DEFAULT false CONSTRAINT auto_approve_policies_restrict_outcome_reporting_to_ad_not_null NOT NULL, force_approve_min_level integer DEFAULT 3 NOT NULL, created_by bigint, created_at timestamp with time zone DEFAULT now() NOT NULL, @@ -709,7 +1356,7 @@ CREATE TABLE public.chat_charts ( vega_spec jsonb NOT NULL, raw_llm_output text NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - context jsonb, + context text[], stats jsonb ); @@ -788,7 +1435,7 @@ CREATE TABLE public.chat_files ( rows integer, data bytea NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, - context jsonb + context text[] ); @@ -1466,6 +2113,49 @@ CREATE SEQUENCE public.org_slack_configs_id_seq ALTER SEQUENCE public.org_slack_configs_id_seq OWNED BY public.org_slack_configs.id; +-- +-- Name: org_sso_configs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.org_sso_configs ( + id bigint NOT NULL, + org_id bigint NOT NULL, + provider_type character varying(16) NOT NULL, + display_name character varying(255) NOT NULL, + enabled boolean DEFAULT false NOT NULL, + oidc_issuer character varying(512), + oidc_client_id character varying(512), + oidc_client_secret_enc text, + oidc_scopes text, + saml_idp_metadata_url character varying(512), + saml_idp_metadata_xml text, + saml_idp_entity_id character varying(512), + created_at timestamp with time zone DEFAULT now() NOT NULL, + updated_at timestamp with time zone DEFAULT now() NOT NULL, + created_by_user_id bigint, + CONSTRAINT org_sso_configs_provider_type_check CHECK (((provider_type)::text = ANY ((ARRAY['oidc'::character varying, 'saml'::character varying])::text[]))) +); + + +-- +-- Name: org_sso_configs_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.org_sso_configs_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: org_sso_configs_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.org_sso_configs_id_seq OWNED BY public.org_sso_configs.id; + + -- -- Name: org_teams_configs; Type: TABLE; Schema: public; Owner: - -- @@ -1783,12 +2473,12 @@ CREATE TABLE public.quota_batch_settlements ( context_tokens_batch bigint NOT NULL, allowed_context_tokens_batch bigint NOT NULL, extra_context_tokens_batch bigint NOT NULL, - provider_total_input_tokens_batch bigint NOT NULL, + provider_total_input_tokens_batch bigint CONSTRAINT quota_batch_settlements_provider_total_input_tokens_ba_not_null NOT NULL, output_tokens_batch bigint NOT NULL, input_cost_usd_batch double precision NOT NULL, output_cost_usd_batch double precision NOT NULL, total_cost_usd_batch double precision NOT NULL, - context_tokens_per_loc_allowance double precision NOT NULL, + context_tokens_per_loc_allowance double precision CONSTRAINT quota_batch_settlements_context_tokens_per_loc_allowan_not_null NOT NULL, accounted_at timestamp with time zone DEFAULT now() NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, @@ -1852,9 +2542,9 @@ CREATE TABLE public.quota_operation_aggregates ( diff_input_tokens_total bigint NOT NULL, context_chars_total bigint NOT NULL, context_tokens_total bigint NOT NULL, - allowed_context_tokens_total bigint NOT NULL, + allowed_context_tokens_total bigint CONSTRAINT quota_operation_aggregates_allowed_context_tokens_tota_not_null NOT NULL, extra_context_tokens_total bigint NOT NULL, - provider_total_input_tokens_total bigint NOT NULL, + provider_total_input_tokens_total bigint CONSTRAINT quota_operation_aggregates_provider_total_input_tokens_not_null NOT NULL, output_tokens_total bigint NOT NULL, input_cost_usd_total double precision NOT NULL, output_cost_usd_total double precision NOT NULL, @@ -1913,7 +2603,7 @@ CREATE TABLE public.quota_policy_catalog ( context_budget_ratio double precision NOT NULL, ops_reserved_ratio double precision NOT NULL, input_cost_per_million_tokens_usd double precision NOT NULL, - output_cost_per_million_tokens_usd double precision NOT NULL, + output_cost_per_million_tokens_usd double precision CONSTRAINT quota_policy_catalog_output_cost_per_million_tokens_us_not_null NOT NULL, rounding_scale integer DEFAULT 6 NOT NULL, active boolean DEFAULT true NOT NULL, created_at timestamp with time zone DEFAULT now() NOT NULL, @@ -2130,10 +2820,22 @@ CREATE SEQUENCE public.review_events_id_seq -- --- Name: review_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- Name: review_events_id_seq1; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.review_events_id_seq1 + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: review_events_id_seq1; Type: SEQUENCE OWNED BY; Schema: public; Owner: - -- -ALTER SEQUENCE public.review_events_id_seq OWNED BY public.review_events.id; +ALTER SEQUENCE public.review_events_id_seq1 OWNED BY public.review_events.id; -- @@ -2489,37 +3191,6 @@ CREATE UNLOGGED TABLE public.river_client_queue ( ); --- --- Name: river_job; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.river_job ( - id bigint NOT NULL, - state public.river_job_state DEFAULT 'available'::public.river_job_state NOT NULL, - attempt smallint DEFAULT 0 NOT NULL, - max_attempts smallint NOT NULL, - attempted_at timestamp with time zone, - created_at timestamp with time zone DEFAULT now() NOT NULL, - finalized_at timestamp with time zone, - scheduled_at timestamp with time zone DEFAULT now() NOT NULL, - priority smallint DEFAULT 1 NOT NULL, - args jsonb NOT NULL, - attempted_by text[], - errors jsonb[], - kind text NOT NULL, - metadata jsonb DEFAULT '{}'::jsonb NOT NULL, - queue text DEFAULT 'default'::text NOT NULL, - tags character varying(255)[] DEFAULT '{}'::character varying[] NOT NULL, - unique_key bytea, - unique_states bit(8), - CONSTRAINT finalized_or_finalized_at_null CHECK ((((finalized_at IS NULL) AND (state <> ALL (ARRAY['cancelled'::public.river_job_state, 'completed'::public.river_job_state, 'discarded'::public.river_job_state]))) OR ((finalized_at IS NOT NULL) AND (state = ANY (ARRAY['cancelled'::public.river_job_state, 'completed'::public.river_job_state, 'discarded'::public.river_job_state]))))), - CONSTRAINT kind_length CHECK (((char_length(kind) > 0) AND (char_length(kind) < 128))), - CONSTRAINT max_attempts_is_positive CHECK ((max_attempts > 0)), - CONSTRAINT priority_in_range CHECK (((priority >= 1) AND (priority <= 4))), - CONSTRAINT queue_length CHECK (((char_length(queue) > 0) AND (char_length(queue) < 128))) -); - - -- -- Name: river_job_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -2533,10 +3204,29 @@ CREATE SEQUENCE public.river_job_id_seq -- --- Name: river_job_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- Name: river_job; Type: TABLE; Schema: public; Owner: - -- -ALTER SEQUENCE public.river_job_id_seq OWNED BY public.river_job.id; +CREATE TABLE public.river_job ( + id bigint DEFAULT nextval('public.river_job_id_seq'::regclass) NOT NULL, + args jsonb DEFAULT '{}'::jsonb NOT NULL, + attempt smallint DEFAULT 0 NOT NULL, + attempted_at timestamp with time zone, + attempted_by text[], + created_at timestamp with time zone DEFAULT now() NOT NULL, + errors jsonb[], + finalized_at timestamp with time zone, + kind text NOT NULL, + max_attempts smallint DEFAULT 3 NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + priority smallint DEFAULT 1 NOT NULL, + queue text DEFAULT 'default'::text NOT NULL, + state public.river_job_state DEFAULT 'available'::public.river_job_state NOT NULL, + scheduled_at timestamp with time zone DEFAULT now() NOT NULL, + tags character varying(255)[] DEFAULT '{}'::character varying[], + unique_key bytea, + unique_states bit(8) +); -- @@ -3061,14 +3751,14 @@ CREATE TABLE public.upgrade_replacement_cutovers ( org_id bigint NOT NULL, owner_user_id bigint NOT NULL, old_local_subscription_id bigint NOT NULL, - old_razorpay_subscription_id character varying(255) NOT NULL, + old_razorpay_subscription_id character varying(255) CONSTRAINT upgrade_replacement_cutover_old_razorpay_subscription__not_null NOT NULL, replacement_local_subscription_id bigint, replacement_razorpay_subscription_id character varying(255), target_plan_code character varying(64) NOT NULL, target_quantity integer NOT NULL, currency character varying(16) NOT NULL, cutover_at timestamp with time zone NOT NULL, - old_cancellation_scheduled boolean DEFAULT false NOT NULL, + old_cancellation_scheduled boolean DEFAULT false CONSTRAINT upgrade_replacement_cutover_old_cancellation_scheduled_not_null NOT NULL, status character varying(64) DEFAULT 'pending_provisioning'::character varying NOT NULL, retry_count integer DEFAULT 0 NOT NULL, next_retry_at timestamp with time zone, @@ -3102,24 +3792,6 @@ CREATE SEQUENCE public.upgrade_replacement_cutovers_id_seq ALTER SEQUENCE public.upgrade_replacement_cutovers_id_seq OWNED BY public.upgrade_replacement_cutovers.id; --- --- Name: upgrade_request_events; Type: TABLE; Schema: public; Owner: - --- - -CREATE TABLE public.upgrade_request_events ( - id bigint NOT NULL, - upgrade_request_id character varying(36) NOT NULL, - org_id bigint NOT NULL, - event_source character varying(64) NOT NULL, - event_type character varying(64) NOT NULL, - from_status character varying(64), - to_status character varying(64), - event_payload jsonb, - event_time timestamp with time zone DEFAULT now() NOT NULL, - created_at timestamp with time zone DEFAULT now() NOT NULL -); - - -- -- Name: upgrade_request_events_id_seq; Type: SEQUENCE; Schema: public; Owner: - -- @@ -3132,13 +3804,6 @@ CREATE SEQUENCE public.upgrade_request_events_id_seq CACHE 1; --- --- Name: upgrade_request_events_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - --- - -ALTER SEQUENCE public.upgrade_request_events_id_seq OWNED BY public.upgrade_request_events.id; - - -- -- Name: upgrade_requests; Type: TABLE; Schema: public; Owner: - -- @@ -3408,119 +4073,231 @@ ALTER SEQUENCE public.webhook_registry_id_seq OWNED BY public.webhook_registry.i -- --- Name: ai_comments id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2026_10; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.ai_comments ALTER COLUMN id SET DEFAULT nextval('public.ai_comments_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2026_10 FOR VALUES FROM ('2026-10-01 00:00:00+00') TO ('2026-11-01 00:00:00+00'); -- --- Name: ai_connectors id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2026_11; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.ai_connectors ALTER COLUMN id SET DEFAULT nextval('public.ai_connectors_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2026_11 FOR VALUES FROM ('2026-11-01 00:00:00+00') TO ('2026-12-01 00:00:00+00'); -- --- Name: ai_models id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2026_12; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.ai_models ALTER COLUMN id SET DEFAULT nextval('public.ai_models_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2026_12 FOR VALUES FROM ('2026-12-01 00:00:00+00') TO ('2027-01-01 00:00:00+00'); -- --- Name: api_keys id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_01; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.api_keys ALTER COLUMN id SET DEFAULT nextval('public.api_keys_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_01 FOR VALUES FROM ('2027-01-01 00:00:00+00') TO ('2027-02-01 00:00:00+00'); -- --- Name: auth_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_02; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.auth_tokens ALTER COLUMN id SET DEFAULT nextval('public.auth_tokens_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_02 FOR VALUES FROM ('2027-02-01 00:00:00+00') TO ('2027-03-01 00:00:00+00'); -- --- Name: auto_approve_policies id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_03; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.auto_approve_policies ALTER COLUMN id SET DEFAULT nextval('public.auto_approve_policies_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_03 FOR VALUES FROM ('2027-03-01 00:00:00+00') TO ('2027-04-01 00:00:00+00'); -- --- Name: billing_notification_outbox id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_04; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.billing_notification_outbox ALTER COLUMN id SET DEFAULT nextval('public.billing_notification_outbox_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_04 FOR VALUES FROM ('2027-04-01 00:00:00+00') TO ('2027-05-01 00:00:00+00'); -- --- Name: blast_radius_hunks id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_05; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.blast_radius_hunks ALTER COLUMN id SET DEFAULT nextval('public.blast_radius_hunks_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_05 FOR VALUES FROM ('2027-05-01 00:00:00+00') TO ('2027-06-01 00:00:00+00'); -- --- Name: chat_charts id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_06; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.chat_charts ALTER COLUMN id SET DEFAULT nextval('public.chat_charts_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_06 FOR VALUES FROM ('2027-06-01 00:00:00+00') TO ('2027-07-01 00:00:00+00'); -- --- Name: chat_conversations id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_07; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.chat_conversations ALTER COLUMN id SET DEFAULT nextval('public.chat_conversations_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_07 FOR VALUES FROM ('2027-07-01 00:00:00+00') TO ('2027-08-01 00:00:00+00'); -- --- Name: chat_files id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_08; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.chat_files ALTER COLUMN id SET DEFAULT nextval('public.chat_files_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_08 FOR VALUES FROM ('2027-08-01 00:00:00+00') TO ('2027-09-01 00:00:00+00'); -- --- Name: chat_messages id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_09; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.chat_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_messages_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_09 FOR VALUES FROM ('2027-09-01 00:00:00+00') TO ('2027-10-01 00:00:00+00'); -- --- Name: ci_rulesets id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_10; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.ci_rulesets ALTER COLUMN id SET DEFAULT nextval('public.ci_rulesets_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_10 FOR VALUES FROM ('2027-10-01 00:00:00+00') TO ('2027-11-01 00:00:00+00'); -- --- Name: instance_details id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_2027_11; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.instance_details ALTER COLUMN id SET DEFAULT nextval('public.instance_details_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_2027_11 FOR VALUES FROM ('2027-11-01 00:00:00+00') TO ('2027-12-01 00:00:00+00'); -- --- Name: integration_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- Name: audit_events_default; Type: TABLE ATTACH; Schema: public; Owner: - -- -ALTER TABLE ONLY public.integration_tokens ALTER COLUMN id SET DEFAULT nextval('public.integration_tokens_id_seq'::regclass); +ALTER TABLE ONLY public.audit_events ATTACH PARTITION public.audit_events_default DEFAULT; -- --- Name: license_log id; Type: DEFAULT; Schema: public; Owner: - +-- Name: ai_comments id; Type: DEFAULT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.license_log ALTER COLUMN id SET DEFAULT nextval('public.license_log_id_seq'::regclass); +ALTER TABLE ONLY public.ai_comments ALTER COLUMN id SET DEFAULT nextval('public.ai_comments_id_seq'::regclass); -- --- Name: license_seat_assignments id; Type: DEFAULT; Schema: public; Owner: - +-- Name: ai_connectors id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ai_connectors ALTER COLUMN id SET DEFAULT nextval('public.ai_connectors_id_seq'::regclass); + + +-- +-- Name: ai_models id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ai_models ALTER COLUMN id SET DEFAULT nextval('public.ai_models_id_seq'::regclass); + + +-- +-- Name: api_keys id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys ALTER COLUMN id SET DEFAULT nextval('public.api_keys_id_seq'::regclass); + + +-- +-- Name: audit_events id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events ALTER COLUMN id SET DEFAULT nextval('public.audit_events_id_seq'::regclass); + + +-- +-- Name: auth_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auth_tokens ALTER COLUMN id SET DEFAULT nextval('public.auth_tokens_id_seq'::regclass); + + +-- +-- Name: auto_approve_policies id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.auto_approve_policies ALTER COLUMN id SET DEFAULT nextval('public.auto_approve_policies_id_seq'::regclass); + + +-- +-- Name: billing_notification_outbox id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_notification_outbox ALTER COLUMN id SET DEFAULT nextval('public.billing_notification_outbox_id_seq'::regclass); + + +-- +-- Name: blast_radius_hunks id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.blast_radius_hunks ALTER COLUMN id SET DEFAULT nextval('public.blast_radius_hunks_id_seq'::regclass); + + +-- +-- Name: chat_charts id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_charts ALTER COLUMN id SET DEFAULT nextval('public.chat_charts_id_seq'::regclass); + + +-- +-- Name: chat_conversations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_conversations ALTER COLUMN id SET DEFAULT nextval('public.chat_conversations_id_seq'::regclass); + + +-- +-- Name: chat_files id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_files ALTER COLUMN id SET DEFAULT nextval('public.chat_files_id_seq'::regclass); + + +-- +-- Name: chat_messages id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.chat_messages ALTER COLUMN id SET DEFAULT nextval('public.chat_messages_id_seq'::regclass); + + +-- +-- Name: ci_rulesets id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ci_rulesets ALTER COLUMN id SET DEFAULT nextval('public.ci_rulesets_id_seq'::regclass); + + +-- +-- Name: instance_details id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.instance_details ALTER COLUMN id SET DEFAULT nextval('public.instance_details_id_seq'::regclass); + + +-- +-- Name: integration_tokens id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_tokens ALTER COLUMN id SET DEFAULT nextval('public.integration_tokens_id_seq'::regclass); + + +-- +-- Name: license_log id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.license_log ALTER COLUMN id SET DEFAULT nextval('public.license_log_id_seq'::regclass); + + +-- +-- Name: license_seat_assignments id; Type: DEFAULT; Schema: public; Owner: - -- ALTER TABLE ONLY public.license_seat_assignments ALTER COLUMN id SET DEFAULT nextval('public.license_seat_assignments_id_seq'::regclass); @@ -3568,6 +4345,13 @@ ALTER TABLE ONLY public.org_discord_configs ALTER COLUMN id SET DEFAULT nextval( ALTER TABLE ONLY public.org_slack_configs ALTER COLUMN id SET DEFAULT nextval('public.org_slack_configs_id_seq'::regclass); +-- +-- Name: org_sso_configs id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_sso_configs ALTER COLUMN id SET DEFAULT nextval('public.org_sso_configs_id_seq'::regclass); + + -- -- Name: org_teams_configs id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3656,7 +4440,7 @@ ALTER TABLE ONLY public.review_commits ALTER COLUMN id SET DEFAULT nextval('publ -- Name: review_events id; Type: DEFAULT; Schema: public; Owner: - -- -ALTER TABLE ONLY public.review_events ALTER COLUMN id SET DEFAULT nextval('public.review_events_id_seq'::regclass); +ALTER TABLE ONLY public.review_events ALTER COLUMN id SET DEFAULT nextval('public.review_events_id_seq1'::regclass); -- @@ -3715,13 +4499,6 @@ ALTER TABLE ONLY public.risk_feature_snapshots ALTER COLUMN id SET DEFAULT nextv ALTER TABLE ONLY public.risk_model_versions ALTER COLUMN id SET DEFAULT nextval('public.risk_model_versions_id_seq'::regclass); --- --- Name: river_job id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.river_job ALTER COLUMN id SET DEFAULT nextval('public.river_job_id_seq'::regclass); - - -- -- Name: roles id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3785,13 +4562,6 @@ ALTER TABLE ONLY public.upgrade_payment_attempts ALTER COLUMN id SET DEFAULT nex ALTER TABLE ONLY public.upgrade_replacement_cutovers ALTER COLUMN id SET DEFAULT nextval('public.upgrade_replacement_cutovers_id_seq'::regclass); --- --- Name: upgrade_request_events id; Type: DEFAULT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.upgrade_request_events ALTER COLUMN id SET DEFAULT nextval('public.upgrade_request_events_id_seq'::regclass); - - -- -- Name: upgrade_requests id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3882,6 +4652,142 @@ ALTER TABLE ONLY public.api_keys ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); +-- +-- Name: audit_archives audit_archives_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_archives + ADD CONSTRAINT audit_archives_pkey PRIMARY KEY (org_id, month); + + +-- +-- Name: audit_events audit_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events + ADD CONSTRAINT audit_events_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2026_10 audit_events_2026_10_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2026_10 + ADD CONSTRAINT audit_events_2026_10_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2026_11 audit_events_2026_11_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2026_11 + ADD CONSTRAINT audit_events_2026_11_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2026_12 audit_events_2026_12_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2026_12 + ADD CONSTRAINT audit_events_2026_12_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_01 audit_events_2027_01_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_01 + ADD CONSTRAINT audit_events_2027_01_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_02 audit_events_2027_02_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_02 + ADD CONSTRAINT audit_events_2027_02_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_03 audit_events_2027_03_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_03 + ADD CONSTRAINT audit_events_2027_03_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_04 audit_events_2027_04_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_04 + ADD CONSTRAINT audit_events_2027_04_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_05 audit_events_2027_05_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_05 + ADD CONSTRAINT audit_events_2027_05_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_06 audit_events_2027_06_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_06 + ADD CONSTRAINT audit_events_2027_06_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_07 audit_events_2027_07_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_07 + ADD CONSTRAINT audit_events_2027_07_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_08 audit_events_2027_08_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_08 + ADD CONSTRAINT audit_events_2027_08_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_09 audit_events_2027_09_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_09 + ADD CONSTRAINT audit_events_2027_09_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_10 audit_events_2027_10_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_10 + ADD CONSTRAINT audit_events_2027_10_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_2027_11 audit_events_2027_11_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_2027_11 + ADD CONSTRAINT audit_events_2027_11_pkey PRIMARY KEY (id, occurred_at); + + +-- +-- Name: audit_events_default audit_events_default_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.audit_events_default + ADD CONSTRAINT audit_events_default_pkey PRIMARY KEY (id, occurred_at); + + -- -- Name: auth_tokens auth_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4122,6 +5028,22 @@ ALTER TABLE ONLY public.org_slack_configs ADD CONSTRAINT org_slack_configs_pkey PRIMARY KEY (id); +-- +-- Name: org_sso_configs org_sso_configs_org_id_provider_type_key; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_sso_configs + ADD CONSTRAINT org_sso_configs_org_id_provider_type_key UNIQUE (org_id, provider_type); + + +-- +-- Name: org_sso_configs org_sso_configs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_sso_configs + ADD CONSTRAINT org_sso_configs_pkey PRIMARY KEY (id); + + -- -- Name: org_teams_configs org_teams_configs_org_id_key; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4514,14 +5436,6 @@ ALTER TABLE ONLY public.upgrade_replacement_cutovers ADD CONSTRAINT upgrade_replacement_cutovers_upgrade_request_id_key UNIQUE (upgrade_request_id); --- --- Name: upgrade_request_events upgrade_request_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.upgrade_request_events - ADD CONSTRAINT upgrade_request_events_pkey PRIMARY KEY (id); - - -- -- Name: upgrade_requests upgrade_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4683,1431 +5597,3496 @@ ALTER TABLE ONLY public.webhook_registry -- --- Name: idx_ai_comments_created_at; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_created_at ON public.ai_comments USING btree (created_at DESC); +CREATE INDEX idx_audit_events_id ON ONLY public.audit_events USING btree (id); -- --- Name: idx_ai_comments_file_path; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_id_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_file_path ON public.ai_comments USING btree (file_path) WHERE (file_path IS NOT NULL); +CREATE INDEX audit_events_2026_10_id_idx ON public.audit_events_2026_10 USING btree (id); -- --- Name: idx_ai_comments_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_action_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_org_created ON public.ai_comments USING btree (org_id, created_at DESC); +CREATE INDEX idx_audit_events_org_action_time ON ONLY public.audit_events USING btree (org_id, action, occurred_at DESC); -- --- Name: idx_ai_comments_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_org_id ON public.ai_comments USING btree (org_id); +CREATE INDEX audit_events_2026_10_org_id_action_occurred_at_idx ON public.audit_events_2026_10 USING btree (org_id, action, occurred_at DESC); -- --- Name: idx_ai_comments_org_review; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_actor_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_org_review ON public.ai_comments USING btree (org_id, review_id); +CREATE INDEX idx_audit_events_org_actor_time ON ONLY public.audit_events USING btree (org_id, actor_user_id, occurred_at DESC); -- --- Name: idx_ai_comments_review_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_review_id ON public.ai_comments USING btree (review_id); +CREATE INDEX audit_events_2026_10_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2026_10 USING btree (org_id, actor_user_id, occurred_at DESC); -- --- Name: idx_ai_comments_type; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_category_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_comments_type ON public.ai_comments USING btree (comment_type); +CREATE INDEX idx_audit_events_org_category_time ON ONLY public.audit_events USING btree (org_id, category, occurred_at DESC); -- --- Name: idx_ai_connectors_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_connectors_org_id ON public.ai_connectors USING btree (org_id); +CREATE INDEX audit_events_2026_10_org_id_category_occurred_at_idx ON public.audit_events_2026_10 USING btree (org_id, category, occurred_at DESC); -- --- Name: idx_ai_connectors_org_provider; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_connectors_org_provider ON public.ai_connectors USING btree (org_id, provider_name); +CREATE INDEX idx_audit_events_org_time ON ONLY public.audit_events USING btree (org_id, occurred_at DESC, id DESC); -- --- Name: idx_ai_connectors_org_role_order; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_connectors_org_role_order ON public.ai_connectors USING btree (org_id, role, display_order); +CREATE INDEX audit_events_2026_10_org_id_occurred_at_id_idx ON public.audit_events_2026_10 USING btree (org_id, occurred_at DESC, id DESC); -- --- Name: idx_ai_connectors_provider_name; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_outcome_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_connectors_provider_name ON public.ai_connectors USING btree (provider_name); +CREATE INDEX idx_audit_events_org_outcome_time ON ONLY public.audit_events USING btree (org_id, outcome, occurred_at DESC); -- --- Name: idx_ai_models_provider; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ai_models_provider ON public.ai_models USING btree (provider); +CREATE INDEX audit_events_2026_10_org_id_outcome_occurred_at_idx ON public.audit_events_2026_10 USING btree (org_id, outcome, occurred_at DESC); -- --- Name: idx_api_keys_key_hash; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_seq; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_api_keys_key_hash ON public.api_keys USING btree (key_hash); +CREATE INDEX idx_audit_events_org_seq ON ONLY public.audit_events USING btree (org_id, seq) WHERE (seq IS NOT NULL); -- --- Name: idx_api_keys_key_prefix; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_api_keys_key_prefix ON public.api_keys USING btree (key_prefix); +CREATE INDEX audit_events_2026_10_org_id_seq_idx ON public.audit_events_2026_10 USING btree (org_id, seq) WHERE (seq IS NOT NULL); -- --- Name: idx_api_keys_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: idx_audit_events_org_severity_time; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_api_keys_org_id ON public.api_keys USING btree (org_id); +CREATE INDEX idx_audit_events_org_severity_time ON ONLY public.audit_events USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2026_10_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_10_org_id_severity_occurred_at_idx ON public.audit_events_2026_10 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: uq_audit_events_legacy; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX uq_audit_events_legacy ON ONLY public.audit_events USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2026_10_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2026_10_source_legacy_id_occurred_at_idx ON public.audit_events_2026_10 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2026_11_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_id_idx ON public.audit_events_2026_11 USING btree (id); + + +-- +-- Name: audit_events_2026_11_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_action_occurred_at_idx ON public.audit_events_2026_11 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2026_11_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2026_11 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2026_11_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_category_occurred_at_idx ON public.audit_events_2026_11 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2026_11_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_occurred_at_id_idx ON public.audit_events_2026_11 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2026_11_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_outcome_occurred_at_idx ON public.audit_events_2026_11 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2026_11_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_seq_idx ON public.audit_events_2026_11 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2026_11_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_11_org_id_severity_occurred_at_idx ON public.audit_events_2026_11 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2026_11_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2026_11_source_legacy_id_occurred_at_idx ON public.audit_events_2026_11 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2026_12_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_id_idx ON public.audit_events_2026_12 USING btree (id); + + +-- +-- Name: audit_events_2026_12_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_action_occurred_at_idx ON public.audit_events_2026_12 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2026_12_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2026_12 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2026_12_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_category_occurred_at_idx ON public.audit_events_2026_12 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2026_12_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_occurred_at_id_idx ON public.audit_events_2026_12 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2026_12_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_outcome_occurred_at_idx ON public.audit_events_2026_12 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2026_12_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_seq_idx ON public.audit_events_2026_12 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2026_12_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2026_12_org_id_severity_occurred_at_idx ON public.audit_events_2026_12 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2026_12_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2026_12_source_legacy_id_occurred_at_idx ON public.audit_events_2026_12 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_01_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_id_idx ON public.audit_events_2027_01 USING btree (id); + + +-- +-- Name: audit_events_2027_01_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_action_occurred_at_idx ON public.audit_events_2027_01 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_01_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_01 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_01_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_category_occurred_at_idx ON public.audit_events_2027_01 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_01_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_occurred_at_id_idx ON public.audit_events_2027_01 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_01_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_outcome_occurred_at_idx ON public.audit_events_2027_01 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_01_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_seq_idx ON public.audit_events_2027_01 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_01_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_01_org_id_severity_occurred_at_idx ON public.audit_events_2027_01 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_01_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_01_source_legacy_id_occurred_at_idx ON public.audit_events_2027_01 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_02_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_id_idx ON public.audit_events_2027_02 USING btree (id); + + +-- +-- Name: audit_events_2027_02_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_action_occurred_at_idx ON public.audit_events_2027_02 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_02_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_02 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_02_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_category_occurred_at_idx ON public.audit_events_2027_02 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_02_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_occurred_at_id_idx ON public.audit_events_2027_02 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_02_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_outcome_occurred_at_idx ON public.audit_events_2027_02 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_02_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_seq_idx ON public.audit_events_2027_02 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_02_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_02_org_id_severity_occurred_at_idx ON public.audit_events_2027_02 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_02_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_02_source_legacy_id_occurred_at_idx ON public.audit_events_2027_02 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_03_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_id_idx ON public.audit_events_2027_03 USING btree (id); + + +-- +-- Name: audit_events_2027_03_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_action_occurred_at_idx ON public.audit_events_2027_03 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_03_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_03 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_03_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_category_occurred_at_idx ON public.audit_events_2027_03 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_03_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_occurred_at_id_idx ON public.audit_events_2027_03 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_03_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_outcome_occurred_at_idx ON public.audit_events_2027_03 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_03_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_seq_idx ON public.audit_events_2027_03 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_03_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_03_org_id_severity_occurred_at_idx ON public.audit_events_2027_03 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_03_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_03_source_legacy_id_occurred_at_idx ON public.audit_events_2027_03 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_04_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_id_idx ON public.audit_events_2027_04 USING btree (id); + + +-- +-- Name: audit_events_2027_04_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_action_occurred_at_idx ON public.audit_events_2027_04 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_04_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_04 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_04_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_category_occurred_at_idx ON public.audit_events_2027_04 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_04_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_occurred_at_id_idx ON public.audit_events_2027_04 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_04_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_outcome_occurred_at_idx ON public.audit_events_2027_04 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_04_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_seq_idx ON public.audit_events_2027_04 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_04_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_04_org_id_severity_occurred_at_idx ON public.audit_events_2027_04 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_04_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_04_source_legacy_id_occurred_at_idx ON public.audit_events_2027_04 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_05_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_id_idx ON public.audit_events_2027_05 USING btree (id); + + +-- +-- Name: audit_events_2027_05_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_action_occurred_at_idx ON public.audit_events_2027_05 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_05_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_05 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_05_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_category_occurred_at_idx ON public.audit_events_2027_05 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_05_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_occurred_at_id_idx ON public.audit_events_2027_05 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_05_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_outcome_occurred_at_idx ON public.audit_events_2027_05 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_05_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_seq_idx ON public.audit_events_2027_05 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_05_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_05_org_id_severity_occurred_at_idx ON public.audit_events_2027_05 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_05_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_05_source_legacy_id_occurred_at_idx ON public.audit_events_2027_05 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_06_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_id_idx ON public.audit_events_2027_06 USING btree (id); + + +-- +-- Name: audit_events_2027_06_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_action_occurred_at_idx ON public.audit_events_2027_06 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_06_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_06 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_06_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_category_occurred_at_idx ON public.audit_events_2027_06 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_06_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_occurred_at_id_idx ON public.audit_events_2027_06 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_06_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_outcome_occurred_at_idx ON public.audit_events_2027_06 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_06_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_seq_idx ON public.audit_events_2027_06 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_06_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_06_org_id_severity_occurred_at_idx ON public.audit_events_2027_06 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_06_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_06_source_legacy_id_occurred_at_idx ON public.audit_events_2027_06 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_07_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_id_idx ON public.audit_events_2027_07 USING btree (id); + + +-- +-- Name: audit_events_2027_07_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_action_occurred_at_idx ON public.audit_events_2027_07 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_07_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_07 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_07_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_category_occurred_at_idx ON public.audit_events_2027_07 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_07_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_occurred_at_id_idx ON public.audit_events_2027_07 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_07_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_outcome_occurred_at_idx ON public.audit_events_2027_07 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_07_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_seq_idx ON public.audit_events_2027_07 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_07_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_07_org_id_severity_occurred_at_idx ON public.audit_events_2027_07 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_07_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_07_source_legacy_id_occurred_at_idx ON public.audit_events_2027_07 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_08_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_id_idx ON public.audit_events_2027_08 USING btree (id); + + +-- +-- Name: audit_events_2027_08_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_action_occurred_at_idx ON public.audit_events_2027_08 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_08_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_08 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_08_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_category_occurred_at_idx ON public.audit_events_2027_08 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_08_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_occurred_at_id_idx ON public.audit_events_2027_08 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_08_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_outcome_occurred_at_idx ON public.audit_events_2027_08 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_08_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_seq_idx ON public.audit_events_2027_08 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_08_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_08_org_id_severity_occurred_at_idx ON public.audit_events_2027_08 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_08_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_08_source_legacy_id_occurred_at_idx ON public.audit_events_2027_08 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_09_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_id_idx ON public.audit_events_2027_09 USING btree (id); + + +-- +-- Name: audit_events_2027_09_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_action_occurred_at_idx ON public.audit_events_2027_09 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_09_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_09 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_09_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_category_occurred_at_idx ON public.audit_events_2027_09 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_09_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_occurred_at_id_idx ON public.audit_events_2027_09 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_09_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_outcome_occurred_at_idx ON public.audit_events_2027_09 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_09_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_seq_idx ON public.audit_events_2027_09 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_09_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_09_org_id_severity_occurred_at_idx ON public.audit_events_2027_09 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_09_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_09_source_legacy_id_occurred_at_idx ON public.audit_events_2027_09 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_10_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_id_idx ON public.audit_events_2027_10 USING btree (id); + + +-- +-- Name: audit_events_2027_10_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_action_occurred_at_idx ON public.audit_events_2027_10 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_10_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_10 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_10_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_category_occurred_at_idx ON public.audit_events_2027_10 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_10_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_occurred_at_id_idx ON public.audit_events_2027_10 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_10_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_outcome_occurred_at_idx ON public.audit_events_2027_10 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_10_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_seq_idx ON public.audit_events_2027_10 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_10_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_10_org_id_severity_occurred_at_idx ON public.audit_events_2027_10 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_10_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_10_source_legacy_id_occurred_at_idx ON public.audit_events_2027_10 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_2027_11_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_id_idx ON public.audit_events_2027_11 USING btree (id); + + +-- +-- Name: audit_events_2027_11_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_action_occurred_at_idx ON public.audit_events_2027_11 USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_2027_11_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_actor_user_id_occurred_at_idx ON public.audit_events_2027_11 USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_2027_11_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_category_occurred_at_idx ON public.audit_events_2027_11 USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_2027_11_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_occurred_at_id_idx ON public.audit_events_2027_11 USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_2027_11_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_outcome_occurred_at_idx ON public.audit_events_2027_11 USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_2027_11_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_seq_idx ON public.audit_events_2027_11 USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_2027_11_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_2027_11_org_id_severity_occurred_at_idx ON public.audit_events_2027_11 USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_2027_11_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_2027_11_source_legacy_id_occurred_at_idx ON public.audit_events_2027_11 USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: audit_events_default_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_id_idx ON public.audit_events_default USING btree (id); + + +-- +-- Name: audit_events_default_org_id_action_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_action_occurred_at_idx ON public.audit_events_default USING btree (org_id, action, occurred_at DESC); + + +-- +-- Name: audit_events_default_org_id_actor_user_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_actor_user_id_occurred_at_idx ON public.audit_events_default USING btree (org_id, actor_user_id, occurred_at DESC); + + +-- +-- Name: audit_events_default_org_id_category_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_category_occurred_at_idx ON public.audit_events_default USING btree (org_id, category, occurred_at DESC); + + +-- +-- Name: audit_events_default_org_id_occurred_at_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_occurred_at_id_idx ON public.audit_events_default USING btree (org_id, occurred_at DESC, id DESC); + + +-- +-- Name: audit_events_default_org_id_outcome_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_outcome_occurred_at_idx ON public.audit_events_default USING btree (org_id, outcome, occurred_at DESC); + + +-- +-- Name: audit_events_default_org_id_seq_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_seq_idx ON public.audit_events_default USING btree (org_id, seq) WHERE (seq IS NOT NULL); + + +-- +-- Name: audit_events_default_org_id_severity_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX audit_events_default_org_id_severity_occurred_at_idx ON public.audit_events_default USING btree (org_id, severity, occurred_at DESC); + + +-- +-- Name: audit_events_default_source_legacy_id_occurred_at_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX audit_events_default_source_legacy_id_occurred_at_idx ON public.audit_events_default USING btree (source, legacy_id, occurred_at) WHERE (legacy_id IS NOT NULL); + + +-- +-- Name: idx_ai_comments_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_created_at ON public.ai_comments USING btree (created_at DESC); + + +-- +-- Name: idx_ai_comments_file_path; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_file_path ON public.ai_comments USING btree (file_path) WHERE (file_path IS NOT NULL); + + +-- +-- Name: idx_ai_comments_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_org_created ON public.ai_comments USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_ai_comments_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_org_id ON public.ai_comments USING btree (org_id); + + +-- +-- Name: idx_ai_comments_org_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_org_review ON public.ai_comments USING btree (org_id, review_id); + + +-- +-- Name: idx_ai_comments_review_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_review_id ON public.ai_comments USING btree (review_id); + + +-- +-- Name: idx_ai_comments_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_comments_type ON public.ai_comments USING btree (comment_type); + + +-- +-- Name: idx_ai_connectors_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_connectors_org_id ON public.ai_connectors USING btree (org_id); + + +-- +-- Name: idx_ai_connectors_org_provider; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_connectors_org_provider ON public.ai_connectors USING btree (org_id, provider_name); + + +-- +-- Name: idx_ai_connectors_org_role_order; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_connectors_org_role_order ON public.ai_connectors USING btree (org_id, role, display_order); + + +-- +-- Name: idx_ai_connectors_provider_name; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_connectors_provider_name ON public.ai_connectors USING btree (provider_name); + + +-- +-- Name: idx_ai_models_provider; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ai_models_provider ON public.ai_models USING btree (provider); + + +-- +-- Name: idx_api_keys_key_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_keys_key_hash ON public.api_keys USING btree (key_hash); + + +-- +-- Name: idx_api_keys_key_prefix; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_keys_key_prefix ON public.api_keys USING btree (key_prefix); + + +-- +-- Name: idx_api_keys_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_keys_org_id ON public.api_keys USING btree (org_id); + + +-- +-- Name: idx_api_keys_org_kind; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_keys_org_kind ON public.api_keys USING btree (org_id, kind) WHERE (revoked_at IS NULL); -- -- Name: idx_api_keys_user_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_api_keys_user_id ON public.api_keys USING btree (user_id); +CREATE INDEX idx_api_keys_user_id ON public.api_keys USING btree (user_id); + + +-- +-- Name: idx_audit_org_action; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_audit_org_action ON public.user_management_audit USING btree (org_id, action, created_at DESC); + + +-- +-- Name: idx_audit_performed_by; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_audit_performed_by ON public.user_management_audit USING btree (performed_by_user_id, created_at DESC); + + +-- +-- Name: idx_audit_target_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_audit_target_time ON public.user_management_audit USING btree (target_user_id, created_at DESC); + + +-- +-- Name: idx_auth_tokens_active_sessions; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_active_sessions ON public.auth_tokens USING btree (user_id, last_used_at) WHERE (((token_type)::text = 'session'::text) AND (is_active = true)); + + +-- +-- Name: idx_auth_tokens_cleanup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_cleanup ON public.auth_tokens USING btree (token_type, expires_at, is_active); + + +-- +-- Name: idx_auth_tokens_expires; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_expires ON public.auth_tokens USING btree (expires_at) WHERE (is_active = true); + + +-- +-- Name: idx_auth_tokens_hash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_hash ON public.auth_tokens USING btree (token_hash) WHERE (is_active = true); + + +-- +-- Name: idx_auth_tokens_last_used; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_last_used ON public.auth_tokens USING btree (last_used_at) WHERE (is_active = true); + + +-- +-- Name: idx_auth_tokens_refresh; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_refresh ON public.auth_tokens USING btree (token_hash, token_type) WHERE (((token_type)::text = 'refresh'::text) AND (is_active = true)); + + +-- +-- Name: idx_auth_tokens_type_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_type_user ON public.auth_tokens USING btree (token_type, user_id) WHERE (is_active = true); + + +-- +-- Name: idx_auth_tokens_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auth_tokens_user_id ON public.auth_tokens USING btree (user_id); + + +-- +-- Name: idx_auto_approve_policies_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_auto_approve_policies_org ON public.auto_approve_policies USING btree (org_id); + + +-- +-- Name: idx_auto_approve_policies_org_default; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_auto_approve_policies_org_default ON public.auto_approve_policies USING btree (org_id) WHERE (repository_id IS NULL); + + +-- +-- Name: idx_auto_approve_policies_org_repo; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_auto_approve_policies_org_repo ON public.auto_approve_policies USING btree (org_id, repository_id) WHERE (repository_id IS NOT NULL); + + +-- +-- Name: idx_billing_notification_outbox_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_billing_notification_outbox_org_created ON public.billing_notification_outbox USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_billing_notification_outbox_pending; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_billing_notification_outbox_pending ON public.billing_notification_outbox USING btree (status, send_after, created_at) WHERE ((status)::text = ANY (ARRAY[('pending'::character varying)::text, ('failed'::character varying)::text])); + + +-- +-- Name: idx_blast_radius_hunks_org_combined; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_blast_radius_hunks_org_combined ON public.blast_radius_hunks USING btree (org_id, combined DESC); + + +-- +-- Name: idx_blast_radius_hunks_review_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_blast_radius_hunks_review_id ON public.blast_radius_hunks USING btree (review_id); + + +-- +-- Name: idx_chat_charts_message; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_charts_message ON public.chat_charts USING btree (message_id); + + +-- +-- Name: idx_chat_conversations_search; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_conversations_search ON public.chat_conversations USING gin (search_vector); + + +-- +-- Name: idx_chat_conversations_title_trgm; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_conversations_title_trgm ON public.chat_conversations USING gin (title public.gin_trgm_ops); + + +-- +-- Name: idx_chat_conversations_user_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_conversations_user_active ON public.chat_conversations USING btree (user_id, org_id, updated_at DESC) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_chat_conversations_user_active_surface; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_conversations_user_active_surface ON public.chat_conversations USING btree (user_id, org_id, surface, updated_at DESC) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_chat_files_message; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_files_message ON public.chat_files USING btree (message_id); + + +-- +-- Name: idx_chat_messages_conversation; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_conversation ON public.chat_messages USING btree (conversation_id, turn_seq); + + +-- +-- Name: idx_chat_messages_search; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chat_messages_search ON public.chat_messages USING gin (search_vector); + + +-- +-- Name: idx_chunks_appctx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chunks_appctx ON public.prompt_chunks USING btree (application_context_id); + + +-- +-- Name: idx_chunks_prompt_var; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_chunks_prompt_var ON public.prompt_chunks USING btree (prompt_key, variable_name); + + +-- +-- Name: idx_ci_rulesets_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_ci_rulesets_org_id ON public.ci_rulesets USING btree (org_id); + + +-- +-- Name: idx_dashboard_cache_org_updated; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_dashboard_cache_org_updated ON public.dashboard_cache USING btree (org_id, updated_at DESC); + + +-- +-- Name: idx_integration_tokens_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_integration_tokens_org_created ON public.integration_tokens USING btree (org_id, created_at); + + +-- +-- Name: idx_integration_tokens_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_integration_tokens_org_id ON public.integration_tokens USING btree (org_id); + + +-- +-- Name: idx_integration_tokens_org_provider; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_integration_tokens_org_provider ON public.integration_tokens USING btree (org_id, provider); + + +-- +-- Name: idx_learning_events_learning; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learning_events_learning ON public.learning_events USING btree (learning_id, created_at DESC); + + +-- +-- Name: idx_learning_events_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learning_events_org_created ON public.learning_events USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_learnings_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learnings_active ON public.learnings USING btree (org_id) WHERE (status = 'active'::public.learning_status); + + +-- +-- Name: idx_learnings_org_simhash; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learnings_org_simhash ON public.learnings USING btree (org_id, simhash); + + +-- +-- Name: idx_learnings_tags; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learnings_tags ON public.learnings USING gin (tags); + + +-- +-- Name: idx_learnings_tsv; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_learnings_tsv ON public.learnings USING gin (tsv); + + +-- +-- Name: idx_license_log_action; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_log_action ON public.license_log USING btree (event_type); + + +-- +-- Name: idx_license_log_processed; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_log_processed ON public.license_log USING btree (processed) WHERE (processed = false); + + +-- +-- Name: idx_license_log_razorpay; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_log_razorpay ON public.license_log USING btree (razorpay_event_id) WHERE (razorpay_event_id IS NOT NULL); + + +-- +-- Name: idx_license_log_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_log_subscription ON public.license_log USING btree (subscription_id); + + +-- +-- Name: idx_license_log_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_log_user ON public.license_log USING btree (user_id); + + +-- +-- Name: idx_license_seat_assignments_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_seat_assignments_active ON public.license_seat_assignments USING btree (is_active) WHERE (is_active = true); + + +-- +-- Name: idx_license_seat_assignments_assigned_by; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_seat_assignments_assigned_by ON public.license_seat_assignments USING btree (assigned_by_user_id); + + +-- +-- Name: idx_license_seat_assignments_user_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_license_seat_assignments_user_active ON public.license_seat_assignments USING btree (user_id) WHERE (is_active = true); + + +-- +-- Name: idx_license_state_expires_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_state_expires_at ON public.license_state USING btree (expires_at); + + +-- +-- Name: idx_license_state_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_license_state_status ON public.license_state USING btree (status); + + +-- +-- Name: idx_loc_lifecycle_log_email_pending; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_lifecycle_log_email_pending ON public.loc_lifecycle_log USING btree (notified_email, created_at) WHERE (notified_email = false); + + +-- +-- Name: idx_loc_lifecycle_log_event_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_lifecycle_log_event_type ON public.loc_lifecycle_log USING btree (event_type); + + +-- +-- Name: idx_loc_lifecycle_log_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_lifecycle_log_org_created ON public.loc_lifecycle_log USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_loc_usage_ledger_operation; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_operation ON public.loc_usage_ledger USING btree (operation_type, trigger_source); + + +-- +-- Name: idx_loc_usage_ledger_org_accounted_tokens; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_org_accounted_tokens ON public.loc_usage_ledger USING btree (org_id, accounted_at DESC) WHERE ((input_tokens IS NOT NULL) OR (output_tokens IS NOT NULL) OR (llm_cost_usd IS NOT NULL)); + + +-- +-- Name: idx_loc_usage_ledger_org_period_user_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_org_period_user_time ON public.loc_usage_ledger USING btree (org_id, billing_period_start, user_id, accounted_at DESC) WHERE ((status)::text = 'accounted'::text); + + +-- +-- Name: idx_loc_usage_ledger_org_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_org_review ON public.loc_usage_ledger USING btree (org_id, review_id) WHERE (review_id IS NOT NULL); + + +-- +-- Name: idx_loc_usage_ledger_org_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_org_time ON public.loc_usage_ledger USING btree (org_id, accounted_at DESC); + + +-- +-- Name: idx_loc_usage_ledger_org_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_loc_usage_ledger_org_user ON public.loc_usage_ledger USING btree (org_id, user_id) WHERE (user_id IS NOT NULL); + + +-- +-- Name: idx_org_billing_current_plan; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_billing_current_plan ON public.org_billing_state USING btree (current_plan_code); + + +-- +-- Name: idx_org_billing_scheduled_effective; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_billing_scheduled_effective ON public.org_billing_state USING btree (scheduled_plan_effective_at) WHERE (scheduled_plan_effective_at IS NOT NULL); + + +-- +-- Name: idx_org_discord_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_discord_configs_enabled ON public.org_discord_configs USING btree (enabled); + + +-- +-- Name: idx_org_discord_configs_guild_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_discord_configs_guild_id ON public.org_discord_configs USING btree (guild_id); + + +-- +-- Name: idx_org_discord_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_discord_configs_org_id ON public.org_discord_configs USING btree (org_id); + + +-- +-- Name: idx_org_slack_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_slack_configs_enabled ON public.org_slack_configs USING btree (enabled); + + +-- +-- Name: idx_org_slack_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_slack_configs_org_id ON public.org_slack_configs USING btree (org_id); + + +-- +-- Name: idx_org_slack_configs_team_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_slack_configs_team_id ON public.org_slack_configs USING btree (team_id); + + +-- +-- Name: idx_org_sso_configs_org_enabled; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_sso_configs_org_enabled ON public.org_sso_configs USING btree (org_id) WHERE (enabled = true); + + +-- +-- Name: idx_org_teams_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_teams_configs_enabled ON public.org_teams_configs USING btree (enabled); + + +-- +-- Name: idx_org_teams_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_org_teams_configs_org_id ON public.org_teams_configs USING btree (org_id); + + +-- +-- Name: idx_orgs_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_orgs_active ON public.orgs USING btree (is_active, created_at); + + +-- +-- Name: idx_orgs_plan; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_orgs_plan ON public.orgs USING btree (subscription_plan, is_active); + + +-- +-- Name: idx_orgs_settings; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_orgs_settings ON public.orgs USING gin (settings) WHERE (settings IS NOT NULL); + + +-- +-- Name: idx_pac_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pac_org ON public.prompt_application_context USING btree (org_id); + + +-- +-- Name: idx_pac_targeting; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pac_targeting ON public.prompt_application_context USING btree (org_id, ai_connector_id, integration_token_id, group_identifier, repository); + + +-- +-- Name: idx_plan_catalog_active_rank; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_plan_catalog_active_rank ON public.plan_catalog USING btree (active, rank); + + +-- +-- Name: idx_pull_requests_last_synced_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pull_requests_last_synced_at ON public.pull_requests USING btree (last_synced_at); + + +-- +-- Name: idx_pull_requests_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pull_requests_org_id ON public.pull_requests USING btree (org_id); + + +-- +-- Name: idx_pull_requests_org_state; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pull_requests_org_state ON public.pull_requests USING btree (org_id, state); + + +-- +-- Name: idx_pull_requests_repository_state; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_pull_requests_repository_state ON public.pull_requests USING btree (repository_id, state); + + +-- +-- Name: idx_quota_batch_settlements_org_idempotency; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_quota_batch_settlements_org_idempotency ON public.quota_batch_settlements USING btree (org_id, idempotency_key); + + +-- +-- Name: idx_quota_batch_settlements_org_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_quota_batch_settlements_org_time ON public.quota_batch_settlements USING btree (org_id, accounted_at DESC); + + +-- +-- Name: idx_quota_operation_aggregates_org_time; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_quota_operation_aggregates_org_time ON public.quota_operation_aggregates USING btree (org_id, finalized_at DESC); + + +-- +-- Name: idx_quota_policy_catalog_lookup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_quota_policy_catalog_lookup ON public.quota_policy_catalog USING btree (plan_code, provider_key) WHERE (active = true); + + +-- +-- Name: idx_recent_activity_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_created_at ON public.recent_activity USING btree (created_at DESC); + + +-- +-- Name: idx_recent_activity_dashboard; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_dashboard ON public.recent_activity USING btree (created_at DESC, activity_type); + + +-- +-- Name: idx_recent_activity_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_org_created ON public.recent_activity USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_recent_activity_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_org_id ON public.recent_activity USING btree (org_id); + + +-- +-- Name: idx_recent_activity_org_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_org_type ON public.recent_activity USING btree (org_id, activity_type); + + +-- +-- Name: idx_recent_activity_review_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_review_id ON public.recent_activity USING btree (review_id); + + +-- +-- Name: idx_recent_activity_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_recent_activity_type ON public.recent_activity USING btree (activity_type); + + +-- +-- Name: idx_repositories_connector_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_repositories_connector_id ON public.repositories USING btree (connector_id); + + +-- +-- Name: idx_repositories_last_synced_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_repositories_last_synced_at ON public.repositories USING btree (last_synced_at); + + +-- +-- Name: idx_repositories_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_repositories_org_id ON public.repositories USING btree (org_id); + + +-- +-- Name: idx_repositories_org_provider; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_repositories_org_provider ON public.repositories USING btree (org_id, provider); + + +-- +-- Name: idx_review_commits_org_ref; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_commits_org_ref ON public.review_commits USING btree (org_id, ref); + + +-- +-- Name: idx_review_commits_review_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_commits_review_id ON public.review_commits USING btree (review_id); + + +-- +-- Name: idx_review_events_org_ts; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_events_org_ts ON public.review_events USING btree (org_id, ts); + + +-- +-- Name: idx_review_events_review_ts; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_events_review_ts ON public.review_events USING btree (review_id, ts); + + +-- +-- Name: idx_review_events_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_events_type ON public.review_events USING btree (review_id, event_type, ts DESC); + + +-- +-- Name: idx_review_feedback_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_feedback_created_at ON public.review_feedback USING btree (created_at DESC); + + +-- +-- Name: idx_review_feedback_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_feedback_org_id ON public.review_feedback USING btree (org_id); + + +-- +-- Name: idx_review_feedback_review_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_feedback_review_id ON public.review_feedback USING btree (review_id) WHERE (review_id IS NOT NULL); + + +-- +-- Name: idx_review_feedback_vote_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_feedback_vote_type ON public.review_feedback USING btree (vote_type); + + +-- +-- Name: idx_review_outcomes_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_outcomes_org_created ON public.review_outcomes USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_outcomes_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_outcomes_review ON public.review_outcomes USING btree (review_id); + + +-- +-- Name: idx_review_policy_log_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_log_org ON public.review_policy_log USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_policy_log_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_log_review ON public.review_policy_log USING btree (review_id); + + +-- +-- Name: idx_review_policy_overrides_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_overrides_org ON public.review_policy_overrides USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_review_policy_overrides_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_review_policy_overrides_review ON public.review_policy_overrides USING btree (review_id); + + +-- +-- Name: idx_reviews_connector_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_connector_id ON public.reviews USING btree (connector_id); + + +-- +-- Name: idx_reviews_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_created_at ON public.reviews USING btree (created_at DESC); + + +-- +-- Name: idx_reviews_org_connector; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_org_connector ON public.reviews USING btree (org_id, connector_id); + + +-- +-- Name: idx_reviews_org_created; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_org_created ON public.reviews USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_reviews_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_org_id ON public.reviews USING btree (org_id); + + +-- +-- Name: idx_reviews_org_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_org_status ON public.reviews USING btree (org_id, status); + + +-- +-- Name: idx_reviews_provider; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_provider ON public.reviews USING btree (provider); + + +-- +-- Name: idx_reviews_pull_request_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_pull_request_id ON public.reviews USING btree (pull_request_id); + + +-- +-- Name: idx_reviews_repository; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_repository ON public.reviews USING btree (repository); + + +-- +-- Name: idx_reviews_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_reviews_status ON public.reviews USING btree (status); + + +-- +-- Name: idx_risk_feature_snapshots_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_feature_snapshots_org ON public.risk_feature_snapshots USING btree (org_id, created_at DESC); + + +-- +-- Name: idx_risk_feature_snapshots_review; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_feature_snapshots_review ON public.risk_feature_snapshots USING btree (review_id); + + +-- +-- Name: idx_risk_model_versions_org_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_risk_model_versions_org_active ON public.risk_model_versions USING btree (org_id) WHERE is_active; + + +-- +-- Name: idx_scheduled_review_configs_due; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_scheduled_review_configs_due ON public.scheduled_review_configs USING btree (next_run_at) WHERE (enabled = true); + + +-- +-- Name: idx_scheduled_review_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_scheduled_review_configs_org_id ON public.scheduled_review_configs USING btree (org_id); + + +-- +-- Name: idx_scheduled_review_runs_config_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_scheduled_review_runs_config_id ON public.scheduled_review_runs USING btree (config_id, started_at DESC); + + +-- +-- Name: idx_subscription_payments_captured; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscription_payments_captured ON public.subscription_payments USING btree (captured_at) WHERE (captured_at IS NOT NULL); + + +-- +-- Name: idx_subscription_payments_captured_bool; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscription_payments_captured_bool ON public.subscription_payments USING btree (captured); + + +-- +-- Name: idx_subscription_payments_razorpay; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscription_payments_razorpay ON public.subscription_payments USING btree (razorpay_payment_id); + + +-- +-- Name: idx_subscription_payments_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscription_payments_status ON public.subscription_payments USING btree (status); + + +-- +-- Name: idx_subscription_payments_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscription_payments_subscription ON public.subscription_payments USING btree (subscription_id); + + +-- +-- Name: idx_subscriptions_org; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_org ON public.subscriptions USING btree (org_id); + + +-- +-- Name: idx_subscriptions_owner; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_owner ON public.subscriptions USING btree (owner_user_id); + + +-- +-- Name: idx_subscriptions_payment_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_payment_status ON public.subscriptions USING btree (last_payment_status); + + +-- +-- Name: idx_subscriptions_payment_verified; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_payment_verified ON public.subscriptions USING btree (payment_verified); + + +-- +-- Name: idx_subscriptions_razorpay; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_razorpay ON public.subscriptions USING btree (razorpay_subscription_id); + + +-- +-- Name: idx_subscriptions_short_url; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_short_url ON public.subscriptions USING btree (short_url) WHERE (short_url IS NOT NULL); + + +-- +-- Name: idx_subscriptions_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_subscriptions_status ON public.subscriptions USING btree (status); + + +-- +-- Name: idx_trial_eligibility_consumed; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_trial_eligibility_consumed ON public.trial_eligibility USING btree (consumed, consumed_at DESC); + + +-- +-- Name: idx_trial_eligibility_reservation_expires; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_trial_eligibility_reservation_expires ON public.trial_eligibility USING btree (reservation_expires_at) WHERE (reservation_expires_at IS NOT NULL); + + +-- +-- Name: idx_upgrade_payment_attempts_execute_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_payment_attempts_execute_key ON public.upgrade_payment_attempts USING btree (execute_idempotency_key) WHERE (execute_idempotency_key IS NOT NULL); + + +-- +-- Name: idx_upgrade_payment_attempts_order; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_payment_attempts_order ON public.upgrade_payment_attempts USING btree (razorpay_order_id); + + +-- +-- Name: idx_upgrade_payment_attempts_org_preview; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_payment_attempts_org_preview ON public.upgrade_payment_attempts USING btree (org_id, preview_token_sha256); + + +-- +-- Name: idx_upgrade_payment_attempts_payment; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_payment_attempts_payment ON public.upgrade_payment_attempts USING btree (razorpay_payment_id) WHERE (razorpay_payment_id IS NOT NULL); + + +-- +-- Name: idx_upgrade_payment_attempts_request; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_upgrade_payment_attempts_request ON public.upgrade_payment_attempts USING btree (upgrade_request_id) WHERE (upgrade_request_id IS NOT NULL); -- --- Name: idx_audit_org_action; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_payment_attempts_status; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_audit_org_action ON public.user_management_audit USING btree (org_id, action, created_at DESC); +CREATE INDEX idx_upgrade_payment_attempts_status ON public.upgrade_payment_attempts USING btree (status); -- --- Name: idx_audit_performed_by; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_replacement_cutovers_cutover_at; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_audit_performed_by ON public.user_management_audit USING btree (performed_by_user_id, created_at DESC); +CREATE INDEX idx_upgrade_replacement_cutovers_cutover_at ON public.upgrade_replacement_cutovers USING btree (cutover_at, status); -- --- Name: idx_audit_target_time; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_replacement_cutovers_next_retry; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_audit_target_time ON public.user_management_audit USING btree (target_user_id, created_at DESC); +CREATE INDEX idx_upgrade_replacement_cutovers_next_retry ON public.upgrade_replacement_cutovers USING btree (next_retry_at) WHERE (next_retry_at IS NOT NULL); -- --- Name: idx_auth_tokens_active_sessions; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_replacement_cutovers_org_status; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_active_sessions ON public.auth_tokens USING btree (user_id, last_used_at) WHERE (((token_type)::text = 'session'::text) AND (is_active = true)); +CREATE INDEX idx_upgrade_replacement_cutovers_org_status ON public.upgrade_replacement_cutovers USING btree (org_id, status, updated_at DESC); -- --- Name: idx_auth_tokens_cleanup; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_customer_state; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_cleanup ON public.auth_tokens USING btree (token_type, expires_at, is_active); +CREATE INDEX idx_upgrade_requests_customer_state ON public.upgrade_requests USING btree (org_id, customer_state, updated_at DESC) WHERE (customer_state IS NOT NULL); -- --- Name: idx_auth_tokens_expires; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_order; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_expires ON public.auth_tokens USING btree (expires_at) WHERE (is_active = true); +CREATE INDEX idx_upgrade_requests_order ON public.upgrade_requests USING btree (razorpay_order_id) WHERE (razorpay_order_id IS NOT NULL); -- --- Name: idx_auth_tokens_hash; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_org_created; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_hash ON public.auth_tokens USING btree (token_hash) WHERE (is_active = true); +CREATE INDEX idx_upgrade_requests_org_created ON public.upgrade_requests USING btree (org_id, created_at DESC); -- --- Name: idx_auth_tokens_last_used; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_org_status; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_last_used ON public.auth_tokens USING btree (last_used_at) WHERE (is_active = true); +CREATE INDEX idx_upgrade_requests_org_status ON public.upgrade_requests USING btree (org_id, current_status, updated_at DESC); -- --- Name: idx_auth_tokens_refresh; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_payment; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_refresh ON public.auth_tokens USING btree (token_hash, token_type) WHERE (((token_type)::text = 'refresh'::text) AND (is_active = true)); +CREATE INDEX idx_upgrade_requests_payment ON public.upgrade_requests USING btree (razorpay_payment_id) WHERE (razorpay_payment_id IS NOT NULL); -- --- Name: idx_auth_tokens_type_user; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_pending_apply; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_type_user ON public.auth_tokens USING btree (token_type, user_id) WHERE (is_active = true); +CREATE INDEX idx_upgrade_requests_pending_apply ON public.upgrade_requests USING btree (current_status, plan_grant_applied, updated_at) WHERE (((current_status)::text = 'resolved'::text) AND (plan_grant_applied = false)); -- --- Name: idx_auth_tokens_user_id; Type: INDEX; Schema: public; Owner: - +-- Name: idx_upgrade_requests_subscription; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auth_tokens_user_id ON public.auth_tokens USING btree (user_id); +CREATE INDEX idx_upgrade_requests_subscription ON public.upgrade_requests USING btree (razorpay_subscription_id) WHERE (razorpay_subscription_id IS NOT NULL); -- --- Name: idx_auto_approve_policies_org; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_identities_lookup; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_auto_approve_policies_org ON public.auto_approve_policies USING btree (org_id); +CREATE INDEX idx_user_identities_lookup ON public.user_identities USING btree (org_id, kind, value); -- --- Name: idx_auto_approve_policies_org_default; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_identities_org_user; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX idx_auto_approve_policies_org_default ON public.auto_approve_policies USING btree (org_id) WHERE (repository_id IS NULL); +CREATE INDEX idx_user_identities_org_user ON public.user_identities USING btree (org_id, user_id); -- --- Name: idx_auto_approve_policies_org_repo; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_role_history_changed_by; Type: INDEX; Schema: public; Owner: - -- -CREATE UNIQUE INDEX idx_auto_approve_policies_org_repo ON public.auto_approve_policies USING btree (org_id, repository_id) WHERE (repository_id IS NOT NULL); +CREATE INDEX idx_user_role_history_changed_by ON public.user_role_history USING btree (changed_by_user_id, created_at); -- --- Name: idx_billing_notification_outbox_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_role_history_org; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_billing_notification_outbox_org_created ON public.billing_notification_outbox USING btree (org_id, created_at DESC); +CREATE INDEX idx_user_role_history_org ON public.user_role_history USING btree (org_id, created_at); -- --- Name: idx_billing_notification_outbox_pending; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_role_history_user; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_billing_notification_outbox_pending ON public.billing_notification_outbox USING btree (status, send_after, created_at) WHERE ((status)::text = ANY (ARRAY[('pending'::character varying)::text, ('failed'::character varying)::text])); +CREATE INDEX idx_user_role_history_user ON public.user_role_history USING btree (user_id, created_at); -- --- Name: idx_blast_radius_hunks_org_combined; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_license_expires; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_blast_radius_hunks_org_combined ON public.blast_radius_hunks USING btree (org_id, combined DESC); +CREATE INDEX idx_user_roles_license_expires ON public.user_roles USING btree (license_expires_at) WHERE (license_expires_at IS NOT NULL); -- --- Name: idx_blast_radius_hunks_review_id; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_org_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_blast_radius_hunks_review_id ON public.blast_radius_hunks USING btree (review_id); +CREATE INDEX idx_user_roles_org_id ON public.user_roles USING btree (org_id); -- --- Name: idx_chat_charts_message; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_plan_type; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_charts_message ON public.chat_charts USING btree (message_id); +CREATE INDEX idx_user_roles_plan_type ON public.user_roles USING btree (plan_type); -- --- Name: idx_chat_conversations_search; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_subscription; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_conversations_search ON public.chat_conversations USING gin (search_vector); +CREATE INDEX idx_user_roles_subscription ON public.user_roles USING btree (active_subscription_id) WHERE (active_subscription_id IS NOT NULL); -- --- Name: idx_chat_conversations_title_trgm; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_user_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_conversations_title_trgm ON public.chat_conversations USING gin (title public.gin_trgm_ops); +CREATE INDEX idx_user_roles_user_id ON public.user_roles USING btree (user_id); -- --- Name: idx_chat_conversations_user_active; Type: INDEX; Schema: public; Owner: - +-- Name: idx_user_roles_user_org; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_conversations_user_active ON public.chat_conversations USING btree (user_id, org_id, updated_at DESC) WHERE (deleted_at IS NULL); +CREATE INDEX idx_user_roles_user_org ON public.user_roles USING btree (user_id, org_id); -- --- Name: idx_chat_conversations_user_active_surface; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_created_by; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_conversations_user_active_surface ON public.chat_conversations USING btree (user_id, org_id, surface, updated_at DESC) WHERE (deleted_at IS NULL); +CREATE INDEX idx_users_created_by ON public.users USING btree (created_by_user_id, created_at DESC); -- --- Name: idx_chat_files_message; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_email; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_files_message ON public.chat_files USING btree (message_id); +CREATE INDEX idx_users_email ON public.users USING btree (email); -- --- Name: idx_chat_messages_conversation; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_last_login; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_messages_conversation ON public.chat_messages USING btree (conversation_id, turn_seq); +CREATE INDEX idx_users_last_login ON public.users USING btree (last_login_at DESC) WHERE (is_active = true); -- --- Name: idx_chat_messages_search; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_onboarding_api_key; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chat_messages_search ON public.chat_messages USING gin (search_vector); +CREATE INDEX idx_users_onboarding_api_key ON public.users USING btree (onboarding_api_key) WHERE (onboarding_api_key IS NOT NULL); -- --- Name: idx_chunks_appctx; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_org_active; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chunks_appctx ON public.prompt_chunks USING btree (application_context_id); +CREATE INDEX idx_users_org_active ON public.users USING btree (id) WHERE (is_active = true); -- --- Name: idx_chunks_prompt_var; Type: INDEX; Schema: public; Owner: - +-- Name: idx_users_password_reset; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_chunks_prompt_var ON public.prompt_chunks USING btree (prompt_key, variable_name); +CREATE INDEX idx_users_password_reset ON public.users USING btree (id) WHERE (password_reset_required = true); -- --- Name: idx_ci_rulesets_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: idx_webhook_registry_integration_token_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_ci_rulesets_org_id ON public.ci_rulesets USING btree (org_id); +CREATE INDEX idx_webhook_registry_integration_token_id ON public.webhook_registry USING btree (integration_token_id); -- --- Name: idx_dashboard_cache_org_updated; Type: INDEX; Schema: public; Owner: - +-- Name: idx_webhook_registry_org_id; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_dashboard_cache_org_updated ON public.dashboard_cache USING btree (org_id, updated_at DESC); +CREATE INDEX idx_webhook_registry_org_id ON public.webhook_registry USING btree (org_id); -- --- Name: idx_integration_tokens_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: idx_webhook_registry_org_provider; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_integration_tokens_org_created ON public.integration_tokens USING btree (org_id, created_at); +CREATE INDEX idx_webhook_registry_org_provider ON public.webhook_registry USING btree (org_id, provider); -- --- Name: idx_integration_tokens_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: idx_webhook_registry_org_status; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_integration_tokens_org_id ON public.integration_tokens USING btree (org_id); +CREATE INDEX idx_webhook_registry_org_status ON public.webhook_registry USING btree (org_id, status); -- --- Name: idx_integration_tokens_org_provider; Type: INDEX; Schema: public; Owner: - +-- Name: idx_webhook_registry_provider_project; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_integration_tokens_org_provider ON public.integration_tokens USING btree (org_id, provider); +CREATE INDEX idx_webhook_registry_provider_project ON public.webhook_registry USING btree (provider, provider_project_id); -- --- Name: idx_learning_events_learning; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_args_index; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learning_events_learning ON public.learning_events USING btree (learning_id, created_at DESC); +CREATE INDEX river_job_args_index ON public.river_job USING gin (args jsonb_path_ops); -- --- Name: idx_learning_events_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_kind; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learning_events_org_created ON public.learning_events USING btree (org_id, created_at DESC); +CREATE INDEX river_job_kind ON public.river_job USING btree (kind); -- --- Name: idx_learnings_active; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_prioritized_fetching_index; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learnings_active ON public.learnings USING btree (org_id) WHERE (status = 'active'::public.learning_status); +CREATE INDEX river_job_prioritized_fetching_index ON public.river_job USING btree (state, queue, priority, scheduled_at, id); -- --- Name: idx_learnings_org_simhash; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_scheduled_at_index; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learnings_org_simhash ON public.learnings USING btree (org_id, simhash); +CREATE INDEX river_job_scheduled_at_index ON public.river_job USING btree (scheduled_at) WHERE (state = 'scheduled'::public.river_job_state); -- --- Name: idx_learnings_tags; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_state_and_finalized_at_index; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learnings_tags ON public.learnings USING gin (tags); +CREATE INDEX river_job_state_and_finalized_at_index ON public.river_job USING btree (state, finalized_at) WHERE (finalized_at IS NOT NULL); -- --- Name: idx_learnings_tsv; Type: INDEX; Schema: public; Owner: - +-- Name: river_job_unique_idx; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_learnings_tsv ON public.learnings USING gin (tsv); +CREATE UNIQUE INDEX river_job_unique_idx ON public.river_job USING btree (unique_key) WHERE ((unique_key IS NOT NULL) AND (unique_states IS NOT NULL) AND public.river_job_state_in_bitmask(unique_states, state)); -- --- Name: idx_license_log_action; Type: INDEX; Schema: public; Owner: - +-- Name: uq_integration_tokens_github_app_installation_org; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_license_log_action ON public.license_log USING btree (event_type); +CREATE UNIQUE INDEX uq_integration_tokens_github_app_installation_org ON public.integration_tokens USING btree (provider_url, provider_app_id, org_id) WHERE (token_type = 'GITHUB_APP'::text); -- --- Name: idx_license_log_processed; Type: INDEX; Schema: public; Owner: - +-- Name: uq_pull_requests_repo_number; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_license_log_processed ON public.license_log USING btree (processed) WHERE (processed = false); +CREATE UNIQUE INDEX uq_pull_requests_repo_number ON public.pull_requests USING btree (repository_id, number); -- --- Name: idx_license_log_razorpay; Type: INDEX; Schema: public; Owner: - +-- Name: uq_system_default_ai_configs_active_tier; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_license_log_razorpay ON public.license_log USING btree (razorpay_event_id) WHERE (razorpay_event_id IS NOT NULL); +CREATE UNIQUE INDEX uq_system_default_ai_configs_active_tier ON public.system_default_ai_configs USING btree (tier_name) WHERE is_active; -- --- Name: idx_license_log_subscription; Type: INDEX; Schema: public; Owner: - +-- Name: ux_license_state_singleton; Type: INDEX; Schema: public; Owner: - -- -CREATE INDEX idx_license_log_subscription ON public.license_log USING btree (subscription_id); +CREATE UNIQUE INDEX ux_license_state_singleton ON public.license_state USING btree (id); -- --- Name: idx_license_log_user; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_license_log_user ON public.license_log USING btree (user_id); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2026_10_id_idx; -- --- Name: idx_license_seat_assignments_active; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_license_seat_assignments_active ON public.license_seat_assignments USING btree (is_active) WHERE (is_active = true); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2026_10_org_id_action_occurred_at_idx; -- --- Name: idx_license_seat_assignments_assigned_by; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_license_seat_assignments_assigned_by ON public.license_seat_assignments USING btree (assigned_by_user_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2026_10_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_license_seat_assignments_user_active; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE UNIQUE INDEX idx_license_seat_assignments_user_active ON public.license_seat_assignments USING btree (user_id) WHERE (is_active = true); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2026_10_org_id_category_occurred_at_idx; -- --- Name: idx_license_state_expires_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_license_state_expires_at ON public.license_state USING btree (expires_at); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2026_10_org_id_occurred_at_id_idx; -- --- Name: idx_license_state_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_license_state_status ON public.license_state USING btree (status); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2026_10_org_id_outcome_occurred_at_idx; -- --- Name: idx_loc_lifecycle_log_email_pending; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_lifecycle_log_email_pending ON public.loc_lifecycle_log USING btree (notified_email, created_at) WHERE (notified_email = false); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2026_10_org_id_seq_idx; -- --- Name: idx_loc_lifecycle_log_event_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_lifecycle_log_event_type ON public.loc_lifecycle_log USING btree (event_type); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2026_10_org_id_severity_occurred_at_idx; -- --- Name: idx_loc_lifecycle_log_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_lifecycle_log_org_created ON public.loc_lifecycle_log USING btree (org_id, created_at DESC); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2026_10_pkey; -- --- Name: idx_loc_usage_ledger_operation; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_10_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_operation ON public.loc_usage_ledger USING btree (operation_type, trigger_source); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2026_10_source_legacy_id_occurred_at_idx; -- --- Name: idx_loc_usage_ledger_org_accounted_tokens; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_org_accounted_tokens ON public.loc_usage_ledger USING btree (org_id, accounted_at DESC) WHERE ((input_tokens IS NOT NULL) OR (output_tokens IS NOT NULL) OR (llm_cost_usd IS NOT NULL)); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2026_11_id_idx; -- --- Name: idx_loc_usage_ledger_org_period_user_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_org_period_user_time ON public.loc_usage_ledger USING btree (org_id, billing_period_start, user_id, accounted_at DESC) WHERE ((status)::text = 'accounted'::text); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2026_11_org_id_action_occurred_at_idx; -- --- Name: idx_loc_usage_ledger_org_review; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_org_review ON public.loc_usage_ledger USING btree (org_id, review_id) WHERE (review_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2026_11_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_loc_usage_ledger_org_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_org_time ON public.loc_usage_ledger USING btree (org_id, accounted_at DESC); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2026_11_org_id_category_occurred_at_idx; -- --- Name: idx_loc_usage_ledger_org_user; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_loc_usage_ledger_org_user ON public.loc_usage_ledger USING btree (org_id, user_id) WHERE (user_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2026_11_org_id_occurred_at_id_idx; -- --- Name: idx_org_billing_current_plan; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_billing_current_plan ON public.org_billing_state USING btree (current_plan_code); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2026_11_org_id_outcome_occurred_at_idx; -- --- Name: idx_org_billing_scheduled_effective; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_billing_scheduled_effective ON public.org_billing_state USING btree (scheduled_plan_effective_at) WHERE (scheduled_plan_effective_at IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2026_11_org_id_seq_idx; -- --- Name: idx_org_discord_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_discord_configs_enabled ON public.org_discord_configs USING btree (enabled); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2026_11_org_id_severity_occurred_at_idx; -- --- Name: idx_org_discord_configs_guild_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_discord_configs_guild_id ON public.org_discord_configs USING btree (guild_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2026_11_pkey; -- --- Name: idx_org_discord_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_11_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_discord_configs_org_id ON public.org_discord_configs USING btree (org_id); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2026_11_source_legacy_id_occurred_at_idx; -- --- Name: idx_org_slack_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_slack_configs_enabled ON public.org_slack_configs USING btree (enabled); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2026_12_id_idx; -- --- Name: idx_org_slack_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_slack_configs_org_id ON public.org_slack_configs USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2026_12_org_id_action_occurred_at_idx; -- --- Name: idx_org_slack_configs_team_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_slack_configs_team_id ON public.org_slack_configs USING btree (team_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2026_12_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_org_teams_configs_enabled; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_teams_configs_enabled ON public.org_teams_configs USING btree (enabled); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2026_12_org_id_category_occurred_at_idx; -- --- Name: idx_org_teams_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_org_teams_configs_org_id ON public.org_teams_configs USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2026_12_org_id_occurred_at_id_idx; -- --- Name: idx_orgs_active; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2026_12_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_orgs_active ON public.orgs USING btree (is_active, created_at); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2026_12_org_id_outcome_occurred_at_idx; + + +-- +-- Name: audit_events_2026_12_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2026_12_org_id_seq_idx; + + +-- +-- Name: audit_events_2026_12_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2026_12_org_id_severity_occurred_at_idx; + + +-- +-- Name: audit_events_2026_12_pkey; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2026_12_pkey; + + +-- +-- Name: audit_events_2026_12_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2026_12_source_legacy_id_occurred_at_idx; + + +-- +-- Name: audit_events_2027_01_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_01_id_idx; + + +-- +-- Name: audit_events_2027_01_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_01_org_id_action_occurred_at_idx; -- --- Name: idx_orgs_plan; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_orgs_plan ON public.orgs USING btree (subscription_plan, is_active); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_01_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_orgs_settings; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_orgs_settings ON public.orgs USING gin (settings) WHERE (settings IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_01_org_id_category_occurred_at_idx; -- --- Name: idx_pac_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pac_org ON public.prompt_application_context USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_01_org_id_occurred_at_id_idx; -- --- Name: idx_pac_targeting; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pac_targeting ON public.prompt_application_context USING btree (org_id, ai_connector_id, integration_token_id, group_identifier, repository); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_01_org_id_outcome_occurred_at_idx; -- --- Name: idx_plan_catalog_active_rank; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_plan_catalog_active_rank ON public.plan_catalog USING btree (active, rank); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_01_org_id_seq_idx; -- --- Name: idx_pull_requests_last_synced_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pull_requests_last_synced_at ON public.pull_requests USING btree (last_synced_at); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_01_org_id_severity_occurred_at_idx; -- --- Name: idx_pull_requests_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pull_requests_org_id ON public.pull_requests USING btree (org_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_01_pkey; -- --- Name: idx_pull_requests_org_state; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_01_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pull_requests_org_state ON public.pull_requests USING btree (org_id, state); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_01_source_legacy_id_occurred_at_idx; -- --- Name: idx_pull_requests_repository_state; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_pull_requests_repository_state ON public.pull_requests USING btree (repository_id, state); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_02_id_idx; -- --- Name: idx_quota_batch_settlements_org_idempotency; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_quota_batch_settlements_org_idempotency ON public.quota_batch_settlements USING btree (org_id, idempotency_key); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_02_org_id_action_occurred_at_idx; -- --- Name: idx_quota_batch_settlements_org_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_quota_batch_settlements_org_time ON public.quota_batch_settlements USING btree (org_id, accounted_at DESC); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_02_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_quota_operation_aggregates_org_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_quota_operation_aggregates_org_time ON public.quota_operation_aggregates USING btree (org_id, finalized_at DESC); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_02_org_id_category_occurred_at_idx; -- --- Name: idx_quota_policy_catalog_lookup; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_quota_policy_catalog_lookup ON public.quota_policy_catalog USING btree (plan_code, provider_key) WHERE (active = true); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_02_org_id_occurred_at_id_idx; -- --- Name: idx_recent_activity_created_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_created_at ON public.recent_activity USING btree (created_at DESC); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_02_org_id_outcome_occurred_at_idx; -- --- Name: idx_recent_activity_dashboard; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_dashboard ON public.recent_activity USING btree (created_at DESC, activity_type); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_02_org_id_seq_idx; -- --- Name: idx_recent_activity_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_org_created ON public.recent_activity USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_02_org_id_severity_occurred_at_idx; -- --- Name: idx_recent_activity_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_org_id ON public.recent_activity USING btree (org_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_02_pkey; -- --- Name: idx_recent_activity_org_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_02_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_org_type ON public.recent_activity USING btree (org_id, activity_type); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_02_source_legacy_id_occurred_at_idx; -- --- Name: idx_recent_activity_review_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_review_id ON public.recent_activity USING btree (review_id); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_03_id_idx; -- --- Name: idx_recent_activity_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_recent_activity_type ON public.recent_activity USING btree (activity_type); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_03_org_id_action_occurred_at_idx; -- --- Name: idx_repositories_connector_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_repositories_connector_id ON public.repositories USING btree (connector_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_03_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_repositories_last_synced_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_repositories_last_synced_at ON public.repositories USING btree (last_synced_at); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_03_org_id_category_occurred_at_idx; -- --- Name: idx_repositories_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_repositories_org_id ON public.repositories USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_03_org_id_occurred_at_id_idx; -- --- Name: idx_repositories_org_provider; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_repositories_org_provider ON public.repositories USING btree (org_id, provider); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_03_org_id_outcome_occurred_at_idx; -- --- Name: idx_review_commits_org_ref; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_commits_org_ref ON public.review_commits USING btree (org_id, ref); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_03_org_id_seq_idx; -- --- Name: idx_review_commits_review_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_commits_review_id ON public.review_commits USING btree (review_id); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_03_org_id_severity_occurred_at_idx; -- --- Name: idx_review_events_org_ts; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_events_org_ts ON public.review_events USING btree (org_id, ts); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_03_pkey; -- --- Name: idx_review_events_review_ts; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_03_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_events_review_ts ON public.review_events USING btree (review_id, ts); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_03_source_legacy_id_occurred_at_idx; -- --- Name: idx_review_events_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_events_type ON public.review_events USING btree (review_id, event_type, ts DESC); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_04_id_idx; -- --- Name: idx_review_feedback_created_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_feedback_created_at ON public.review_feedback USING btree (created_at DESC); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_04_org_id_action_occurred_at_idx; -- --- Name: idx_review_feedback_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_feedback_org_id ON public.review_feedback USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_04_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_review_feedback_review_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_feedback_review_id ON public.review_feedback USING btree (review_id) WHERE (review_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_04_org_id_category_occurred_at_idx; -- --- Name: idx_review_feedback_vote_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_feedback_vote_type ON public.review_feedback USING btree (vote_type); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_04_org_id_occurred_at_id_idx; -- --- Name: idx_review_outcomes_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_outcomes_org_created ON public.review_outcomes USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_04_org_id_outcome_occurred_at_idx; -- --- Name: idx_review_outcomes_review; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_outcomes_review ON public.review_outcomes USING btree (review_id); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_04_org_id_seq_idx; -- --- Name: idx_review_policy_log_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_policy_log_org ON public.review_policy_log USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_04_org_id_severity_occurred_at_idx; -- --- Name: idx_review_policy_log_review; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_policy_log_review ON public.review_policy_log USING btree (review_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_04_pkey; -- --- Name: idx_review_policy_overrides_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_04_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_policy_overrides_org ON public.review_policy_overrides USING btree (org_id, created_at DESC); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_04_source_legacy_id_occurred_at_idx; -- --- Name: idx_review_policy_overrides_review; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_review_policy_overrides_review ON public.review_policy_overrides USING btree (review_id); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_05_id_idx; -- --- Name: idx_reviews_connector_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_connector_id ON public.reviews USING btree (connector_id); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_05_org_id_action_occurred_at_idx; -- --- Name: idx_reviews_created_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_created_at ON public.reviews USING btree (created_at DESC); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_05_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_reviews_org_connector; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_org_connector ON public.reviews USING btree (org_id, connector_id); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_05_org_id_category_occurred_at_idx; -- --- Name: idx_reviews_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_org_created ON public.reviews USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_05_org_id_occurred_at_id_idx; -- --- Name: idx_reviews_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_org_id ON public.reviews USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_05_org_id_outcome_occurred_at_idx; -- --- Name: idx_reviews_org_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_org_status ON public.reviews USING btree (org_id, status); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_05_org_id_seq_idx; -- --- Name: idx_reviews_provider; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_provider ON public.reviews USING btree (provider); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_05_org_id_severity_occurred_at_idx; -- --- Name: idx_reviews_pull_request_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_pull_request_id ON public.reviews USING btree (pull_request_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_05_pkey; -- --- Name: idx_reviews_repository; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_05_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_repository ON public.reviews USING btree (repository); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_05_source_legacy_id_occurred_at_idx; -- --- Name: idx_reviews_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_reviews_status ON public.reviews USING btree (status); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_06_id_idx; -- --- Name: idx_risk_feature_snapshots_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_risk_feature_snapshots_org ON public.risk_feature_snapshots USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_06_org_id_action_occurred_at_idx; -- --- Name: idx_risk_feature_snapshots_review; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_risk_feature_snapshots_review ON public.risk_feature_snapshots USING btree (review_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_06_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_risk_model_versions_org_active; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_risk_model_versions_org_active ON public.risk_model_versions USING btree (org_id) WHERE is_active; +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_06_org_id_category_occurred_at_idx; -- --- Name: idx_scheduled_review_configs_due; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_scheduled_review_configs_due ON public.scheduled_review_configs USING btree (next_run_at) WHERE (enabled = true); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_06_org_id_occurred_at_id_idx; -- --- Name: idx_scheduled_review_configs_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_scheduled_review_configs_org_id ON public.scheduled_review_configs USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_06_org_id_outcome_occurred_at_idx; -- --- Name: idx_scheduled_review_runs_config_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_scheduled_review_runs_config_id ON public.scheduled_review_runs USING btree (config_id, started_at DESC); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_06_org_id_seq_idx; -- --- Name: idx_subscription_payments_captured; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscription_payments_captured ON public.subscription_payments USING btree (captured_at) WHERE (captured_at IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_06_org_id_severity_occurred_at_idx; -- --- Name: idx_subscription_payments_captured_bool; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscription_payments_captured_bool ON public.subscription_payments USING btree (captured); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_06_pkey; -- --- Name: idx_subscription_payments_razorpay; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_06_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscription_payments_razorpay ON public.subscription_payments USING btree (razorpay_payment_id); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_06_source_legacy_id_occurred_at_idx; -- --- Name: idx_subscription_payments_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscription_payments_status ON public.subscription_payments USING btree (status); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_07_id_idx; -- --- Name: idx_subscription_payments_subscription; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscription_payments_subscription ON public.subscription_payments USING btree (subscription_id); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_07_org_id_action_occurred_at_idx; -- --- Name: idx_subscriptions_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_org ON public.subscriptions USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_07_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_subscriptions_owner; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_owner ON public.subscriptions USING btree (owner_user_id); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_07_org_id_category_occurred_at_idx; -- --- Name: idx_subscriptions_payment_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_payment_status ON public.subscriptions USING btree (last_payment_status); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_07_org_id_occurred_at_id_idx; -- --- Name: idx_subscriptions_payment_verified; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_payment_verified ON public.subscriptions USING btree (payment_verified); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_07_org_id_outcome_occurred_at_idx; -- --- Name: idx_subscriptions_razorpay; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_razorpay ON public.subscriptions USING btree (razorpay_subscription_id); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_07_org_id_seq_idx; -- --- Name: idx_subscriptions_short_url; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_short_url ON public.subscriptions USING btree (short_url) WHERE (short_url IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_07_org_id_severity_occurred_at_idx; -- --- Name: idx_subscriptions_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_subscriptions_status ON public.subscriptions USING btree (status); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_07_pkey; -- --- Name: idx_trial_eligibility_consumed; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_07_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_trial_eligibility_consumed ON public.trial_eligibility USING btree (consumed, consumed_at DESC); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_07_source_legacy_id_occurred_at_idx; -- --- Name: idx_trial_eligibility_reservation_expires; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_trial_eligibility_reservation_expires ON public.trial_eligibility USING btree (reservation_expires_at) WHERE (reservation_expires_at IS NOT NULL); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_08_id_idx; -- --- Name: idx_upgrade_payment_attempts_execute_key; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_execute_key ON public.upgrade_payment_attempts USING btree (execute_idempotency_key) WHERE (execute_idempotency_key IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_08_org_id_action_occurred_at_idx; -- --- Name: idx_upgrade_payment_attempts_order; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_order ON public.upgrade_payment_attempts USING btree (razorpay_order_id); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_08_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_upgrade_payment_attempts_org_preview; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_org_preview ON public.upgrade_payment_attempts USING btree (org_id, preview_token_sha256); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_08_org_id_category_occurred_at_idx; -- --- Name: idx_upgrade_payment_attempts_payment; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_payment ON public.upgrade_payment_attempts USING btree (razorpay_payment_id) WHERE (razorpay_payment_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_08_org_id_occurred_at_id_idx; -- --- Name: idx_upgrade_payment_attempts_request; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_request ON public.upgrade_payment_attempts USING btree (upgrade_request_id) WHERE (upgrade_request_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_08_org_id_outcome_occurred_at_idx; -- --- Name: idx_upgrade_payment_attempts_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_payment_attempts_status ON public.upgrade_payment_attempts USING btree (status); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_08_org_id_seq_idx; -- --- Name: idx_upgrade_replacement_cutovers_cutover_at; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_replacement_cutovers_cutover_at ON public.upgrade_replacement_cutovers USING btree (cutover_at, status); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_08_org_id_severity_occurred_at_idx; -- --- Name: idx_upgrade_replacement_cutovers_next_retry; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_replacement_cutovers_next_retry ON public.upgrade_replacement_cutovers USING btree (next_retry_at) WHERE (next_retry_at IS NOT NULL); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_08_pkey; -- --- Name: idx_upgrade_replacement_cutovers_org_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_08_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_replacement_cutovers_org_status ON public.upgrade_replacement_cutovers USING btree (org_id, status, updated_at DESC); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_08_source_legacy_id_occurred_at_idx; -- --- Name: idx_upgrade_request_events_org_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_request_events_org_time ON public.upgrade_request_events USING btree (org_id, event_time DESC); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_09_id_idx; -- --- Name: idx_upgrade_request_events_request_time; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_request_events_request_time ON public.upgrade_request_events USING btree (upgrade_request_id, event_time DESC); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_09_org_id_action_occurred_at_idx; -- --- Name: idx_upgrade_requests_customer_state; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_customer_state ON public.upgrade_requests USING btree (org_id, customer_state, updated_at DESC) WHERE (customer_state IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_09_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_upgrade_requests_order; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_order ON public.upgrade_requests USING btree (razorpay_order_id) WHERE (razorpay_order_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_09_org_id_category_occurred_at_idx; -- --- Name: idx_upgrade_requests_org_created; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_org_created ON public.upgrade_requests USING btree (org_id, created_at DESC); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_09_org_id_occurred_at_id_idx; -- --- Name: idx_upgrade_requests_org_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_org_status ON public.upgrade_requests USING btree (org_id, current_status, updated_at DESC); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_09_org_id_outcome_occurred_at_idx; -- --- Name: idx_upgrade_requests_payment; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_payment ON public.upgrade_requests USING btree (razorpay_payment_id) WHERE (razorpay_payment_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_09_org_id_seq_idx; -- --- Name: idx_upgrade_requests_pending_apply; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_pending_apply ON public.upgrade_requests USING btree (current_status, plan_grant_applied, updated_at) WHERE (((current_status)::text = 'resolved'::text) AND (plan_grant_applied = false)); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_09_org_id_severity_occurred_at_idx; -- --- Name: idx_upgrade_requests_subscription; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_upgrade_requests_subscription ON public.upgrade_requests USING btree (razorpay_subscription_id) WHERE (razorpay_subscription_id IS NOT NULL); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_09_pkey; -- --- Name: idx_user_identities_lookup; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_09_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_identities_lookup ON public.user_identities USING btree (org_id, kind, value); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_09_source_legacy_id_occurred_at_idx; -- --- Name: idx_user_identities_org_user; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_identities_org_user ON public.user_identities USING btree (org_id, user_id); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_10_id_idx; -- --- Name: idx_user_role_history_changed_by; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_role_history_changed_by ON public.user_role_history USING btree (changed_by_user_id, created_at); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_10_org_id_action_occurred_at_idx; -- --- Name: idx_user_role_history_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_role_history_org ON public.user_role_history USING btree (org_id, created_at); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_10_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_user_role_history_user; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_role_history_user ON public.user_role_history USING btree (user_id, created_at); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_10_org_id_category_occurred_at_idx; -- --- Name: idx_user_roles_license_expires; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_license_expires ON public.user_roles USING btree (license_expires_at) WHERE (license_expires_at IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_10_org_id_occurred_at_id_idx; -- --- Name: idx_user_roles_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_org_id ON public.user_roles USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_10_org_id_outcome_occurred_at_idx; -- --- Name: idx_user_roles_plan_type; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_plan_type ON public.user_roles USING btree (plan_type); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_10_org_id_seq_idx; -- --- Name: idx_user_roles_subscription; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_subscription ON public.user_roles USING btree (active_subscription_id) WHERE (active_subscription_id IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_10_org_id_severity_occurred_at_idx; -- --- Name: idx_user_roles_user_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_user_id ON public.user_roles USING btree (user_id); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_10_pkey; -- --- Name: idx_user_roles_user_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_10_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_user_roles_user_org ON public.user_roles USING btree (user_id, org_id); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_10_source_legacy_id_occurred_at_idx; -- --- Name: idx_users_created_by; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_created_by ON public.users USING btree (created_by_user_id, created_at DESC); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_2027_11_id_idx; -- --- Name: idx_users_email; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_email ON public.users USING btree (email); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_2027_11_org_id_action_occurred_at_idx; -- --- Name: idx_users_last_login; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_last_login ON public.users USING btree (last_login_at DESC) WHERE (is_active = true); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_2027_11_org_id_actor_user_id_occurred_at_idx; -- --- Name: idx_users_onboarding_api_key; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_onboarding_api_key ON public.users USING btree (onboarding_api_key) WHERE (onboarding_api_key IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_2027_11_org_id_category_occurred_at_idx; -- --- Name: idx_users_org_active; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_org_active ON public.users USING btree (id) WHERE (is_active = true); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_2027_11_org_id_occurred_at_id_idx; -- --- Name: idx_users_password_reset; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_users_password_reset ON public.users USING btree (id) WHERE (password_reset_required = true); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_2027_11_org_id_outcome_occurred_at_idx; -- --- Name: idx_webhook_registry_integration_token_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_webhook_registry_integration_token_id ON public.webhook_registry USING btree (integration_token_id); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_2027_11_org_id_seq_idx; -- --- Name: idx_webhook_registry_org_id; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_webhook_registry_org_id ON public.webhook_registry USING btree (org_id); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_2027_11_org_id_severity_occurred_at_idx; -- --- Name: idx_webhook_registry_org_provider; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_webhook_registry_org_provider ON public.webhook_registry USING btree (org_id, provider); +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_2027_11_pkey; -- --- Name: idx_webhook_registry_org_status; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_2027_11_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_webhook_registry_org_status ON public.webhook_registry USING btree (org_id, status); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_2027_11_source_legacy_id_occurred_at_idx; -- --- Name: idx_webhook_registry_provider_project; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX idx_webhook_registry_provider_project ON public.webhook_registry USING btree (provider, provider_project_id); +ALTER INDEX public.idx_audit_events_id ATTACH PARTITION public.audit_events_default_id_idx; -- --- Name: river_job_args_index; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_action_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX river_job_args_index ON public.river_job USING gin (args); +ALTER INDEX public.idx_audit_events_org_action_time ATTACH PARTITION public.audit_events_default_org_id_action_occurred_at_idx; -- --- Name: river_job_kind; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_actor_user_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX river_job_kind ON public.river_job USING btree (kind); +ALTER INDEX public.idx_audit_events_org_actor_time ATTACH PARTITION public.audit_events_default_org_id_actor_user_id_occurred_at_idx; -- --- Name: river_job_metadata_index; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_category_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX river_job_metadata_index ON public.river_job USING gin (metadata); +ALTER INDEX public.idx_audit_events_org_category_time ATTACH PARTITION public.audit_events_default_org_id_category_occurred_at_idx; -- --- Name: river_job_prioritized_fetching_index; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_occurred_at_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX river_job_prioritized_fetching_index ON public.river_job USING btree (state, queue, priority, scheduled_at, id); +ALTER INDEX public.idx_audit_events_org_time ATTACH PARTITION public.audit_events_default_org_id_occurred_at_id_idx; -- --- Name: river_job_state_and_finalized_at_index; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_outcome_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE INDEX river_job_state_and_finalized_at_index ON public.river_job USING btree (state, finalized_at) WHERE (finalized_at IS NOT NULL); +ALTER INDEX public.idx_audit_events_org_outcome_time ATTACH PARTITION public.audit_events_default_org_id_outcome_occurred_at_idx; -- --- Name: river_job_unique_idx; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_seq_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE UNIQUE INDEX river_job_unique_idx ON public.river_job USING btree (unique_key) WHERE ((unique_key IS NOT NULL) AND (unique_states IS NOT NULL) AND public.river_job_state_in_bitmask(unique_states, state)); +ALTER INDEX public.idx_audit_events_org_seq ATTACH PARTITION public.audit_events_default_org_id_seq_idx; -- --- Name: uq_integration_tokens_github_app_installation_org; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_org_id_severity_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE UNIQUE INDEX uq_integration_tokens_github_app_installation_org ON public.integration_tokens USING btree (provider_url, provider_app_id, org_id) WHERE (token_type = 'GITHUB_APP'::text); +ALTER INDEX public.idx_audit_events_org_severity_time ATTACH PARTITION public.audit_events_default_org_id_severity_occurred_at_idx; -- --- Name: uq_system_default_ai_configs_active_tier; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_pkey; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE UNIQUE INDEX uq_system_default_ai_configs_active_tier ON public.system_default_ai_configs USING btree (tier_name) WHERE is_active; +ALTER INDEX public.audit_events_pkey ATTACH PARTITION public.audit_events_default_pkey; -- --- Name: uq_pull_requests_repo_number; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events_default_source_legacy_id_occurred_at_idx; Type: INDEX ATTACH; Schema: public; Owner: - -- -CREATE UNIQUE INDEX uq_pull_requests_repo_number ON public.pull_requests USING btree (repository_id, number); +ALTER INDEX public.uq_audit_events_legacy ATTACH PARTITION public.audit_events_default_source_legacy_id_occurred_at_idx; -- --- Name: ux_license_state_singleton; Type: INDEX; Schema: public; Owner: - +-- Name: audit_events audit_events_append_only_trigger; Type: TRIGGER; Schema: public; Owner: - -- -CREATE UNIQUE INDEX ux_license_state_singleton ON public.license_state USING btree (id); +CREATE TRIGGER audit_events_append_only_trigger BEFORE DELETE OR UPDATE ON public.audit_events FOR EACH ROW EXECUTE FUNCTION public.audit_events_append_only(); -- @@ -6166,6 +9145,13 @@ CREATE TRIGGER trg_org_billing_state_audit AFTER UPDATE ON public.org_billing_st CREATE TRIGGER trg_org_billing_state_updated_at BEFORE UPDATE ON public.org_billing_state FOR EACH ROW EXECUTE FUNCTION public.org_billing_state_set_updated_at(); +-- +-- Name: org_sso_configs trg_org_sso_configs_updated_at; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER trg_org_sso_configs_updated_at BEFORE UPDATE ON public.org_sso_configs FOR EACH ROW EXECUTE FUNCTION public.org_sso_configs_set_updated_at(); + + -- -- Name: plan_catalog trg_plan_catalog_updated_at; Type: TRIGGER; Schema: public; Owner: - -- @@ -6537,6 +9523,22 @@ ALTER TABLE ONLY public.org_slack_configs ADD CONSTRAINT org_slack_configs_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; +-- +-- Name: org_sso_configs org_sso_configs_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_sso_configs + ADD CONSTRAINT org_sso_configs_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL; + + +-- +-- Name: org_sso_configs org_sso_configs_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.org_sso_configs + ADD CONSTRAINT org_sso_configs_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; + + -- -- Name: org_teams_configs org_teams_configs_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7001,22 +10003,6 @@ ALTER TABLE ONLY public.upgrade_replacement_cutovers ADD CONSTRAINT upgrade_replacement_cutovers_upgrade_request_id_fkey FOREIGN KEY (upgrade_request_id) REFERENCES public.upgrade_requests(upgrade_request_id) ON DELETE CASCADE; --- --- Name: upgrade_request_events upgrade_request_events_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.upgrade_request_events - ADD CONSTRAINT upgrade_request_events_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE; - - --- --- Name: upgrade_request_events upgrade_request_events_upgrade_request_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - --- - -ALTER TABLE ONLY public.upgrade_request_events - ADD CONSTRAINT upgrade_request_events_upgrade_request_id_fkey FOREIGN KEY (upgrade_request_id) REFERENCES public.upgrade_requests(upgrade_request_id) ON DELETE CASCADE; - - -- -- Name: upgrade_requests upgrade_requests_actor_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: - -- @@ -7310,4 +10296,9 @@ INSERT INTO public.schema_migrations (version) VALUES ('20261020090000'), ('20261022120000'), ('20261022130000'), - ('20261023090000'); + ('20261023090000'), + ('20261025120000'), + ('20261025120100'), + ('20261025130000'), + ('20261029104047'), + ('20261029134500'); diff --git a/debug/.env.example b/debug/.env.example deleted file mode 100644 index 08498a913..000000000 --- a/debug/.env.example +++ /dev/null @@ -1,4 +0,0 @@ -# Example values for LiveReview debug tooling -# Copy to .env and set real values. -LIVEREVIEW_DEBUG_OLLAMA_API_KEY=fake_debug_api_key_for_local_dev -LIVEREVIEW_DEBUG_OLLAMA_CHAT_URL=http://127.0.0.1:11434/api/chat diff --git a/debug/debug_json_repair.go b/debug/debug_json_repair.go deleted file mode 100644 index 9518d0c11..000000000 --- a/debug/debug_json_repair.go +++ /dev/null @@ -1,34 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "strings" - - "github.com/livereview/internal/llm" -) - -func main() { - malformedJSON := `{ - // Comment here - comments: [ - {'file': 'test.go', 'line': 10,}, // Another comment - ] - }` - - fmt.Println("Original:") - fmt.Println(malformedJSON) - fmt.Println("\n" + strings.Repeat("=", 50)) - - repaired, stats, err := llm.RepairJSON(malformedJSON) - - fmt.Printf("Repaired (error: %v):\n", err) - fmt.Println(repaired) - fmt.Println("\n" + strings.Repeat("=", 50)) - - fmt.Printf("Stats: %+v\n", stats) - - // Validate repaired output without deserializing into an unconstrained interface. - valid := json.Valid([]byte(repaired)) - fmt.Printf("Parse valid: %v\n", valid) -} diff --git a/debug/debug_langchain.go b/debug/debug_langchain.go deleted file mode 100644 index bce372a8e..000000000 --- a/debug/debug_langchain.go +++ /dev/null @@ -1,218 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/http" - "net/http/httputil" - "os" - "strings" - "time" - - "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/llms/ollama" -) - -// Auth transport that adds Authorization header -type authTransport struct { - transport http.RoundTripper - apiKey string -} - -func (a *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Clone the request and add auth header - newReq := req.Clone(req.Context()) - newReq.Header.Set("Authorization", "Bearer "+a.apiKey) - return a.transport.RoundTrip(newReq) -} - -// Debug transport that logs all HTTP requests -type debugTransport struct { - transport http.RoundTripper -} - -// maskSecretValue keeps this debug tool's output safe to paste into a -// ticket or chat by masking any header that carries a credential. -func maskSecretValue(name, value string) string { - lower := strings.ToLower(name) - if lower == "authorization" || lower == "x-api-key" || strings.Contains(lower, "token") || strings.Contains(lower, "secret") { - if len(value) <= 12 { - return "[HIDDEN]" - } - return value[:8] + "...[HIDDEN]" - } - return value -} - -func (d *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { - fmt.Println("=== LANGCHAIN HTTP REQUEST ===") - fmt.Printf("Method: %s\n", req.Method) - fmt.Printf("URL: %s\n", req.URL.String()) - fmt.Println("Headers:") - for name, values := range req.Header { - masked := make([]string, len(values)) - for i, v := range values { - masked[i] = maskSecretValue(name, v) - } - fmt.Printf(" %s: %v\n", name, masked) - } - - // Check specifically for Authorization header - if auth := req.Header.Get("Authorization"); auth != "" { - fmt.Printf("✓ Authorization header present: %s\n", maskSecretValue("Authorization", auth)) - } else { - fmt.Printf("❌ Authorization header MISSING\n") - } - - // Dump the full request, then mask any credential-bearing header line in - // the raw dump too (httputil.DumpRequest doesn't go through our masking above). - if req.Body != nil { - dump, err := httputil.DumpRequest(req, true) - if err == nil { - text := string(dump) - lines := strings.Split(text, "\r\n") - for i, line := range lines { - if idx := strings.Index(line, ": "); idx > 0 { - lines[i] = line[:idx] + ": " + maskSecretValue(line[:idx], line[idx+2:]) - } - } - fmt.Printf("Full request dump:\n%s\n", strings.Join(lines, "\r\n")) - } - } - fmt.Println("================================") // Make the actual request - resp, err := d.transport.RoundTrip(req) - - if err != nil { - fmt.Printf("REQUEST ERROR: %v\n", err) - return resp, err - } - - fmt.Println("=== LANGCHAIN HTTP RESPONSE ===") - fmt.Printf("Status: %s\n", resp.Status) - fmt.Println("Response Headers:") - for name, values := range resp.Header { - fmt.Printf(" %s: %v\n", name, values) - } - fmt.Println("===============================") - - return resp, err -} - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: go run debug_langchain.go [generate|chat]") - os.Exit(1) - } - - testType := os.Args[1] - - // Your Ollama server configuration - baseURL := "http://139.59.48.31/ollama" - model := "mistral:7b" - apiKey := strings.TrimSpace(os.Getenv("LIVEREVIEW_DEBUG_OLLAMA_API_KEY")) - if apiKey == "" { - log.Fatal("LIVEREVIEW_DEBUG_OLLAMA_API_KEY is required") - } - - fmt.Printf("=== LangChain Debug Test: %s ===\n", testType) - fmt.Printf("Server: %s\n", baseURL) - fmt.Printf("Model: %s\n", model) - fmt.Printf("API Key length: %d\n", len(apiKey)) - fmt.Println() - - // Create debug HTTP client with auth header injection - httpClient := &http.Client{ - Timeout: 30 * time.Second, - Transport: &debugTransport{ - transport: &authTransport{ - transport: &http.Transport{ - ResponseHeaderTimeout: 60 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - }, - apiKey: apiKey, - }, - }, - } - - // Create Ollama client - llm, err := ollama.New( - ollama.WithServerURL(baseURL), - ollama.WithModel(model), - ollama.WithHTTPClient(httpClient), - ) - if err != nil { - log.Fatalf("Failed to create Ollama client: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - switch testType { - case "generate": - testGenerate(ctx, llm) - case "chat": - testChat(ctx, llm) - default: - fmt.Println("Invalid test type. Use 'generate' or 'chat'") - os.Exit(1) - } -} - -func testGenerate(ctx context.Context, llm llms.Model) { - fmt.Println("=== Testing Generate (Non-streaming) ===") - - prompt := "Say hello" - - fmt.Printf("Prompt: %s\n", prompt) - fmt.Println("Sending request...") - - start := time.Now() - response, err := llm.GenerateContent(ctx, []llms.MessageContent{ - llms.TextParts(llms.ChatMessageTypeHuman, prompt), - }) - duration := time.Since(start) - - if err != nil { - log.Fatalf("Generate failed: %v", err) - } - - fmt.Printf("✓ Generate completed in %v\n", duration) - fmt.Printf("Response: %s\n", response.Choices[0].Content) -} - -func testChat(ctx context.Context, llm llms.Model) { - fmt.Println("=== Testing Chat (Streaming) ===") - - prompt := "Say hello" - - fmt.Printf("Prompt: %s\n", prompt) - fmt.Println("Starting streaming request...") - - start := time.Now() - chunkCount := 0 - var fullResponse string - - _, err := llm.GenerateContent(ctx, []llms.MessageContent{ - llms.TextParts(llms.ChatMessageTypeHuman, prompt), - }, llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error { - chunkCount++ - chunkStr := string(chunk) - fullResponse += chunkStr - - elapsed := time.Since(start) - fmt.Printf("[CHUNK %d] [%v] %q\n", chunkCount, elapsed, chunkStr) - - return nil - })) - - duration := time.Since(start) - - if err != nil { - log.Fatalf("Chat streaming failed: %v", err) - } - - fmt.Printf("✓ Chat streaming completed in %v\n", duration) - fmt.Printf("Total chunks received: %d\n", chunkCount) - fmt.Printf("Full response: %s\n", fullResponse) -} diff --git a/debug/debug_livereview_replica.go b/debug/debug_livereview_replica.go deleted file mode 100644 index eac08b547..000000000 --- a/debug/debug_livereview_replica.go +++ /dev/null @@ -1,170 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - "os" - "strings" - "time" - - "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/llms/ollama" -) - -// Replicate LiveReview's authTransport exactly -type authTransport struct { - Transport http.RoundTripper - token string -} - -// maskSecretHeader keeps this debug tool's output safe to paste into a -// ticket or chat by masking any header that carries a credential. -func maskSecretHeader(name string, values []string) []string { - lower := strings.ToLower(name) - if lower != "authorization" && lower != "x-api-key" && !strings.Contains(lower, "token") && !strings.Contains(lower, "secret") { - return values - } - masked := make([]string, len(values)) - for i, v := range values { - if len(v) <= 12 { - masked[i] = "[HIDDEN]" - } else { - masked[i] = v[:8] + "...[HIDDEN]" - } - } - return masked -} - -func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { - // Add Authorization header - req.Header.Set("Authorization", "Bearer "+t.token) - - // Debug logging - show full request details including body - fmt.Printf("[HTTP DEBUG] Request URL: %s\n", req.URL.String()) - fmt.Printf("[HTTP DEBUG] Request Method: %s\n", req.Method) - fmt.Printf("[HTTP DEBUG] Authorization header set with token length: %d\n", len(t.token)) - fmt.Printf("[HTTP DEBUG] All Headers:\n") - for k, v := range req.Header { - fmt.Printf(" %s: %v\n", k, maskSecretHeader(k, v)) - } - - // Make the request - resp, err := t.Transport.RoundTrip(req) - return resp, err -} - -func main() { - // Replicate LiveReview's exact setup - baseURL := "http://139.59.48.31/ollama/api" // What LiveReview logs show - model := "mistral:7b" - apiKey := strings.TrimSpace(os.Getenv("LIVEREVIEW_DEBUG_OLLAMA_API_KEY")) - if apiKey == "" { - panic("LIVEREVIEW_DEBUG_OLLAMA_API_KEY is required") - } - - // Clean up base URL - remove trailing /api/ if present for Ollama (same as LiveReview) - cleanURL := strings.TrimSuffix(baseURL, "/api/") - cleanURL = strings.TrimSuffix(cleanURL, "/api") - cleanURL = strings.TrimSuffix(cleanURL, "/") - - fmt.Printf("[OLLAMA INIT] Original base URL: %s\n", baseURL) - fmt.Printf("[OLLAMA INIT] Cleaned base URL: %s\n", cleanURL) - - // Create a custom HTTP client with Authorization header and proper timeouts (same as LiveReview) - client := &http.Client{ - Timeout: 5 * time.Minute, // Overall request timeout - } - - // Create a custom transport that adds the Authorization header with connection timeouts - transport := &http.Transport{ - TLSHandshakeTimeout: 10 * time.Second, // TLS timeout - ResponseHeaderTimeout: 60 * time.Second, // Time to wait for response headers - ExpectContinueTimeout: 1 * time.Second, // Expect 100-continue timeout - } - client.Transport = &authTransport{ - Transport: transport, - token: apiKey, - } - - fmt.Printf("[OLLAMA INIT] Custom HTTP client configured with timeouts\n") - - // Create Ollama LLM exactly like LiveReview - llm, err := ollama.New( - ollama.WithModel(model), - ollama.WithServerURL(cleanURL), - ollama.WithHTTPClient(client), - ) - if err != nil { - panic(fmt.Sprintf("Failed to create Ollama LLM: %v", err)) - } - - fmt.Printf("[LANGCHAIN INIT] Initializing Ollama LLM with model: %s\n", model) - - // Create a simple prompt like LiveReview uses - prompt := "Review this code change: func hello() { fmt.Println(\"Hello world\") }. Respond with JSON." - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - - // Replicate LiveReview's streaming function exactly - var totalChunks int = 0 - var lastActivity time.Time = time.Now() - var responseBuilder strings.Builder - - streamingFunc := func(ctx context.Context, chunk []byte) error { - chunkStr := string(chunk) - totalChunks++ - lastActivity = time.Now() - - // Debug: Log raw chunk info to diagnose streaming issues (same as LiveReview) - fmt.Printf("[STREAM DEBUG] Received chunk %d: length=%d, content=%q\n", totalChunks, len(chunk), chunkStr) - - // Add to response builder - responseBuilder.WriteString(chunkStr) - return nil - } - - // Start activity monitor (same as LiveReview) - activityDone := make(chan bool) - go func() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - start := time.Now() - for { - select { - case <-activityDone: - return - case <-ticker.C: - elapsed := time.Since(start) - timeSinceActivity := time.Since(lastActivity) - fmt.Printf("\n[STREAM PROGRESS] Elapsed: %v | Chunks: %d | Last activity: %v ago\n", elapsed.Truncate(time.Second), totalChunks, timeSinceActivity.Truncate(time.Second)) - if timeSinceActivity > 30*time.Second { - fmt.Printf("[STREAM MONITOR] No activity for %v (chunks received: %d)\n", timeSinceActivity, totalChunks) - } - } - } - }() - - // Call the LLM with streaming exactly like LiveReview - fmt.Printf("[STREAM START] Beginning streaming response...\n") - fmt.Printf("[STREAM DEBUG] Calling GenerateFromSinglePrompt with streaming function\n") - - _, err = llms.GenerateFromSinglePrompt( - ctx, - llm, - prompt, - llms.WithStreamingFunc(streamingFunc), - ) - - // Stop activity monitor - close(activityDone) - - fmt.Printf("[STREAM DEBUG] GenerateFromSinglePrompt returned, err=%v\n", err) - fmt.Printf("Total chunks received: %d\n", totalChunks) - fmt.Printf("Response: %s\n", responseBuilder.String()) - - if err != nil { - fmt.Printf("ERROR: %v\n", err) - } -} diff --git a/debug/debug_raw_http.go b/debug/debug_raw_http.go deleted file mode 100644 index ac4f7aab8..000000000 --- a/debug/debug_raw_http.go +++ /dev/null @@ -1,122 +0,0 @@ -package main - -import ( - "fmt" - "io" - "net/http" - "os" - "strings" - "time" -) - -// maskSecretHeader keeps this debug tool's output safe to paste into a -// ticket or chat by masking any header that carries a credential. -func maskSecretHeader(name string, values []string) []string { - lower := strings.ToLower(name) - if lower != "authorization" && lower != "x-api-key" && !strings.Contains(lower, "token") && !strings.Contains(lower, "secret") { - return values - } - masked := make([]string, len(values)) - for i, v := range values { - if len(v) <= 12 { - masked[i] = "[HIDDEN]" - } else { - masked[i] = v[:8] + "...[HIDDEN]" - } - } - return masked -} - -func main() { - // Test the raw HTTP request to see what Ollama is actually returning - baseURL := strings.TrimSpace(os.Getenv("LIVEREVIEW_DEBUG_OLLAMA_CHAT_URL")) - if baseURL == "" { - fmt.Println("LIVEREVIEW_DEBUG_OLLAMA_CHAT_URL is required") - return - } - apiKey := strings.TrimSpace(os.Getenv("LIVEREVIEW_DEBUG_OLLAMA_API_KEY")) - if apiKey == "" { - fmt.Println("LIVEREVIEW_DEBUG_OLLAMA_API_KEY is required") - return - } - - // Create the request body (same as curl) - requestBody := `{ - "model": "mistral:7b", - "messages": [ - { - "role": "user", - "content": "What is the capital of France?" - } - ], - "stream": false - }` - - fmt.Println("=== Raw HTTP Request Test ===") - fmt.Printf("URL: %s\n", baseURL) - fmt.Printf("API Key length: %d\n", len(apiKey)) - fmt.Println() - - // Create HTTP client - client := &http.Client{ - Timeout: 30 * time.Second, - } - - // Create request - req, err := http.NewRequest("POST", baseURL, strings.NewReader(requestBody)) - if err != nil { - fmt.Printf("Error creating request: %v\n", err) - return - } - - // Set headers - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", "Bearer "+apiKey) - - fmt.Println("Request headers:") - for name, values := range req.Header { - fmt.Printf(" %s: %v\n", name, maskSecretHeader(name, values)) - } - fmt.Printf("Request body: %s\n", requestBody) - fmt.Println() - - // Send request - fmt.Println("Sending request...") - start := time.Now() - resp, err := client.Do(req) - if err != nil { - fmt.Printf("Error sending request: %v\n", err) - return - } - defer resp.Body.Close() - - duration := time.Since(start) - fmt.Printf("Response received in %v\n", duration) - fmt.Printf("Status: %s\n", resp.Status) - fmt.Println("Response headers:") - for name, values := range resp.Header { - fmt.Printf(" %s: %v\n", name, values) - } - fmt.Println() - - // Read response body - body, err := io.ReadAll(resp.Body) - if err != nil { - fmt.Printf("Error reading response: %v\n", err) - return - } - - fmt.Printf("Response body length: %d bytes\n", len(body)) - fmt.Printf("Response body (first 500 chars): %q\n", string(body[:min(len(body), 500)])) - - if len(body) > 500 { - fmt.Printf("Response body (last 100 chars): %q\n", string(body[len(body)-100:])) - } -} - -func min(a, b int) int { - if a < b { - return a - } - return b -} diff --git a/debug/test_ollama.go b/debug/test_ollama.go deleted file mode 100644 index de9f843aa..000000000 --- a/debug/test_ollama.go +++ /dev/null @@ -1,126 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log" - "net/http" - "os" - "time" - - "github.com/tmc/langchaingo/llms" - "github.com/tmc/langchaingo/llms/ollama" -) - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: go run test_ollama.go [generate|chat]") - os.Exit(1) - } - - testType := os.Args[1] - - // Your Ollama server configuration - baseURL := "http://139.59.48.31/ollama" - model := "mistral:7b" - apiKey := os.Getenv("LIVEREVIEW_DEBUG_OLLAMA_API_KEY") - if apiKey == "" { - log.Fatal("LIVEREVIEW_DEBUG_OLLAMA_API_KEY is required") - } - - fmt.Printf("=== Ollama Test Case: %s ===\n", testType) - fmt.Printf("Server: %s\n", baseURL) - fmt.Printf("Model: %s\n", model) - fmt.Printf("API Key length: %d\n", len(apiKey)) - fmt.Println() - - // Create custom HTTP client with timeouts - httpClient := &http.Client{ - Timeout: 5 * time.Minute, - Transport: &http.Transport{ - ResponseHeaderTimeout: 60 * time.Second, - TLSHandshakeTimeout: 10 * time.Second, - }, - } - - // Create Ollama client - llm, err := ollama.New( - ollama.WithServerURL(baseURL), - ollama.WithModel(model), - ollama.WithHTTPClient(httpClient), - ) - if err != nil { - log.Fatalf("Failed to create Ollama client: %v", err) - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - defer cancel() - - switch testType { - case "generate": - testGenerate(ctx, llm) - case "chat": - testChat(ctx, llm) - default: - fmt.Println("Invalid test type. Use 'generate' or 'chat'") - os.Exit(1) - } -} - -func testGenerate(ctx context.Context, llm llms.Model) { - fmt.Println("=== Testing Generate (Non-streaming) ===") - - prompt := "What is the capital of France? Please respond in one sentence." - - fmt.Printf("Prompt: %s\n", prompt) - fmt.Println("Sending request...") - - start := time.Now() - response, err := llm.GenerateContent(ctx, []llms.MessageContent{ - llms.TextParts(llms.ChatMessageTypeHuman, prompt), - }) - duration := time.Since(start) - - if err != nil { - log.Fatalf("Generate failed: %v", err) - } - - fmt.Printf("✓ Generate completed in %v\n", duration) - fmt.Printf("Response: %s\n", response.Choices[0].Content) -} - -func testChat(ctx context.Context, llm llms.Model) { - fmt.Println("=== Testing Chat (Streaming) ===") - - prompt := "Explain what HTTP status code 200 means in exactly 2 sentences." - - fmt.Printf("Prompt: %s\n", prompt) - fmt.Println("Starting streaming request...") - - start := time.Now() - chunkCount := 0 - var fullResponse string - - _, err := llm.GenerateContent(ctx, []llms.MessageContent{ - llms.TextParts(llms.ChatMessageTypeHuman, prompt), - }, llms.WithStreamingFunc(func(ctx context.Context, chunk []byte) error { - chunkCount++ - chunkStr := string(chunk) - fullResponse += chunkStr - - elapsed := time.Since(start) - fmt.Printf("[CHUNK %d] [%v] %q\n", chunkCount, elapsed, chunkStr) - - return nil - })) - - duration := time.Since(start) - - if err != nil { - log.Fatalf("Chat streaming failed: %v", err) - } - - fmt.Printf("✓ Chat streaming completed in %v\n", duration) - fmt.Printf("Total chunks received: %d\n", chunkCount) - fmt.Printf("Full response: %s\n", fullResponse) -} diff --git a/docker/docker-compose.sso-test.yml b/docker/docker-compose.sso-test.yml new file mode 100644 index 000000000..619dc07c1 --- /dev/null +++ b/docker/docker-compose.sso-test.yml @@ -0,0 +1,22 @@ +# Local/CI harness for the SSO end-to-end test (see docs/sso.md, "Local +# end-to-end test: Keycloak" and `make test-sso-e2e`). Test-only: this image +# is NOT part of the shipped LiveReview Docker image, so it is intentionally +# pinned here rather than in docker/docker-deps.env (that file only pins +# ingredients baked into Dockerfile/Dockerfile.crosscompile). +services: + keycloak: + image: quay.io/keycloak/keycloak:26.0 + command: start-dev --import-realm + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: admin + KC_BOOTSTRAP_ADMIN_PASSWORD: admin + KC_HEALTH_ENABLED: "true" + volumes: + - ../test/fixtures/keycloak:/opt/keycloak/data/import:ro + ports: + - "8089:8080" + healthcheck: + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && echo -e 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '\"status\": \"UP\"' <&3"] + interval: 3s + timeout: 5s + retries: 40 diff --git a/docs/audit-events.md b/docs/audit-events.md index 33f1fbefd..3ce3bc80b 100644 --- a/docs/audit-events.md +++ b/docs/audit-events.md @@ -112,6 +112,8 @@ Generated from `internal/auditlog/catalog.go`. | `settings.retention.update` | `settings` | setting | critical | Audit retention updated | The audit retention policy was changed. | | `settings.risk_model.update` | `settings` | setting | warning | Risk model updated | Risk model configuration was changed. | | `settings.smtp.update` | `settings` | setting | critical | SMTP settings updated | Outbound email settings were changed. | +| `settings.sso.delete` | `settings` | setting | critical | SSO settings deleted | An organization's single sign-on configuration was removed. | +| `settings.sso.update` | `settings` | setting | critical | SSO settings updated | An organization's single sign-on (OIDC/SAML) configuration was created or changed. | | `settings.storage.update` | `settings` | setting | critical | Storage settings updated | The blob storage backend was changed. | | `settings.update` | `settings` | setting | warning | Settings updated | Platform or organization settings were changed. | | `system.audit.anchor` | `system` | audit | info | Audit anchor written | The daily audit hash anchor was written. | diff --git a/docs/blast-radius-backend-port-plan.md b/docs/blast-radius-backend-port-plan.md index 61cad58e0..c245caf92 100644 --- a/docs/blast-radius-backend-port-plan.md +++ b/docs/blast-radius-backend-port-plan.md @@ -240,8 +240,8 @@ until the index is rebuilt. Same applies on every deploy target. ## 7. Backfill -New `scripts/backfill_blast_radius_hunks.go` (same shape as -`scripts/backfill_chat_chart_stats.go`): enumerate +New `cmd/backfill-blast-radius-hunks/main.go` (same shape as +`cmd/backfill-chat-chart-stats/main.go`): enumerate `org/*/review/*/artifacts/blast-radius.json` via `bucket.List(&blob.ListOptions{ Prefix: "org/"})` — `internal/blobstore.OpenBucket` returns a gocloud `*blob.Bucket`, whose `List` covers this natively, so no aws CLI shell-out like diff --git a/docs/sso.md b/docs/sso.md new file mode 100644 index 000000000..902faa32f --- /dev/null +++ b/docs/sso.md @@ -0,0 +1,375 @@ +# Enterprise SSO (OIDC + SAML) for Self-Hosted LiveReview + +> Status doc. Kept in sync with the task list below after every major step — +> this is the reference for "what's done, what's left," not just the design. + +## Context + +Enterprise self-hosted customers need SSO (their IdP — Okta, Azure AD/Entra, +Google Workspace, Keycloak, ADFS, etc.) instead of LiveReview's own +email/password login. This is gated to self-hosted enterprise only for now +(not cloud), but is built so cloud can turn it on later without +rearchitecting. + +Decisions made: +- **Protocols:** OIDC and SAML, both. +- **Config scope:** per-organization (not instance-wide), matching the + existing org-scoping rules in `CLAUDE.md`. +- **Provisioning:** JIT — first successful SSO login auto-creates the user + as `member` if the email doesn't already exist in that org. +- **Coexistence:** SSO is additive per-org; password login keeps working + (no enforced lockout toggle in this phase). + +Chosen libraries (standard, widely used, avoids hand-rolling crypto): +- **OIDC:** `golang.org/x/oauth2` (already an indirect dep) + `github.com/coreos/go-oidc/v3` for ID-token verification/JWKS. De facto standard Go OIDC client stack. +- **SAML:** `github.com/crewjam/saml` for the Service Provider side (metadata, AuthnRequest, ACS assertion parsing/signature verification). Most-used Go SAML SP library. + +## Data model + +New table `org_sso_configs` (migration in `db/migrations/`): +- `id`, `org_id` (FK → organizations, unique per `(org_id, provider_type)`) +- `provider_type` (`oidc` | `saml`) +- `display_name` (shown on the "Continue with ..." button) +- `enabled` bool +- OIDC columns: `oidc_issuer`, `oidc_client_id`, `oidc_client_secret_enc` (encrypted), `oidc_scopes` +- SAML columns: `saml_idp_metadata_url`, `saml_idp_metadata_xml`, `saml_idp_entity_id` +- `created_at`, `updated_at`, `created_by_user_id` + +Secret-at-rest: AES-GCM helper in the new `internal/sso` package, keyed by +`sha256(JWT_SECRET)` — reuses the secret already required in every +deployment (`.env.selfhosted.example`'s `JWT_SECRET`) instead of introducing +a new env var. + +## Backend (`internal/sso/` new package + `internal/api/auth`) + +1. **Config CRUD** (owner/super-admin only, session-only — no API keys, per + the "Sensitive Operations Gate" rule): + - `POST/PUT/GET/DELETE /api/v1/orgs/:org_id/sso-config` — registered via + `s.audited(...)` with new `auditlog.catalog.go` actions + (`ActionSSOConfigCreate/Update/Delete`), `Sensitive: true`, + `auditlog.Details(...)` scrubbing so client secrets/XML never land in + `details`. Gated additionally on + `license.PlanEnterpriseSelfhosted.HasFeature("sso")` (add `"sso"` to + that plan's `Features` in `internal/license/plans.go`). + - `POST /api/v1/orgs/:org_id/sso-config/test` — validates OIDC discovery / + SAML metadata parses without saving. + +2. **Login endpoints** (public group, mirrors `/auth/login`): + - OIDC: `GET /api/v1/auth/sso/oidc/:org_id/login` (builds auth URL with + signed, short-TTL `state`/`nonce`, signed with `TokenService`'s key — + no new DB table) → `GET /api/v1/auth/sso/oidc/callback` (verifies + state, exchanges code, verifies ID token via `go-oidc`, extracts + email/name). + - SAML: `GET /api/v1/auth/sso/saml/:org_id/metadata` (SP metadata XML), + `GET /api/v1/auth/sso/saml/:org_id/login` (redirect binding), + `POST /api/v1/auth/sso/saml/:org_id/acs` (assertion consumer, via + `crewjam/saml`'s middleware). + - Both success paths converge on `AuthHandlers.CompleteSSOLogin(c, orgID, email, name)`, that: + a. Looks up the user by email **within that org** (never cross-org). + b. JIT-creates the user as `member` if missing (reusing + `internal/api/users/user_service.go`'s existing user-creation path). + c. Issues a normal JWT pair via the **same** `TokenService` method + `Login()` already uses. + d. Calls `h.recordAuthEvent` with new catalog actions + (`ActionSSOLoginSuccess`/`ActionSSOLoginFailure`). + e. Redirects the browser to the frontend (reuse the redirect-with-tokens + convention from `internal/api/github_app_auth.go`'s OAuth callback). + +3. **Discovery for the login page** — public `GET /api/v1/auth/sso/providers` + returns any orgs with `enabled` SSO configs (self-hosted normally has + exactly one). Email-domain discovery for multi-tenant cloud is a later + addition, not needed now. + +4. **Audit catalog additions** (`internal/auditlog/catalog.go` + + `docs/audit-events.md`): `ActionSSOConfigCreate`, `ActionSSOConfigUpdate`, + `ActionSSOConfigDelete`, `ActionSSOLoginSuccess`, `ActionSSOLoginFailure`. + +## Frontend + +- New **SSO** tab in `ui/src/pages/Settings/Settings.tsx`'s `tabs` array, + gated exactly like the existing `license` tab: + `!isCloudMode() && (isSuperAdmin || currentOrg?.role === 'owner')`. +- Tab UI: toggle OIDC vs SAML, form fields per provider, "Test connection" + button, enable switch. +- Login page: calls `/auth/sso/providers` on mount; if any are returned, + render a "Continue with {name}" button that navigates (full page nav) to + the OIDC/SAML login URL. +- **Mandatory per `CLAUDE.md`:** add the new Settings tab to + `ui/src/components/Navbar/megaMenuData.ts` with matching + `requiresOwnerOrAdmin` gating, and add + `internal/docindex/docs/routes_guide/settings/sso.md`. + +## Local end-to-end test: Keycloak + +Keycloak speaks both OIDC and SAML as an IdP (one fixture covers both +protocols), and realm config is fully declarative (JSON import) — no +browser automation needed, Keycloak's login form is plain HTML and can be +POSTed directly. + +1. `docker/docker-compose.sso-test.yml`: one `keycloak` service + (`quay.io/keycloak/keycloak`, version pinned in `docker/docker-deps.env`) + started with `--import-realm`, mounting + `test/fixtures/keycloak/livereview-test-realm.json`: + - A confidential OIDC client (`redirect_uri` = local + `/api/v1/auth/sso/oidc/callback`). + - A SAML client (ACS URL = local `/acs` endpoint). + - One test user (`sso-test@example.com` / fixed test password). +2. `make test-sso-e2e`: brings the container up, waits on Keycloak's health + endpoint, runs the LiveReview server against the test DB with an + `org_sso_configs` row seeded to point at local Keycloak, runs + `go test ./internal/sso/... -tags=sso_e2e`, tears the container down. +3. The Go test (`internal/sso/e2e_test.go`, build-tag gated) uses a plain + `net/http.Client` with a cookie jar — no browser: follows the redirect to + Keycloak, parses the login HTML for the form action + hidden fields, + POSTs credentials, follows the redirect chain back through + `/callback`/`/acs`, asserts the final redirect carries a usable access + token, then calls `GET /api/v1/organizations` with it to prove JIT + provisioning landed the user in the right org as `member`. Run once for + OIDC, once for SAML. + +This fixture doubles as a copy-pasteable example in the SSO settings doc for +admins wiring up a real IdP. + +## Build order & continuous regression safety + +Keycloak comes in right after the first working slice (OIDC), not at the +end — each phase ends green with real-IdP verification before the next +phase starts. **At the end of every phase**, run this fixed regression set +(not just the new tests): + +- `go test ./...` (full suite). +- `go test ./internal/auditlog/...` explicitly (catalog/docs contract). +- Three smoke checks for the existing auth paths most at risk when touching + `internal/api/auth` / `server.go`'s middleware chains: + 1. **Password login** — `POST /api/v1/auth/login` with an existing user + still returns a valid token pair. + 2. **API key auth** — an existing `X-API-Key` request against an + org-scoped route still passes `RequireAuthOrAPIKey()` → + `BuildOrgContext()` → `ValidateOrgAccess()`. + 3. **Cloud login** — `POST /api/v1/auth/ensure-cloud-user` (Hexmos cloud + login) still provisions/logs in a cloud user unchanged — SSO code must + never sit in front of or alter this endpoint, only add new parallel + routes. + +## Rollout / gating recap + +`"sso"` added to `PlanEnterpriseSelfhosted.Features` in +`internal/license/plans.go` — config write endpoints and the Settings tab +both check this, so cloud orgs (which don't have this plan) see nothing new +today. The per-org storage means flipping it on for cloud later is a +plan-features change, not an architecture change. + +## Verification + +Not a final step — runs after every phase (see "Build order" above): + +- `go test ./...` clean at every phase. +- `go test ./internal/auditlog/...` clean at every phase. +- `make test-sso-e2e` green for OIDC alone after phase 2, and for OIDC+SAML + together after phase 3 — real Keycloak, not mocks. +- `npx tsc --noEmit` in `ui/` after phase 4 (no full build without separate + explicit approval). +- The three regression smoke checks passing unchanged after every phase. + +--- + +## Task list + +> Checked off and updated here after every major step. This is the +> single source of truth for progress — update it in the same change that +> makes the progress, not after the fact. + +### Phase 1 — OIDC backend (no UI) — ✅ DONE (2026-10-29) +- [x] `db/migrations/20261029104047_create_org_sso_configs.sql` +- [x] `internal/sso` package: AES-GCM secret helper (`secretbox.go`), signed + state token (`state.go`), OIDC verifier/exchange wrapper around + `go-oidc` + `x/oauth2` (`oidc.go`), config store (`config.go`) +- [x] Config CRUD endpoints (`internal/api/sso_config.go`: + `GET/PUT/DELETE /api/v1/orgs/:org_id/sso-config[/:provider_type]`, + `POST .../test`), session-only + owner-only + self-hosted-enterprise + feature-gated (`requireSSOAccess`), audited via `s.audited` + + `RecordTxEcho` in the same transaction +- [x] `/api/v1/auth/sso/oidc/:org_id/login` + `/callback` + (`internal/api/auth/sso_handlers.go`) +- [x] `AuthHandlers.completeSSOLogin` + `jitProvisionSSOUser` shared + JIT-provision + token-issue path (reuses `CreateTokenPairWithOrg`, + same as password login) +- [x] Reused existing `ActionSSOProvision`/`ActionLoginSuccess`/ + `ActionLoginFailure` catalog actions for login events; added + `ActionSettingsSSOUpdate`/`ActionSettingsSSODelete` for config + writes, in `internal/auditlog/catalog.go` + `docs/audit-events.md` +- [x] `"sso"` added to `PlanEnterpriseSelfhosted.Features` + (`internal/license/plans.go`) +- [x] Regression set green: `go test ./internal/... ./pkg/...` (only + pre-existing, unrelated failures in `provider_input/bitbucket` and + `provider_output/github`, confirmed present on `master` too via + `git stash`), `go test ./internal/auditlog/...` clean, 3 manual + smoke checks against a running server all unchanged (password login + 401 on bad creds, `ensure-cloud-user` 401 without auth header, + bogus-API-key rejection identical on an existing route and on the new + `/orgs/:org_id/sso-config` route) + +### Phase 2 — Keycloak OIDC e2e (checkpoint before SAML) — ✅ DONE (2026-10-29) +- [x] `docker/docker-compose.sso-test.yml`, Keycloak `26.0` pinned inline + (not in `docker/docker-deps.env` - that file only pins ingredients of + the shipped production image; this is a test-only dependency, noted + as such in the compose file's header comment) +- [x] `test/fixtures/keycloak/livereview-test-realm.json`: realm + `livereview-test`, confidential OIDC client `livereview-oidc-test` + (redirect_uri `http://localhost:8888/api/v1/auth/sso/oidc/callback`), + test user `sso-test@example.com` +- [x] `make test-sso-e2e` target (bring-up / health-wait / run / teardown, + Makefile) +- [x] `internal/sso/e2e_test.go` (`sso_e2e` build tag): drives the full + redirect chain with a plain cookie-jar `http.Client` (no browser) - + login redirect → Keycloak's real login form (scraped + POSTed) → + `/callback` → final redirect to the frontend with tokens in the URL + fragment → validates the issued access token and confirms + JIT-provisioned role is `member` in the right org. **Passed twice in + a row against a real Keycloak 26.0 container.** +- [x] Regression set green: `make testall` clean; 3 smoke checks against a + rebuilt server unchanged. (One real bug caught and fixed here: a + stale smoke-test server process from Phase 1 was still holding port + 8888, which is exactly the kind of state leak this phase's + real-IdP checkpoint exists to catch before SAML work builds on top + of it.) + +### Phase 3 — SAML backend — ✅ DONE (2026-10-29) +- [x] `crewjam/saml` SP wiring (`internal/sso/saml.go`: + `BuildServiceProvider`, `ExchangeSAMLResponse`, `TestSAMLMetadata`) + + metadata/login/ACS endpoints + (`internal/api/auth/sso_handlers.go`: `SSOSAMLMetadata`, + `SSOSAMLLogin`, `SSOSAMLACS`). Requests are sent unsigned (no SP + keypair needed for MVP); `AllowIDPInitiated: true` is set because the + signed RelayState already gives the anti-replay guarantee + `InResponseTo` matching would - a deliberate, documented trade, not + an oversight. +- [x] SAML config CRUD wired into the same `/sso-config` endpoints (shared + `parseProviderType`/`requireSSOAccess`); `TestSSOConfig`'s SAML + branch parses/fetches real IdP metadata via `TestSAMLMetadata` +- [x] e2e test extended with a SAML flow + (`internal/sso/e2e_test.go`: `TestSAMLLoginEndToEnd`) - since the + realm fixture can't pre-bake a SAML client for a dynamically-created + org ID, the test registers one at runtime via Keycloak's Admin REST + API (`newKeycloakAdminToken`, `createSAMLClient`, with an explicit + `saml-user-property-mapper` releasing email - a freshly created + client gets no default mappers and Keycloak's default NameID is + `transient`, not email, both discovered by running against the real + IdP rather than assumed) +- [x] OIDC + SAML both green together, twice in a row, in the same + `go test -tags=sso_e2e ./internal/sso/...` run against a real + Keycloak 26.0 +- [x] Regression set green: `make testall` clean, `go test ./internal/auditlog/...` + clean, 3 smoke checks unchanged against a rebuilt server, plus a new + sanity check that an unconfigured org's SAML metadata route 404s + cleanly rather than panicking + +### Phase 4 — Frontend — ✅ DONE (2026-10-29) +- [x] Settings SSO tab: `ui/src/pages/Settings/SSOSettingsTab.tsx` (new, + modeled on `SMTPSettingsTab.tsx`'s local-state + `apiClient` pattern) + wired into `ui/src/pages/Settings/Settings.tsx`'s `tabs` array and + content switch with the exact same gating as the `license` tab + (`!isCloudMode() && (isSuperAdmin || currentOrg?.role === 'owner')`). + Per-provider panel (OIDC/SAML tabs) with Save/Test Connection/Delete, + secrets/metadata XML never round-tripped to the client once saved. +- [x] Login page: `ui/src/pages/Auth/SelfHosted.tsx` fetches + `/api/v1/auth/sso/providers` on mount and renders a "Continue with + {name}" link per enabled provider below the password form (full page + nav to the provider's `/login` route, not a fetch); shows a banner for + `?sso_error=...`. Added `ui/src/pages/Auth/SSOCallback.tsx` + an + `App.tsx` branch for `/sso/callback` - the backend's redirect target - + which reads the token pair out of the URL fragment and dispatches the + same `setTokens` action password login uses, so SSO and password login + converge on identical app state. +- [x] `ui/src/components/Navbar/megaMenuData.ts`: added "Single Sign-On + (SSO)" under Settings → Manage System, gated identically to the tab + itself. +- [x] `internal/docindex/docs/routes_guide/settings/sso.md` (new) + linked + from `settings-overview.md`'s table and related-pages list. +- [x] `npx tsc --noEmit` clean. +- [x] Regression set green: `make testall` clean, rebuilt server's 3 smoke + checks unchanged, plus `GET /api/v1/auth/sso/providers` sanity-checked + (`200 []` with no configs seeded). + +### Phase 5 — Final pass — ✅ DONE (2026-10-29) +- [x] Full `go test ./...` (via `make testall`) clean. +- [x] Full `make test-sso-e2e` equivalent (`go test -tags=sso_e2e ./internal/sso/...` + against a real Keycloak 26.0) - `TestOIDCLoginEndToEnd` and + `TestSAMLLoginEndToEnd` both green, run twice to rule out flakiness. +- [x] `npx tsc --noEmit` clean. +- [x] 3 manual smoke checks (password login 401, `ensure-cloud-user` 401, + bogus API key 401 on an org route) against a freshly rebuilt server + binary, unchanged from pre-SSO behavior. + +**SSO (OIDC + SAML) for self-hosted LiveReview is complete.** All 5 phases +done, each verified independently with the full regression set, plus a real +Keycloak for both protocols. Summary of what shipped: + +- New `internal/sso` package (secret-at-rest encryption, signed state + tokens, OIDC client, SAML SP) + `org_sso_configs` table, per-org. +- Public login endpoints (`/api/v1/auth/sso/providers`, + `/api/v1/auth/sso/oidc/:org_id/login`+`/callback`, + `/api/v1/auth/sso/saml/:org_id/metadata`+`/login`+`/acs`) and session-only + owner/super-admin config CRUD (`/api/v1/orgs/:org_id/sso-config[/:provider_type[/test]]`), + gated on the `sso` plan feature (self-hosted enterprise only). +- JIT provisioning as `member` on first successful SSO login, converging + with password login on identical JWT issuance and audit events + (`auth.login.success`/`.failure`, `auth.sso.provision`, + `settings.sso.update`/`.delete`). +- Settings SSO tab, login-page "Continue with ..." button, `/sso/callback` + handler, mega-menu entry, routes_guide doc. +- `docker/docker-compose.sso-test.yml` + `test/fixtures/keycloak/` + + `make test-sso-e2e` for a real, no-browser, local OIDC+SAML e2e test + against Keycloak - reusable as a template for testing against any other + real IdP later. +- No regressions: password login, API-key auth, and the Hexmos cloud login + path (`ensure-cloud-user`) all verified unchanged at the end of every phase. + +### Phase 6 — Pre-merge hardening — ✅ DONE (2026-10-29) + +Found and fixed while running `lrc review --agent-mode` against the full diff +and the four pre-merge security scanners (`make security-osv`, +`security-govulncheck`, `security-semgrep`, `security-gitleaks`): + +- **Real bug, caught only by manually exercising the Settings → SSO tab**: + the `"sso"` plan feature was added to `PlanDefinitions` in + `internal/license/plans.go`, but that map is entirely overwritten at + startup by `license.SyncPlanDefinitionsFromCatalog` reading + `config/plan_catalog.json` (and, if that file is ever missing, by the + Go-level `DefaultPlanCatalog()` fallback in `plan_catalog.go`) - so the + real runtime feature list never had `"sso"` and every enterprise-selfhosted + org was silently 403'd out of the config endpoints. Fixed by adding + `"sso"` to the `enterprise-selfhosted` entry's `feature_flags` in both + `config/plan_catalog.json` and the Go fallback. `plans.go`'s copy was left + as-is for readability but is effectively dead for feature-gating purposes + once the catalog sync runs. +- **`goxmldsig` v1.4.0** (pulled in transitively by `crewjam/saml`) has a + known loop-variable-capture signature-bypass bug (GHSA-479m-364c-43vc, + fixed in v1.6.0) - directly relevant since this library verifies SAML + assertion signatures. Bumped to v1.6.0 (`go get + github.com/russellhaering/goxmldsig@v1.6.0`); both Keycloak e2e tests + re-verified green after the bump. +- `internal/sso/secretbox.go` and `internal/sso/state.go` switched from a + bare `sha256.Sum256(secret)` key derivation to HKDF-SHA256 with a + purpose-specific info string, so the AES-GCM key and the state-token HMAC + key stay independent even though both derive from the same `JWT_SECRET`, + and a lower-entropy `JWT_SECRET` doesn't translate one-to-one into a weak + key. No stored-data migration needed (pre-release, no production SSO + secrets exist yet). +- `internal/api/sso_config.go`'s `UpsertSSOConfig` previously ignored any + non-`ErrNotFound` error from the existing-config lookup and silently + treated it as "no existing config," which could wipe a saved OIDC client + secret or SAML metadata on a transient DB error. Now a real lookup error + fails the request instead. +- Everything else `lrc` flagged (Keycloak healthcheck port 9000, the SAML + error-message PII, the e2e test's `&` unescaping, `expires_at` + handling in `SSOCallback.tsx`, etc.) was checked against the actual + running code/logs and confirmed to already be handled correctly or to be + a false positive - see the PR description for the per-finding triage. +- Final scanner results: `security-osv` clean except the pre-existing + `labstack/echo` `X-Forwarded-For` advisories (present on `master` before + this branch, no `v4.x` fix exists yet); `security-govulncheck` 0 called + vulnerabilities; `security-semgrep` 0 findings across 1825 rules; + `security-gitleaks` no leaks. `go test ./...`, `go test + ./internal/auditlog/...`, and `make test-sso-e2e` (both protocols) all + re-verified green after every fix in this phase. diff --git a/go.mod b/go.mod index cbc6c77c4..c44b8e412 100644 --- a/go.mod +++ b/go.mod @@ -69,7 +69,18 @@ require ( google.golang.org/api v0.272.0 ) -require github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c +require ( + github.com/cdipaolo/goml v0.0.0-20220715001353-00e0c845ae1c + github.com/coreos/go-oidc/v3 v3.21.0 + github.com/crewjam/saml v0.5.1 +) + +require ( + github.com/beevik/etree v1.6.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect + github.com/russellhaering/goxmldsig v1.6.0 // indirect +) require ( cel.dev/expr v0.25.2 // indirect @@ -258,7 +269,7 @@ require ( golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.62.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/oauth2 v0.37.0 golang.org/x/sync v0.24.0 // indirect golang.org/x/sys v0.49.0 // indirect golang.org/x/tools v0.51.0 // indirect diff --git a/go.sum b/go.sum index 3f04f3084..8c68c69f5 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,8 @@ github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY= github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4= @@ -187,9 +189,13 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/cohere-ai/tokenizer v1.1.2 h1:t3KwUBSpKiBVFtpnHBfVIQNmjfZUuqFVYuSFkZYOWpU= github.com/cohere-ai/tokenizer v1.1.2/go.mod h1:9MNFPd9j1fuiEK3ua2HSCUxxcrfGMlSqpa93livg/C0= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/crewjam/saml v0.5.1 h1:g+mfp0CrLuLRZCK793PgJcZeg5dS/0CDwoeAX2zcwNI= +github.com/crewjam/saml v0.5.1/go.mod h1:r0fDkmFe5URDgPrmtH0IYokva6fac3AUdstiPhyEolQ= github.com/d1vbyz3r0/typed v0.2.3 h1:qvz08JwVvZazA5KgoxFXJ5USQnHDjGTzK5pR76pnu64= github.com/d1vbyz3r0/typed v0.2.3/go.mod h1:QIJsQnr29WFJ1oe6jb91Y3Bo5gtAcF4vaYeJjWIUJ68= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -231,8 +237,6 @@ github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/ github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= -github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= -github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4= github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -272,6 +276,8 @@ github.com/go-swiss/fonts v0.0.0-20230807175105-90067c2f5042/go.mod h1:kDru5pqfn github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -411,6 +417,8 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattermost/xml-roundtrip-validator v0.1.0 h1:RXbVD2UAl7A7nOTR4u7E3ILa4IbtvKBHw64LDsmu9hU= +github.com/mattermost/xml-roundtrip-validator v0.1.0/go.mod h1:qccnGMcpgwcNaBnxqpJpWWUiPNr5H3O8eDgGV9gT5To= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= @@ -496,6 +504,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/russellhaering/goxmldsig v1.6.0 h1:8fdWXEPh2k/NZNQBPFNoVfS3JmzS4ZprY/sAOpKQLks= +github.com/russellhaering/goxmldsig v1.6.0/go.mod h1:TrnaquDcYxWXfJrOjeMBTX4mLBeYAqaHEyUeWPxZlBM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= @@ -713,8 +723,6 @@ golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw= -golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY= golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -722,8 +730,8 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= +golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -862,6 +870,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/api/auth/handlers.go b/internal/api/auth/handlers.go index 0bb3aabaf..c18a643f8 100644 --- a/internal/api/auth/handlers.go +++ b/internal/api/auth/handlers.go @@ -15,6 +15,7 @@ import ( "github.com/labstack/echo/v4" "github.com/livereview/internal/auditlog" + "github.com/livereview/internal/sso" "github.com/livereview/pkg/models" "golang.org/x/crypto/bcrypt" ) @@ -30,6 +31,10 @@ type AuthHandlers struct { // Optional: nil just means EnsureCloudUser skips eager generation (dashboard's own lazy // fallback in collectOnboardingData still covers it). onboardingKeyGenerator func(tx *sql.Tx, userID, orgID int64) (string, error) + // sso/ssoState are optional (nil in tests that never call SetSSO): SSO + // login routes degrade to "not configured" rather than panicking. + sso *sso.Store + ssoState *sso.StateSigner } // NewAuthHandlers creates a new authentication handlers instance diff --git a/internal/api/auth/sso_handlers.go b/internal/api/auth/sso_handlers.go new file mode 100644 index 000000000..43f1bd721 --- /dev/null +++ b/internal/api/auth/sso_handlers.go @@ -0,0 +1,443 @@ +package auth + +import ( + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/xml" + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/auditlog" + "github.com/livereview/internal/sso" + "github.com/livereview/pkg/models" + "golang.org/x/crypto/bcrypt" +) + +// SetSSO wires the SSO config store and state signer into the auth +// handlers. Both the OIDC and (later) SAML login routes, and the config +// CRUD handlers that validate against a live IdP, depend on this. +func (h *AuthHandlers) SetSSO(store *sso.Store, jwtSecret string) { + h.sso = store + h.ssoState = sso.NewStateSigner(jwtSecret) +} + +// baseURL derives this request's own origin, used both as the OIDC/SAML +// redirect/ACS URL registered with the IdP and as the final browser +// redirect back into the frontend. Self-hosted serves API and frontend from +// the same origin behind one reverse proxy (see getSystemInfo in +// internal/api/server.go), so this is correct for both purposes without a +// separate "public URL" setting. +func baseURL(c echo.Context) string { + return fmt.Sprintf("%s://%s", c.Scheme(), c.Request().Host) +} + +func randomNonce() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// ssoFailureRedirect sends the browser back to the login page with an error +// hint, rather than returning raw JSON - this leg of the flow is a full-page +// browser navigation, not an XHR call. +func ssoFailureRedirect(c echo.Context, reason string) error { + return c.Redirect(http.StatusFound, "/login?sso_error="+url.QueryEscape(reason)) +} + +// SSOProviderInfo is what the (unauthenticated) login page uses to decide +// whether to show a "Continue with ..." button. +type SSOProviderInfo struct { + OrgID int64 `json:"org_id"` + OrgName string `json:"org_name"` + ProviderType string `json:"provider_type"` + DisplayName string `json:"display_name"` +} + +// SSOProviders is public: GET /api/v1/auth/sso/providers. In self-hosted +// deployments this normally returns at most one org's providers, since +// self-hosted is typically single-org. +func (h *AuthHandlers) SSOProviders(c echo.Context) error { + if h.sso == nil { + return c.JSON(http.StatusOK, []SSOProviderInfo{}) + } + configs, err := h.sso.ListEnabled() + if err != nil { + log.Printf("[sso] failed to list enabled providers: %v", err) + return c.JSON(http.StatusOK, []SSOProviderInfo{}) + } + out := make([]SSOProviderInfo, 0, len(configs)) + for _, cfg := range configs { + var orgName string + _ = h.db.QueryRow(`SELECT name FROM orgs WHERE id = $1`, cfg.OrgID).Scan(&orgName) + out = append(out, SSOProviderInfo{ + OrgID: cfg.OrgID, + OrgName: orgName, + ProviderType: string(cfg.ProviderType), + DisplayName: cfg.DisplayName, + }) + } + return c.JSON(http.StatusOK, out) +} + +// SSOOIDCLogin is public: GET /api/v1/auth/sso/oidc/:org_id/login. It redirects +// the browser to the org's configured IdP. +func (h *AuthHandlers) SSOOIDCLogin(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil || orgID <= 0 { + return ssoFailureRedirect(c, "invalid_org") + } + if h.sso == nil { + return ssoFailureRedirect(c, "sso_not_configured") + } + cfg, err := h.sso.Get(orgID, sso.ProviderOIDC) + if err != nil || !cfg.Enabled { + return ssoFailureRedirect(c, "sso_not_configured") + } + + nonce, err := randomNonce() + if err != nil { + return ssoFailureRedirect(c, "internal_error") + } + state := h.ssoState.Encode(orgID, nonce) + + redirectURL := baseURL(c) + "/api/v1/auth/sso/oidc/callback" + oauthCfg, _, err := sso.BuildOIDCOAuth2Config(c.Request().Context(), cfg, redirectURL) + if err != nil { + log.Printf("[sso] oidc login for org %d failed: %v", orgID, err) + return ssoFailureRedirect(c, "idp_unreachable") + } + return c.Redirect(http.StatusFound, oauthCfg.AuthCodeURL(state)) +} + +// SSOOIDCCallback is public: GET /api/v1/auth/sso/oidc/callback. The IdP +// redirects the browser here with ?code=...&state=.... +func (h *AuthHandlers) SSOOIDCCallback(c echo.Context) error { + if h.sso == nil { + return ssoFailureRedirect(c, "sso_not_configured") + } + state := c.QueryParam("state") + code := c.QueryParam("code") + if state == "" || code == "" { + return ssoFailureRedirect(c, "missing_code_or_state") + } + orgID, err := h.ssoState.Decode(state) + if err != nil { + return ssoFailureRedirect(c, "invalid_state") + } + cfg, err := h.sso.Get(orgID, sso.ProviderOIDC) + if err != nil || !cfg.Enabled { + return ssoFailureRedirect(c, "sso_not_configured") + } + + redirectURL := baseURL(c) + "/api/v1/auth/sso/oidc/callback" + oauthCfg, verifier, err := sso.BuildOIDCOAuth2Config(c.Request().Context(), cfg, redirectURL) + if err != nil { + log.Printf("[sso] oidc callback discovery for org %d failed: %v", orgID, err) + return ssoFailureRedirect(c, "idp_unreachable") + } + identity, err := sso.ExchangeOIDCCode(c.Request().Context(), oauthCfg, verifier, code) + if err != nil { + log.Printf("[sso] oidc callback exchange for org %d failed: %v", orgID, err) + h.recordAuthEvent(c, auditlog.Event{ + OrgID: orgID, + ActorKind: auditlog.ActorKindMember, + Action: auditlog.ActionLoginFailure, + Outcome: auditlog.OutcomeFailure, + Severity: auditlog.SeverityWarning, + Details: auditlog.Details("method", "sso_oidc", "reason", "exchange_failed"), + }) + return ssoFailureRedirect(c, "login_failed") + } + return h.completeSSOLogin(c, orgID, identity, "sso_oidc") +} + +// samlURLs derives this org's metadata/ACS URLs from the current request's +// own origin, same reasoning as baseURL for OIDC's redirect URL. +func samlURLs(c echo.Context, orgID int64) (metadataURL, acsURL string) { + base := baseURL(c) + "/api/v1/auth/sso/saml/" + strconv.FormatInt(orgID, 10) + return base + "/metadata", base + "/acs" +} + +// SSOSAMLMetadata is public: GET /api/v1/auth/sso/saml/:org_id/metadata. +// IdP admins fetch this to configure LiveReview as a SAML SP. +func (h *AuthHandlers) SSOSAMLMetadata(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil || orgID <= 0 { + return c.String(http.StatusBadRequest, "invalid org") + } + if h.sso == nil { + return c.String(http.StatusNotFound, "sso not configured") + } + cfg, err := h.sso.Get(orgID, sso.ProviderSAML) + if err != nil { + return c.String(http.StatusNotFound, "sso not configured") + } + metadataURL, acsURL := samlURLs(c, orgID) + sp, err := sso.BuildServiceProvider(c.Request().Context(), cfg, metadataURL, acsURL) + if err != nil { + log.Printf("[sso] saml metadata for org %d failed: %v", orgID, err) + return c.String(http.StatusBadGateway, "idp metadata unavailable") + } + buf, err := xml.MarshalIndent(sp.Metadata(), "", " ") + if err != nil { + return c.String(http.StatusInternalServerError, "failed to render metadata") + } + return c.Blob(http.StatusOK, "application/samlmetadata+xml", buf) +} + +// SSOSAMLLogin is public: GET /api/v1/auth/sso/saml/:org_id/login. It +// redirects the browser to the org's configured IdP (HTTP-Redirect +// binding). +func (h *AuthHandlers) SSOSAMLLogin(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil || orgID <= 0 { + return ssoFailureRedirect(c, "invalid_org") + } + if h.sso == nil { + return ssoFailureRedirect(c, "sso_not_configured") + } + cfg, err := h.sso.Get(orgID, sso.ProviderSAML) + if err != nil || !cfg.Enabled { + return ssoFailureRedirect(c, "sso_not_configured") + } + metadataURL, acsURL := samlURLs(c, orgID) + sp, err := sso.BuildServiceProvider(c.Request().Context(), cfg, metadataURL, acsURL) + if err != nil { + log.Printf("[sso] saml login for org %d failed: %v", orgID, err) + return ssoFailureRedirect(c, "idp_unreachable") + } + nonce, err := randomNonce() + if err != nil { + return ssoFailureRedirect(c, "internal_error") + } + state := h.ssoState.Encode(orgID, nonce) + redirectURL, err := sp.MakeRedirectAuthenticationRequest(state) + if err != nil { + log.Printf("[sso] saml authn request for org %d failed: %v", orgID, err) + return ssoFailureRedirect(c, "idp_unreachable") + } + return c.Redirect(http.StatusFound, redirectURL.String()) +} + +// SSOSAMLACS is public: POST /api/v1/auth/sso/saml/:org_id/acs - the +// Assertion Consumer Service the IdP POSTs the SAMLResponse to. +func (h *AuthHandlers) SSOSAMLACS(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil || orgID <= 0 { + return ssoFailureRedirect(c, "invalid_org") + } + if h.sso == nil { + return ssoFailureRedirect(c, "sso_not_configured") + } + // ParseForm once, up front: crewjam's ParseResponse reads + // req.PostForm directly rather than calling FormValue itself, so it + // must already be populated. + if err := c.Request().ParseForm(); err != nil { + return ssoFailureRedirect(c, "invalid_response") + } + if _, err := h.ssoState.Decode(c.Request().PostForm.Get("RelayState")); err != nil { + return ssoFailureRedirect(c, "invalid_state") + } + cfg, err := h.sso.Get(orgID, sso.ProviderSAML) + if err != nil || !cfg.Enabled { + return ssoFailureRedirect(c, "sso_not_configured") + } + metadataURL, acsURL := samlURLs(c, orgID) + sp, err := sso.BuildServiceProvider(c.Request().Context(), cfg, metadataURL, acsURL) + if err != nil { + log.Printf("[sso] saml acs discovery for org %d failed: %v", orgID, err) + return ssoFailureRedirect(c, "idp_unreachable") + } + identity, err := sso.ExchangeSAMLResponse(sp, c.Request()) + if err != nil { + log.Printf("[sso] saml acs exchange for org %d failed: %v", orgID, err) + h.recordAuthEvent(c, auditlog.Event{ + OrgID: orgID, + ActorKind: auditlog.ActorKindMember, + Action: auditlog.ActionLoginFailure, + Outcome: auditlog.OutcomeFailure, + Severity: auditlog.SeverityWarning, + Details: auditlog.Details("method", "sso_saml", "reason", "exchange_failed"), + }) + return ssoFailureRedirect(c, "login_failed") + } + return h.completeSSOLogin(c, orgID, identity, "sso_saml") +} + +// completeSSOLogin is the single convergence point for every SSO protocol: +// look up (or JIT-provision) the user in the target org, issue the same JWT +// pair password login issues, and send the browser back to the frontend +// with it. Never creates or touches a user outside orgID. +func (h *AuthHandlers) completeSSOLogin(c echo.Context, orgID int64, identity *sso.Identity, method string) error { + email := strings.ToLower(strings.TrimSpace(identity.Email)) + if email == "" { + return ssoFailureRedirect(c, "no_email_claim") + } + + user, created, err := h.jitProvisionSSOUser(orgID, email, identity.Name) + if err != nil { + log.Printf("[sso] JIT provisioning failed for %s in org %d: %v", email, orgID, err) + h.recordAuthEvent(c, auditlog.Event{ + OrgID: orgID, + ActorEmail: email, + ActorKind: auditlog.ActorKindMember, + Action: auditlog.ActionLoginFailure, + Outcome: auditlog.OutcomeFailure, + Severity: auditlog.SeverityWarning, + Details: auditlog.Details("method", method, "reason", "provisioning_failed"), + }) + return ssoFailureRedirect(c, "provisioning_failed") + } + + userAgent := c.Request().Header.Get("User-Agent") + ipAddress := c.RealIP() + tokenPair, err := h.tokenService.CreateTokenPairWithOrg(user, orgID, userAgent, ipAddress) + if err != nil { + log.Printf("[sso] token issuance failed for %s in org %d: %v", email, orgID, err) + return ssoFailureRedirect(c, "internal_error") + } + + _, _ = h.db.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID) + + uid := user.ID + actorKind, actorCapacity := h.actorFor(user) + if created { + h.recordAuthEvent(c, auditlog.Event{ + OrgID: orgID, + ActorUserID: &uid, + ActorEmail: email, + ActorKind: actorKind, + ActorCapacity: actorCapacity, + Action: auditlog.ActionSSOProvision, + Outcome: auditlog.OutcomeSuccess, + Severity: auditlog.SeverityInfo, + Details: auditlog.Details("method", method, "role", "member"), + }) + } + h.recordAuthEvent(c, auditlog.Event{ + OrgID: orgID, + ActorUserID: &uid, + ActorEmail: email, + ActorKind: actorKind, + ActorCapacity: actorCapacity, + Action: auditlog.ActionLoginSuccess, + Outcome: auditlog.OutcomeSuccess, + Severity: auditlog.SeverityInfo, + Details: auditlog.Details("method", method), + }) + + target := fmt.Sprintf("%s/sso/callback#access_token=%s&refresh_token=%s&expires_at=%s&token_type=%s", + baseURL(c), + url.QueryEscape(tokenPair.AccessToken), + url.QueryEscape(tokenPair.RefreshToken), + url.QueryEscape(strconv.FormatInt(tokenPair.ExpiresAt.Unix(), 10)), + url.QueryEscape(tokenPair.TokenType), + ) + return c.Redirect(http.StatusFound, target) +} + +// jitProvisionSSOUser finds the user by email. If they already exist +// globally, they're linked into orgID as a member (if not already a +// member there) - never promoted past member and never moved out of any +// other org they belong to. If they don't exist at all, they're created +// fresh as a member of orgID with no usable password (SSO is their only +// login path until an owner sets one). +func (h *AuthHandlers) jitProvisionSSOUser(orgID int64, email, displayName string) (*models.User, bool, error) { + var userID int64 + created := false + + err := func() error { + tx, err := h.db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + err = tx.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID) + if err == sql.ErrNoRows { + randBytes := make([]byte, 32) + if _, rerr := rand.Read(randBytes); rerr != nil { + return rerr + } + hashed, herr := bcrypt.GenerateFromPassword([]byte(hex.EncodeToString(randBytes)), bcrypt.DefaultCost) + if herr != nil { + return herr + } + firstName, lastName := splitDisplayName(displayName) + if err := tx.QueryRow(` + INSERT INTO users (email, password_hash, first_name, last_name, default_org_id, password_reset_required, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, false, NOW(), NOW()) + RETURNING id + `, email, string(hashed), nullableStr(firstName), nullableStr(lastName), orgID).Scan(&userID); err != nil { + return err + } + if _, err := tx.Exec(` + INSERT INTO user_roles (user_id, org_id, role_id) + VALUES ($1, $2, (SELECT id FROM roles WHERE name = 'member')) + `, userID, orgID); err != nil { + return err + } + created = true + } else if err != nil { + return err + } else { + var isMember bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM user_roles WHERE user_id = $1 AND org_id = $2)`, userID, orgID).Scan(&isMember); err != nil { + return err + } + if !isMember { + if _, err := tx.Exec(` + INSERT INTO user_roles (user_id, org_id, role_id) + VALUES ($1, $2, (SELECT id FROM roles WHERE name = 'member')) + `, userID, orgID); err != nil { + return err + } + created = true + } + if _, err := tx.Exec(`UPDATE users SET default_org_id = COALESCE(default_org_id, $1), is_active = TRUE WHERE id = $2`, orgID, userID); err != nil { + return err + } + } + return tx.Commit() + }() + if err != nil { + return nil, false, err + } + + user := &models.User{} + err = h.db.QueryRow(` + SELECT id, email, password_hash, default_org_id, created_at, updated_at + FROM users WHERE id = $1 + `, userID).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.DefaultOrgID, &user.CreatedAt, &user.UpdatedAt) + if err != nil { + return nil, false, err + } + return user, created, nil +} + +func splitDisplayName(name string) (first, last string) { + parts := strings.Fields(name) + switch len(parts) { + case 0: + return "", "" + case 1: + return parts[0], "" + default: + return parts[0], strings.Join(parts[1:], " ") + } +} + +func nullableStr(s string) interface{} { + if s == "" { + return nil + } + return s +} diff --git a/internal/api/server.go b/internal/api/server.go index 3db792449..6207891fa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -50,6 +50,7 @@ import ( reviewprocessor "github.com/livereview/internal/review_processor" "github.com/livereview/internal/scheduledreview" "github.com/livereview/internal/slackbot" + "github.com/livereview/internal/sso" "github.com/livereview/internal/teamsbot" storageanalytics "github.com/livereview/storage/analytics" "github.com/livereview/storage/core" @@ -148,6 +149,7 @@ type Server struct { versionInfo *VersionInfo deploymentConfig *DeploymentConfig authHandlers *auth.AuthHandlers + ssoStore *sso.Store // nil only if the secret box failed to init (see SetSSO wiring) githubApp *githubapp.Config // nil unless GITHUB_APP_* is configured githubAppSecret []byte // HMAC key for the GitHub App OAuth state tokenService *auth.TokenService @@ -321,6 +323,16 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) { tokenService := auth.NewTokenService(db, jwtSecret) authHandlers := auth.NewAuthHandlers(tokenService, db) + // Enterprise SSO (OIDC/SAML) - see docs/sso.md. The secret box reuses + // JWT_SECRET rather than requiring a dedicated encryption key. + var ssoStore *sso.Store + if ssoBox, err := sso.NewSecretBox(jwtSecret); err != nil { + log.Printf("[sso] disabled: %v", err) + } else { + ssoStore = sso.NewStore(db, ssoBox) + authHandlers.SetSSO(ssoStore, jwtSecret) + } + // Audit trail framework: recorder + route-spec registry. The HTTP // middleware records mutations automatically; see AGENTS.md. auditRecorder := auditlog.NewRecorder(db) @@ -379,6 +391,7 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) { versionInfo: versionInfo, deploymentConfig: deploymentConfig, authHandlers: authHandlers, + ssoStore: ssoStore, githubApp: githubapp.Load(), githubAppSecret: []byte(jwtSecret), tokenService: tokenService, @@ -1153,6 +1166,17 @@ func (s *Server) setupRoutes() { public.POST("/auth/setup", s.authHandlers.SetupAdmin) public.POST("/auth/onboard", s.Onboard) + // Enterprise SSO (OIDC/SAML) login + discovery - see docs/sso.md. + // Public by necessity (this IS the login mechanism); org-scoping is + // enforced inside the handlers via the signed state token / SP config, + // never from a client-supplied header. + public.GET("/auth/sso/providers", s.authHandlers.SSOProviders) + public.GET("/auth/sso/oidc/:org_id/login", s.authHandlers.SSOOIDCLogin) + public.GET("/auth/sso/oidc/callback", s.authHandlers.SSOOIDCCallback) + public.GET("/auth/sso/saml/:org_id/metadata", s.authHandlers.SSOSAMLMetadata) + public.GET("/auth/sso/saml/:org_id/login", s.authHandlers.SSOSAMLLogin) + public.POST("/auth/sso/saml/:org_id/acs", s.authHandlers.SSOSAMLACS) + // Diff review endpoints. Accepts either an X-API-Key (the git-lrc CLI's // auth) or a Bearer session token (the web UI's review-details diff // viewer, which reads this same data via GET /:review_id) — mirrors the @@ -1317,6 +1341,16 @@ func (s *Server) setupRoutes() { orgGroup.DELETE("/users/:user_id/identities/:id", s.removeUserIdentity) orgGroup.GET("/identities", s.listOrgIdentities) + // Enterprise SSO (OIDC/SAML) configuration - see docs/sso.md. + // Session-only, owner/super-admin-only, self-hosted-enterprise-only + // (all enforced inside requireSSOAccess, since the gate also depends on + // the org's license plan, not just role/API-key checks s.audited can + // express declaratively). + orgGroup.GET("/sso-config", s.ListSSOConfigs) + s.audited(orgGroup, echo.PUT, "/sso-config/:provider_type", auditlog.Spec{Action: auditlog.ActionSettingsSSOUpdate, Category: auditlog.CategorySettings, ResourceType: "org_sso_config", ResourceIDParam: "provider_type", Sensitive: true}, s.UpsertSSOConfig) + s.audited(orgGroup, echo.DELETE, "/sso-config/:provider_type", auditlog.Spec{Action: auditlog.ActionSettingsSSODelete, Category: auditlog.CategorySettings, ResourceType: "org_sso_config", ResourceIDParam: "provider_type", Sensitive: true}, s.DeleteSSOConfig) + orgGroup.POST("/sso-config/:provider_type/test", s.TestSSOConfig) + // Super admin routes - Production adminGroup := v1.Group("/admin") adminGroup.Use(RequireAuthOrAPIKey(s.tokenService, s.db)) @@ -1791,7 +1825,26 @@ func (s *Server) setupRoutes() { subscriptionsGroup.Use(authMiddleware.BuildOrgContextFromHeader()) subscriptionsGroup.Use(authMiddleware.ValidateOrgAccess()) subscriptionsGroup.Use(authMiddleware.BuildPermissionContext()) - subscriptionMutationLimiter := middleware.RateLimiter(middleware.NewRateLimiterMemoryStore(30)) + // Identify by authenticated user, not RealIP(): these routes always run + // after BuildPermissionContext, and echo's default IdentifierExtractor + // trusts the client-supplied X-Forwarded-For/X-Real-IP headers, letting + // an attacker rotate them to get a fresh rate-limit bucket on every + // request (GHSA-246p-cpwv-v3jq / GHSA-99jh-6h7p-pp36, no echo v4 fix). + subscriptionMutationLimiter := middleware.RateLimiterWithConfig(middleware.RateLimiterConfig{ + Store: middleware.NewRateLimiterMemoryStore(30), + IdentifierExtractor: func(c echo.Context) (string, error) { + // This group's middleware chain (RequireAuthOrAPIKey -> + // BuildPermissionContext) always runs before this limiter and + // aborts on auth failure, so permCtx should never be nil here. + // Fail closed rather than fall back to the spoofable RealIP() + // if that invariant is ever broken by a future routing change. + permCtx := auth.GetPermissionContext(c) + if permCtx == nil { + return "", fmt.Errorf("rate limiter: no permission context on authenticated route") + } + return strconv.FormatInt(permCtx.GetUserID(), 10), nil + }, + }) subscriptionsGroup.POST("", subscriptionsHandler.CreateSubscription) subscriptionsGroup.POST("/confirm-purchase", subscriptionsHandler.ConfirmPurchase) diff --git a/internal/api/sso_config.go b/internal/api/sso_config.go new file mode 100644 index 000000000..ee816e291 --- /dev/null +++ b/internal/api/sso_config.go @@ -0,0 +1,320 @@ +package api + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/labstack/echo/v4" + "github.com/livereview/internal/api/auth" + apimiddleware "github.com/livereview/internal/api/middleware" + "github.com/livereview/internal/auditlog" + "github.com/livereview/internal/license" + "github.com/livereview/internal/sso" +) + +// Self-hosted enterprise only, per docs/sso.md: SSO is not offered on cloud +// (yet). This checks the current org's plan rather than a hardcoded +// "self-hosted" bit, so the moment cloud ever adds "sso" to a plan's +// Features, this gate opens for that org with no code change here. +func (s *Server) orgHasSSOFeature(orgID int64) bool { + var planCode string + _ = s.db.QueryRow(`SELECT current_plan_code FROM org_billing_state WHERE org_id = $1`, orgID).Scan(&planCode) + return license.PlanType(planCode).HasFeature("sso") +} + +// ssoConfigRequest is the write-side payload for PUT /sso-config/:provider_type. +// OIDCClientSecret is a pointer so "omit the field" (keep existing secret) +// is distinguishable from "empty string" (clear it) on update. +type ssoConfigRequest struct { + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCClientID string `json:"oidc_client_id,omitempty"` + OIDCClientSecret *string `json:"oidc_client_secret,omitempty"` + OIDCScopes string `json:"oidc_scopes,omitempty"` + + SAMLIDPMetadataURL string `json:"saml_idp_metadata_url,omitempty"` + SAMLIDPMetadataXML string `json:"saml_idp_metadata_xml,omitempty"` + SAMLIDPEntityID string `json:"saml_idp_entity_id,omitempty"` +} + +// ssoConfigResponse never carries secrets - this is what GET/PUT return. +type ssoConfigResponse struct { + ProviderType string `json:"provider_type"` + DisplayName string `json:"display_name"` + Enabled bool `json:"enabled"` + OIDCIssuer string `json:"oidc_issuer,omitempty"` + OIDCClientID string `json:"oidc_client_id,omitempty"` + OIDCClientSecretSet bool `json:"oidc_client_secret_set"` + OIDCScopes string `json:"oidc_scopes,omitempty"` + SAMLIDPMetadataURL string `json:"saml_idp_metadata_url,omitempty"` + SAMLIDPEntityID string `json:"saml_idp_entity_id,omitempty"` + SAMLMetadataSet bool `json:"saml_metadata_set"` +} + +func redactedSSOConfig(cfg *sso.Config) ssoConfigResponse { + return ssoConfigResponse{ + ProviderType: string(cfg.ProviderType), + DisplayName: cfg.DisplayName, + Enabled: cfg.Enabled, + OIDCIssuer: cfg.OIDCIssuer, + OIDCClientID: cfg.OIDCClientID, + OIDCClientSecretSet: cfg.OIDCClientSecret != "", + OIDCScopes: cfg.OIDCScopes, + SAMLIDPMetadataURL: cfg.SAMLIDPMetadataURL, + SAMLIDPEntityID: cfg.SAMLIDPEntityID, + SAMLMetadataSet: cfg.SAMLIDPMetadataXML != "", + } +} + +func parseProviderType(raw string) (sso.ProviderType, bool) { + switch sso.ProviderType(raw) { + case sso.ProviderOIDC, sso.ProviderSAML: + return sso.ProviderType(raw), true + default: + return "", false + } +} + +// requireSSOAccess checks the "Sensitive Operations Gate" + "Session-Only +// Gating" + self-hosted-enterprise-feature rules from CLAUDE.md in one +// place, since every SSO config handler needs all three. +func (s *Server) requireSSOAccess(c echo.Context, orgID int64) error { + if err := requireSession(c); err != nil { + return err + } + pc := auth.GetPermissionContext(c) + if pc == nil { + return c.JSON(http.StatusForbidden, map[string]string{"error": "only org owners can manage SSO configuration"}) + } + if err := pc.RequireOrgOwner(); err != nil { + return c.JSON(http.StatusForbidden, map[string]string{"error": "only org owners can manage SSO configuration"}) + } + if apimiddleware.IsCloudMode() { + return c.JSON(http.StatusForbidden, map[string]string{"error": "SSO is available on self-hosted enterprise deployments only"}) + } + if !s.orgHasSSOFeature(orgID) { + return c.JSON(http.StatusForbidden, map[string]string{"error": "SSO requires an enterprise self-hosted license"}) + } + if s.ssoStore == nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "SSO is not available on this deployment"}) + } + return nil +} + +// ListSSOConfigs returns both provider configs for the org (whichever +// exist), with secrets redacted. +func (s *Server) ListSSOConfigs(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid org_id"}) + } + if err := s.requireSSOAccess(c, orgID); err != nil { + return err + } + out := []ssoConfigResponse{} + for _, provider := range []sso.ProviderType{sso.ProviderOIDC, sso.ProviderSAML} { + cfg, err := s.ssoStore.Get(orgID, provider) + if err == sso.ErrNotFound { + continue + } + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load SSO configuration"}) + } + out = append(out, redactedSSOConfig(cfg)) + } + return c.JSON(http.StatusOK, out) +} + +// UpsertSSOConfig is PUT /orgs/:org_id/sso-config/:provider_type. Registered +// via s.audited with ActionSettingsSSOUpdate, Sensitive: true. +func (s *Server) UpsertSSOConfig(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid org_id"}) + } + provider, ok := parseProviderType(c.Param("provider_type")) + if !ok { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "provider_type must be 'oidc' or 'saml'"}) + } + if err := s.requireSSOAccess(c, orgID); err != nil { + return err + } + + var req ssoConfigRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + if req.DisplayName == "" { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "display_name is required"}) + } + + cfg := &sso.Config{ + OrgID: orgID, + ProviderType: provider, + DisplayName: req.DisplayName, + Enabled: req.Enabled, + OIDCIssuer: req.OIDCIssuer, + OIDCClientID: req.OIDCClientID, + OIDCScopes: req.OIDCScopes, + SAMLIDPMetadataURL: req.SAMLIDPMetadataURL, + SAMLIDPMetadataXML: req.SAMLIDPMetadataXML, + SAMLIDPEntityID: req.SAMLIDPEntityID, + } + + // Preserve the existing secret/metadata unless the caller explicitly + // sent a replacement, so the settings UI never has to round-trip a + // decrypted secret just to leave it unchanged. A real (non-not-found) + // lookup error must fail the request rather than be treated as "no + // existing config" - silently swallowing it here would wipe the saved + // client secret/metadata on a transient DB error. + existing, gerr := s.ssoStore.Get(orgID, provider) + if gerr != nil && gerr != sso.ErrNotFound { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load existing SSO configuration"}) + } + if gerr == nil { + cfg.OIDCClientSecret = existing.OIDCClientSecret + if cfg.SAMLIDPMetadataXML == "" { + cfg.SAMLIDPMetadataXML = existing.SAMLIDPMetadataXML + } + } + if req.OIDCClientSecret != nil { + cfg.OIDCClientSecret = *req.OIDCClientSecret + } + + pc := auth.GetPermissionContext(c) + var actorUserID int64 + if pc != nil && pc.User != nil { + actorUserID = pc.User.ID + } + + tx, err := s.db.BeginTx(c.Request().Context(), nil) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save SSO configuration"}) + } + defer func() { _ = tx.Rollback() }() + + if err := s.ssoStore.UpsertTx(tx, cfg, actorUserID); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save SSO configuration"}) + } + if s.auditRecorder != nil { + if err := s.auditRecorder.RecordTxEcho(c, tx, auditlog.Event{ + OrgID: orgID, + Action: auditlog.ActionSettingsSSOUpdate, + Category: auditlog.CategorySettings, + ResourceType: "org_sso_config", + ResourceID: string(provider), + Outcome: auditlog.OutcomeSuccess, + Severity: auditlog.SeverityCritical, + Details: auditlog.Details("provider_type", string(provider), "enabled", req.Enabled, "display_name", req.DisplayName), + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to record audit event"}) + } + } + if err := tx.Commit(); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to save SSO configuration"}) + } + + saved, err := s.ssoStore.Get(orgID, provider) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "saved, but failed to re-read configuration"}) + } + return c.JSON(http.StatusOK, redactedSSOConfig(saved)) +} + +// DeleteSSOConfig is DELETE /orgs/:org_id/sso-config/:provider_type. +func (s *Server) DeleteSSOConfig(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid org_id"}) + } + provider, ok := parseProviderType(c.Param("provider_type")) + if !ok { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "provider_type must be 'oidc' or 'saml'"}) + } + if err := s.requireSSOAccess(c, orgID); err != nil { + return err + } + + tx, err := s.db.BeginTx(c.Request().Context(), nil) + if err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete SSO configuration"}) + } + defer func() { _ = tx.Rollback() }() + + if err := s.ssoStore.DeleteTx(tx, orgID, provider); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete SSO configuration"}) + } + if s.auditRecorder != nil { + if err := s.auditRecorder.RecordTxEcho(c, tx, auditlog.Event{ + OrgID: orgID, + Action: auditlog.ActionSettingsSSODelete, + Category: auditlog.CategorySettings, + ResourceType: "org_sso_config", + ResourceID: string(provider), + Outcome: auditlog.OutcomeSuccess, + Severity: auditlog.SeverityCritical, + Details: auditlog.Details("provider_type", string(provider)), + }); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to record audit event"}) + } + } + if err := tx.Commit(); err != nil { + return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to delete SSO configuration"}) + } + return c.JSON(http.StatusOK, map[string]string{"message": "SSO configuration deleted"}) +} + +// TestSSOConfig is POST /orgs/:org_id/sso-config/:provider_type/test. It +// validates the IdP is reachable without saving anything, so the settings +// UI can give immediate feedback before an owner commits a change. +func (s *Server) TestSSOConfig(c echo.Context) error { + orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64) + if err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid org_id"}) + } + provider, ok := parseProviderType(c.Param("provider_type")) + if !ok { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "provider_type must be 'oidc' or 'saml'"}) + } + if err := s.requireSSOAccess(c, orgID); err != nil { + return err + } + + var req ssoConfigRequest + if err := c.Bind(&req); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + } + + switch provider { + case sso.ProviderOIDC: + ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) + defer cancel() + if err := sso.TestOIDCDiscovery(ctx, req.OIDCIssuer); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"message": "OIDC discovery succeeded"}) + case sso.ProviderSAML: + ctx, cancel := context.WithTimeout(c.Request().Context(), 10*time.Second) + defer cancel() + testCfg := &sso.Config{ + ID: orgID, // only used for error messages here + ProviderType: sso.ProviderSAML, + SAMLIDPMetadataURL: req.SAMLIDPMetadataURL, + SAMLIDPMetadataXML: req.SAMLIDPMetadataXML, + } + if testCfg.SAMLIDPMetadataXML == "" { + if existing, gerr := s.ssoStore.Get(orgID, sso.ProviderSAML); gerr == nil { + testCfg.SAMLIDPMetadataXML = existing.SAMLIDPMetadataXML + } + } + if err := sso.TestSAMLMetadata(ctx, testCfg); err != nil { + return c.JSON(http.StatusBadRequest, map[string]string{"error": err.Error()}) + } + return c.JSON(http.StatusOK, map[string]string{"message": "SAML IdP metadata parsed successfully"}) + } + return c.JSON(http.StatusBadRequest, map[string]string{"error": "unsupported provider_type"}) +} diff --git a/internal/auditlog/catalog.go b/internal/auditlog/catalog.go index c1b85cbbf..f1c47c707 100644 --- a/internal/auditlog/catalog.go +++ b/internal/auditlog/catalog.go @@ -149,6 +149,8 @@ const ( ActionSettingsRiskModel Action = "settings.risk_model.update" ActionSettingsRetention Action = "settings.retention.update" ActionSettingsPrompts Action = "settings.prompts.update" + ActionSettingsSSOUpdate Action = "settings.sso.update" + ActionSettingsSSODelete Action = "settings.sso.delete" ActionConnectorCreate Action = "connector.create" ActionConnectorUpdate Action = "connector.update" @@ -283,6 +285,8 @@ var actionCatalog = func() map[Action]ActionMeta { sensitive(meta(ActionSettingsUpdate, CategorySettings, "setting", "Settings updated", "Platform or organization settings were changed.", SeverityWarning)), sensitive(meta(ActionSettingsSMTPUpdate, CategorySettings, "setting", "SMTP settings updated", "Outbound email settings were changed.", SeverityCritical)), sensitive(meta(ActionSettingsStorageUpdate, CategorySettings, "setting", "Storage settings updated", "The blob storage backend was changed.", SeverityCritical)), + sensitive(meta(ActionSettingsSSOUpdate, CategorySettings, "setting", "SSO settings updated", "An organization's single sign-on (OIDC/SAML) configuration was created or changed.", SeverityCritical)), + sensitive(meta(ActionSettingsSSODelete, CategorySettings, "setting", "SSO settings deleted", "An organization's single sign-on configuration was removed.", SeverityCritical)), sensitive(meta(ActionSettingsManagedAI, CategorySettings, "setting", "Managed AI updated", "LiveReview-managed AI configuration was changed.", SeverityCritical)), sensitive(meta(ActionSettingsAutoApprove, CategorySettings, "setting", "Auto-approve updated", "The auto-approve policy was changed.", SeverityWarning)), sensitive(meta(ActionSettingsRiskModel, CategorySettings, "setting", "Risk model updated", "Risk model configuration was changed.", SeverityWarning)), diff --git a/internal/docindex/docs/.synced-commits.env b/internal/docindex/docs/.synced-commits.env index c464ace0a..9f3eb6620 100644 --- a/internal/docindex/docs/.synced-commits.env +++ b/internal/docindex/docs/.synced-commits.env @@ -1,4 +1,4 @@ GIT_LRC_COMMIT=5b11de1daa1c8d3f861b3571f85bff6056e53bd7 GIT_LRC_WIKI_COMMIT=a1bb17c392a46c1f3bf6cfad6396813ccf80a1a9 -HEXMOSHOMEPAGE_DOCS_COMMIT=ee3099919f8516de5d8f767f75107cc12595a74b +HEXMOSHOMEPAGE_DOCS_COMMIT=d1248c89f2ef458d69333d67416ee6fb7171ee2f LIVEREVIEW_WIKI_COMMIT=8ade37693a4271c8785f2321dccc09e440797881 diff --git a/internal/docindex/docs/routes_guide/settings/settings-overview.md b/internal/docindex/docs/routes_guide/settings/settings-overview.md index b832c7915..add9dd3d6 100644 --- a/internal/docindex/docs/routes_guide/settings/settings-overview.md +++ b/internal/docindex/docs/routes_guide/settings/settings-overview.md @@ -20,6 +20,7 @@ tabs. Which tabs are visible depends on the user's role | Managed AI | `managed-ai` | super_admin, cloud only | | Deployment | `deployment` | super_admin or org owner | | License | `license` | super_admin or org owner, self-hosted only (tab absent in cloud) | +| SSO | `sso` | super_admin or org owner, self-hosted only (tab absent in cloud) | | Prompts | `prompts` | super_admin, or org owner/member | | Auto Approve | `auto-approve` | org owner (or super_admin) | | Learnings | `learnings` | any org member | @@ -38,7 +39,7 @@ the `subscriptions` tab. Each tab has its own file in this folder: [Account](account.md), [Instance](instance.md), [SMTP](smtp.md), [Storage](storage.md), [Deployment](deployment.md), -[License](license.md), [Managed AI](managed-ai.md), [Prompts](prompts.md), [Auto Approve](auto-approve.md), +[License](license.md), [SSO](sso.md), [Managed AI](managed-ai.md), [Prompts](prompts.md), [Auto Approve](auto-approve.md), [Learnings](learnings.md), [API Keys](api-keys.md), [MCP Integration](mcp-integration.md), [User Management](user-management.md), [Integrations](integrations.md), diff --git a/internal/docindex/docs/routes_guide/settings/sso.md b/internal/docindex/docs/routes_guide/settings/sso.md new file mode 100644 index 000000000..d46d5d84b --- /dev/null +++ b/internal/docindex/docs/routes_guide/settings/sso.md @@ -0,0 +1,76 @@ +# Settings → SSO + +**Route:** `/settings#sso` +**Who sees it:** self-hosted deployments only (super_admin or org owner). +The tab is not rendered at all in cloud mode - see `isCloudMode()` in +`ui/src/pages/Settings/Settings.tsx` (same gating as [License](license.md)). + +## Purpose + +Configure enterprise Single Sign-On so an organization's members can log +in with the company's identity provider (Okta, Azure AD/Entra ID, Google +Workspace, Keycloak, ADFS, etc.) instead of - or in addition to - an +email/password account. SSO is per-organization, additive, and optional: +turning it on for an org never disables password login for that org. + +Both OIDC and SAML 2.0 are supported, as two independently configured +providers an org can enable at the same time. Each has its own tab inside +the page (`ui/src/pages/Settings/SSOSettingsTab.tsx`). + +## Provisioning behavior + +The **first successful SSO login** for a given email auto-creates ("JIT +provisions") that user as a `member` of the org the SSO config belongs to, +if they aren't already a member. There is no separate "invite" step - an +org owner enabling SSO is granting entry to anyone who can authenticate +against that configured IdP, so the IdP's own group/access rules are the +real gatekeeper. + +## OIDC fields + +- **Display Name** - shown on the login page's "Continue with {name}" button. +- **Issuer URL** - the IdP's OIDC discovery issuer (e.g. + `https://your-idp.example.com/realms/your-realm`); discovery + (`/.well-known/openid-configuration`) is fetched live on every login, not cached. +- **Client ID** / **Client Secret** - from the IdP's OIDC client registration. + The secret is encrypted at rest and never redisplayed once saved (the + input just shows whether one is set). +- **Scopes** - space-separated, optional (defaults cover `openid email profile`). +- Give the IdP this **Redirect URI**: `{origin}/api/v1/auth/sso/oidc/callback`. + +## SAML fields + +- **Display Name** - same as above. +- **IdP Metadata URL** or pasted **IdP Metadata XML** (the XML, if provided, + takes priority over the URL). +- Give the IdP these: **SP Entity ID / Metadata URL** + `{origin}/api/v1/auth/sso/saml/{org_id}/metadata` and **ACS URL** + `{origin}/api/v1/auth/sso/saml/{org_id}/acs`. +- Requests are sent unsigned; LiveReview's SP has no signing keypair in this + release. The IdP's response/assertion signature is still verified. + +## Key actions + +- Toggle between the OIDC and SAML tabs; each provider's config is independent. +- Fill in the provider's fields and click **Test Connection** (hits OIDC + discovery or fetches/parses SAML metadata without saving) before saving. +- **Save** to persist, **Delete** to remove a provider's configuration entirely. +- Flip **Enabled** on a saved config to let users sign in with it immediately, + or off to stop new SSO logins without losing the saved settings. + +## Where this shows up elsewhere + +- The self-hosted login page (`ui/src/pages/Auth/SelfHosted.tsx`) calls the + public `GET /api/v1/auth/sso/providers` endpoint and renders a "Continue + with {name}" button per enabled provider, above the password form. +- A successful login (either protocol) redirects the browser to + `/sso/callback` (`ui/src/pages/Auth/SSOCallback.tsx`), which picks the + issued token pair out of the URL fragment and completes sign-in exactly + like password login. +- This feature requires the `sso` entry in the org's plan features + (`internal/license/plans.go`'s `PlanEnterpriseSelfhosted`); cloud orgs + don't have it today. + +## Related pages + +[Settings overview](settings-overview.md), [License](license.md) diff --git a/internal/license/plan_catalog.go b/internal/license/plan_catalog.go index 5d4ab0d63..40d41d5d5 100644 --- a/internal/license/plan_catalog.go +++ b/internal/license/plan_catalog.go @@ -130,7 +130,7 @@ func DefaultPlanCatalog() PlanCatalog { Rank: 999, MonthlyPriceUSD: 0, MonthlyLOCLimit: -1, - FeatureFlags: []string{"hosted_auto_model", "usage_envelope_v1", "byok_optional"}, + FeatureFlags: []string{"hosted_auto_model", "usage_envelope_v1", "byok_optional", "sso"}, TrialPolicy: PlanTrialPolicy{Enabled: false, Days: 0}, EnvelopeVisibility: PlanEnvelopeVisibility{ShowPrice: false}, }, diff --git a/internal/license/plans.go b/internal/license/plans.go index a18b88111..f87ced7ee 100644 --- a/internal/license/plans.go +++ b/internal/license/plans.go @@ -4,10 +4,10 @@ package license type PlanType string const ( - PlanFree30K PlanType = "free_30k" - PlanTeam32USD PlanType = "team_32usd" - PlanFree PlanType = PlanFree30K - PlanTeam PlanType = PlanTeam32USD + PlanFree30K PlanType = "free_30k" + PlanTeam32USD PlanType = "team_32usd" + PlanFree PlanType = PlanFree30K + PlanTeam PlanType = PlanTeam32USD PlanEnterpriseSelfhosted PlanType = "enterprise-selfhosted" PlanStarter100K PlanType = PlanTeam32USD @@ -161,6 +161,7 @@ var PlanDefinitions = map[PlanType]PlanLimits{ "hosted_auto_model", "email_support", "priority_support", + "sso", }, }, } diff --git a/internal/sso/config.go b/internal/sso/config.go new file mode 100644 index 000000000..43bcfc085 --- /dev/null +++ b/internal/sso/config.go @@ -0,0 +1,181 @@ +package sso + +import ( + "database/sql" + "errors" + "fmt" +) + +// ProviderType is the SSO protocol a config row speaks. +type ProviderType string + +const ( + ProviderOIDC ProviderType = "oidc" + ProviderSAML ProviderType = "saml" +) + +// Config is one org's SSO provider configuration. ClientSecret/metadata XML +// are plaintext here (decrypted on load) - never serialize this struct back +// to an HTTP response without stripping secrets first (see redactedConfig in +// handlers.go). +type Config struct { + ID int64 + OrgID int64 + ProviderType ProviderType + DisplayName string + Enabled bool + OIDCIssuer string + OIDCClientID string + OIDCClientSecret string + OIDCScopes string + SAMLIDPMetadataURL string + SAMLIDPMetadataXML string + SAMLIDPEntityID string +} + +// Store persists org_sso_configs, transparently encrypting/decrypting the +// OIDC client secret with box. +type Store struct { + db *sql.DB + box *SecretBox +} + +func NewStore(db *sql.DB, box *SecretBox) *Store { + return &Store{db: db, box: box} +} + +// ErrNotFound is returned when no config row exists for the (org, provider). +var ErrNotFound = errors.New("sso: config not found") + +func (s *Store) Get(orgID int64, provider ProviderType) (*Config, error) { + row := s.db.QueryRow(` + SELECT id, org_id, provider_type, display_name, enabled, + COALESCE(oidc_issuer, ''), COALESCE(oidc_client_id, ''), COALESCE(oidc_client_secret_enc, ''), COALESCE(oidc_scopes, ''), + COALESCE(saml_idp_metadata_url, ''), COALESCE(saml_idp_metadata_xml, ''), COALESCE(saml_idp_entity_id, '') + FROM org_sso_configs + WHERE org_id = $1 AND provider_type = $2 + `, orgID, string(provider)) + + cfg := &Config{} + var encSecret string + if err := row.Scan(&cfg.ID, &cfg.OrgID, &cfg.ProviderType, &cfg.DisplayName, &cfg.Enabled, + &cfg.OIDCIssuer, &cfg.OIDCClientID, &encSecret, &cfg.OIDCScopes, + &cfg.SAMLIDPMetadataURL, &cfg.SAMLIDPMetadataXML, &cfg.SAMLIDPEntityID); err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, fmt.Errorf("sso: loading config: %w", err) + } + secret, err := s.box.Decrypt(encSecret) + if err != nil { + return nil, fmt.Errorf("sso: decrypting client secret: %w", err) + } + cfg.OIDCClientSecret = secret + return cfg, nil +} + +// ListEnabled returns every org's enabled configs, newest first - used by +// the public login-discovery endpoint. +func (s *Store) ListEnabled() ([]*Config, error) { + rows, err := s.db.Query(` + SELECT id, org_id, provider_type, display_name, enabled + FROM org_sso_configs + WHERE enabled = TRUE + ORDER BY org_id, provider_type + `) + if err != nil { + return nil, fmt.Errorf("sso: listing enabled configs: %w", err) + } + defer rows.Close() + + var out []*Config + for rows.Next() { + cfg := &Config{} + if err := rows.Scan(&cfg.ID, &cfg.OrgID, &cfg.ProviderType, &cfg.DisplayName, &cfg.Enabled); err != nil { + return nil, fmt.Errorf("sso: scanning config: %w", err) + } + out = append(out, cfg) + } + return out, rows.Err() +} + +// sqlExecer is satisfied by both *sql.DB and *sql.Tx, so Upsert/Delete can +// run either standalone or as part of a caller's transaction (the latter is +// required for the audited config-change handlers, which must write the +// audit event in the same transaction as the config change per CLAUDE.md's +// "Audit Logging Is Automatic" rule). +type sqlExecer interface { + Exec(query string, args ...interface{}) (sql.Result, error) +} + +// Upsert creates or replaces the (org, provider) config. createdByUserID may +// be nil-equivalent (0) only for system-level callers; HTTP handlers must +// always pass the authenticated actor. +func (s *Store) Upsert(cfg *Config, createdByUserID int64) error { + return s.UpsertTx(s.db, cfg, createdByUserID) +} + +// UpsertTx is Upsert run against an explicit executor (typically a *sql.Tx +// an HTTP handler also uses to write its audit event). +func (s *Store) UpsertTx(exec sqlExecer, cfg *Config, createdByUserID int64) error { + encSecret, err := s.box.Encrypt(cfg.OIDCClientSecret) + if err != nil { + return fmt.Errorf("sso: encrypting client secret: %w", err) + } + _, err = exec.Exec(` + INSERT INTO org_sso_configs ( + org_id, provider_type, display_name, enabled, + oidc_issuer, oidc_client_id, oidc_client_secret_enc, oidc_scopes, + saml_idp_metadata_url, saml_idp_metadata_xml, saml_idp_entity_id, + created_by_user_id + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ON CONFLICT (org_id, provider_type) DO UPDATE SET + display_name = EXCLUDED.display_name, + enabled = EXCLUDED.enabled, + oidc_issuer = EXCLUDED.oidc_issuer, + oidc_client_id = EXCLUDED.oidc_client_id, + oidc_client_secret_enc = EXCLUDED.oidc_client_secret_enc, + oidc_scopes = EXCLUDED.oidc_scopes, + saml_idp_metadata_url = EXCLUDED.saml_idp_metadata_url, + saml_idp_metadata_xml = EXCLUDED.saml_idp_metadata_xml, + saml_idp_entity_id = EXCLUDED.saml_idp_entity_id, + updated_at = NOW() + `, cfg.OrgID, string(cfg.ProviderType), cfg.DisplayName, cfg.Enabled, + nullable(cfg.OIDCIssuer), nullable(cfg.OIDCClientID), nullable(encSecret), nullable(cfg.OIDCScopes), + nullable(cfg.SAMLIDPMetadataURL), nullable(cfg.SAMLIDPMetadataXML), nullable(cfg.SAMLIDPEntityID), + nullableID(createdByUserID)) + if err != nil { + return fmt.Errorf("sso: upserting config: %w", err) + } + return nil +} + +func (s *Store) Delete(orgID int64, provider ProviderType) error { + return s.DeleteTx(s.db, orgID, provider) +} + +// DeleteTx is Delete run against an explicit executor; see UpsertTx. +func (s *Store) DeleteTx(exec sqlExecer, orgID int64, provider ProviderType) error { + _, err := exec.Exec(`DELETE FROM org_sso_configs WHERE org_id = $1 AND provider_type = $2`, orgID, string(provider)) + if err != nil { + return fmt.Errorf("sso: deleting config: %w", err) + } + return nil +} + +func nullable(s string) interface{} { + if s == "" { + return nil + } + return s +} + +// nullableID treats 0 as "no actor" (NULL) - callers like the SSO e2e +// harness that seed a config outside any real user session pass 0 rather +// than a fabricated user id. +func nullableID(id int64) interface{} { + if id == 0 { + return nil + } + return id +} diff --git a/internal/sso/e2e_test.go b/internal/sso/e2e_test.go new file mode 100644 index 000000000..556d1f9dc --- /dev/null +++ b/internal/sso/e2e_test.go @@ -0,0 +1,493 @@ +//go:build sso_e2e + +// This file only builds/runs with `-tags=sso_e2e` (see `make test-sso-e2e`), +// never in a normal `go test ./...` - it requires a real Keycloak instance +// (docker/docker-compose.sso-test.yml) and binds a real HTTP listener on +// :8888, which the checked-in realm fixture's OIDC client redirect_uri +// points at. +package sso_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "html" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "regexp" + "strings" + "testing" + "time" + + "github.com/labstack/echo/v4" + _ "github.com/lib/pq" + "github.com/livereview/internal/api/auth" + "github.com/livereview/internal/sso" +) + +func testDatabaseURL() string { + if dsn := os.Getenv("DATABASE_URL"); dsn != "" { + return dsn + } + return "postgres://livereview:livereview_password_123@localhost:5432/livereview?sslmode=disable" +} + +func keycloakBaseURL() string { + if u := os.Getenv("KEYCLOAK_BASE_URL"); u != "" { + return u + } + return "http://localhost:8089" +} + +const ( + testListenAddr = "localhost:8888" // must match the realm fixture's OIDC redirect_uri host:port + testUserEmail = "sso-test@example.com" + testUserPass = "sso-test-password" + testOIDCClientID = "livereview-oidc-test" + testOIDCSecret = "test-client-secret" + testRealm = "livereview-test" + testJWTSecret = "sso-e2e-test-jwt-secret" +) + +// setupTestServer creates a throwaway org and starts a minimal HTTP server +// exposing every SSO route (the same handlers server.go wires, just without +// the rest of the application). It seeds no provider config - each test +// seeds whichever protocol it's exercising. +func setupTestServer(t *testing.T) (db *sql.DB, orgID int64, store *sso.Store, roleOf func(userID int64) string) { + t.Helper() + db, err := sql.Open("postgres", testDatabaseURL()) + if err != nil { + t.Fatalf("connecting to test db: %v", err) + } + t.Cleanup(func() { db.Close() }) + + if err := db.QueryRow(`INSERT INTO orgs (name) VALUES ($1) RETURNING id`, fmt.Sprintf("sso-e2e-%d", time.Now().UnixNano())).Scan(&orgID); err != nil { + t.Fatalf("creating test org: %v", err) + } + t.Cleanup(func() { + _, _ = db.Exec(`DELETE FROM user_roles WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM org_sso_configs WHERE org_id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM orgs WHERE id = $1`, orgID) + _, _ = db.Exec(`DELETE FROM users WHERE email = $1`, testUserEmail) + }) + + box, err := sso.NewSecretBox(testJWTSecret) + if err != nil { + t.Fatalf("building secret box: %v", err) + } + store = sso.NewStore(db, box) + + tokenService := auth.NewTokenService(db, testJWTSecret) + authHandlers := auth.NewAuthHandlers(tokenService, db) + authHandlers.SetSSO(store, testJWTSecret) + + e := echo.New() + e.GET("/api/v1/auth/sso/providers", authHandlers.SSOProviders) + e.GET("/api/v1/auth/sso/oidc/:org_id/login", authHandlers.SSOOIDCLogin) + e.GET("/api/v1/auth/sso/oidc/callback", authHandlers.SSOOIDCCallback) + e.GET("/api/v1/auth/sso/saml/:org_id/metadata", authHandlers.SSOSAMLMetadata) + e.GET("/api/v1/auth/sso/saml/:org_id/login", authHandlers.SSOSAMLLogin) + e.POST("/api/v1/auth/sso/saml/:org_id/acs", authHandlers.SSOSAMLACS) + + srv := &http.Server{Addr: testListenAddr, Handler: e} + go func() { + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + t.Logf("test server on %s stopped: %v", testListenAddr, err) + } + }() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + }) + waitForListener(t, testListenAddr) + + roleOf = func(userID int64) string { + var name string + _ = db.QueryRow(`SELECT r.name FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = $1 AND ur.org_id = $2`, userID, orgID).Scan(&name) + return name + } + return db, orgID, store, roleOf +} + +func seedOIDCConfig(t *testing.T, store *sso.Store, orgID int64) { + t.Helper() + if err := store.Upsert(&sso.Config{ + OrgID: orgID, + ProviderType: sso.ProviderOIDC, + DisplayName: "Keycloak (e2e)", + Enabled: true, + OIDCIssuer: keycloakBaseURL() + "/realms/" + testRealm, + OIDCClientID: testOIDCClientID, + OIDCClientSecret: testOIDCSecret, + }, 0); err != nil { + t.Fatalf("seeding oidc config: %v", err) + } +} + +func seedSAMLConfig(t *testing.T, store *sso.Store, orgID int64) { + t.Helper() + if err := store.Upsert(&sso.Config{ + OrgID: orgID, + ProviderType: sso.ProviderSAML, + DisplayName: "Keycloak (e2e, SAML)", + Enabled: true, + SAMLIDPMetadataURL: keycloakBaseURL() + "/realms/" + testRealm + "/protocol/saml/descriptor", + }, 0); err != nil { + t.Fatalf("seeding saml config: %v", err) + } +} + +// newKeycloakAdminToken logs in as the bootstrap admin (KC_BOOTSTRAP_ADMIN_* +// in docker-compose.sso-test.yml) against the master realm's token +// endpoint, using Keycloak's built-in admin-cli public client. +func newKeycloakAdminToken(t *testing.T) string { + t.Helper() + form := url.Values{ + "grant_type": {"password"}, + "client_id": {"admin-cli"}, + "username": {"admin"}, + "password": {"admin"}, + } + resp, err := http.PostForm(keycloakBaseURL()+"/realms/master/protocol/openid-connect/token", form) + if err != nil { + t.Fatalf("fetching keycloak admin token: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK { + t.Fatalf("keycloak admin login failed (%d): %s", resp.StatusCode, body) + } + var out struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &out); err != nil { + t.Fatalf("parsing keycloak admin token response: %v", err) + } + return out.AccessToken +} + +// createSAMLClient registers a SAML SP client in the test realm for this +// run's dynamically-assigned org ID (the realm fixture can't pre-bake this, +// since LiveReview's SP entity ID embeds the org ID). A dynamically-created +// client gets no default protocol mappers, so this explicitly adds one +// releasing the user's email as a SAML attribute (ExchangeSAMLResponse +// falls back to attributes when the NameID itself isn't an email, which is +// Keycloak's default "transient" NameID behavior). +func createSAMLClient(t *testing.T, adminToken, entityID, acsURL string) { + t.Helper() + body, _ := json.Marshal(map[string]interface{}{ + "clientId": entityID, + "protocol": "saml", + "enabled": true, + "redirectUris": []string{acsURL}, + "attributes": map[string]string{ + "saml.authnstatement": "true", + "saml.client.signature": "false", + "saml.server.signature": "true", + "saml.assertion.signature": "true", + "saml.encrypt": "false", + }, + "protocolMappers": []map[string]interface{}{ + { + "name": "email", + "protocol": "saml", + "protocolMapper": "saml-user-property-mapper", + "config": map[string]string{ + "user.attribute": "email", + "attribute.name": "email", + "friendly.name": "email", + "attribute.nameformat": "Basic", + }, + }, + }, + }) + req, err := http.NewRequest(http.MethodPost, keycloakBaseURL()+"/admin/realms/"+testRealm+"/clients", strings.NewReader(string(body))) + if err != nil { + t.Fatalf("building saml client creation request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+adminToken) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("creating saml client: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("creating saml client failed (%d): %s", resp.StatusCode, b) + } +} + +func waitForListener(t *testing.T, addr string) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + conn, err := http.Get("http://" + addr + "/api/v1/auth/sso/providers") + if err == nil { + conn.Body.Close() + return + } + time.Sleep(100 * time.Millisecond) + } + t.Fatalf("test server on %s never came up", addr) +} + +var formActionRe = regexp.MustCompile(`id="kc-form-login"[^>]*action="([^"]+)"`) + +// followUntilPage manually walks a redirect chain (never auto-following, +// since the final hop's fragment must be read off the Location header +// itself - fragments are never sent back to a server, so letting the HTTP +// client auto-follow would silently drop the tokens we're trying to read). +func followUntilPage(t *testing.T, client *http.Client, start string) (*http.Response, string) { + t.Helper() + next := start + for i := 0; i < 10; i++ { + resp, err := client.Get(next) + if err != nil { + t.Fatalf("GET %s: %v", next, err) + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + resp.Body.Close() + if loc == "" { + t.Fatalf("redirect from %s had no Location header", next) + } + next = loc + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return resp, string(body) + } + t.Fatalf("too many redirects starting from %s", start) + return nil, "" +} + +func TestOIDCLoginEndToEnd(t *testing.T) { + db, orgID, store, roleOf := setupTestServer(t) + seedOIDCConfig(t, store, orgID) + + client := &http.Client{ + Jar: must(cookiejar.New(nil)), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + loginURL := fmt.Sprintf("http://%s/api/v1/auth/sso/oidc/%d/login", testListenAddr, orgID) + _, loginPageHTML := followUntilPage(t, client, loginURL) + + m := formActionRe.FindStringSubmatch(loginPageHTML) + if m == nil { + t.Fatalf("could not find Keycloak login form action in page:\n%s", truncate(loginPageHTML)) + } + formAction := strings.ReplaceAll(m[1], "&", "&") + + form := url.Values{"username": {testUserEmail}, "password": {testUserPass}, "credentialId": {""}} + req, err := http.NewRequest(http.MethodPost, formAction, strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("building login POST: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("posting Keycloak login form: %v", err) + } + resp.Body.Close() + if resp.StatusCode < 300 || resp.StatusCode >= 400 { + t.Fatalf("expected a redirect back to LiveReview after login, got %d", resp.StatusCode) + } + callbackURL := resp.Header.Get("Location") + + // This hits our /callback handler for real: code exchange, ID-token + // verification, JIT provisioning, token issuance - then redirects to + // the frontend with tokens in the URL fragment. + finalResp, err := client.Get(callbackURL) + if err != nil { + t.Fatalf("GET callback %s: %v", callbackURL, err) + } + finalResp.Body.Close() + if finalResp.StatusCode < 300 || finalResp.StatusCode >= 400 { + t.Fatalf("expected the callback to redirect to the frontend, got %d", finalResp.StatusCode) + } + finalLocation := finalResp.Header.Get("Location") + + parsed, err := url.Parse(finalLocation) + if err != nil { + t.Fatalf("parsing final redirect %q: %v", finalLocation, err) + } + if !strings.Contains(parsed.Path, "/sso/callback") { + t.Fatalf("expected final redirect to /sso/callback, got %q", finalLocation) + } + fragValues, err := url.ParseQuery(parsed.Fragment) + if err != nil { + t.Fatalf("parsing fragment %q: %v", parsed.Fragment, err) + } + accessToken := fragValues.Get("access_token") + if accessToken == "" { + t.Fatalf("final redirect had no access_token in fragment: %q", finalLocation) + } + + tokenService := auth.NewTokenService(db, testJWTSecret) + user, err := tokenService.ValidateAccessToken(accessToken) + if err != nil { + t.Fatalf("issued access token did not validate: %v", err) + } + if user.Email != testUserEmail { + t.Fatalf("expected provisioned user %s, got %s", testUserEmail, user.Email) + } + if role := roleOf(user.ID); role != "member" { + t.Fatalf("expected JIT-provisioned user to be role 'member' in org %d, got %q", orgID, role) + } +} + +// TestSAMLLoginEndToEnd mirrors TestOIDCLoginEndToEnd, but drives the +// HTTP-Redirect-request / HTTP-POST-response SAML binding Keycloak uses by +// default: the final hop back from Keycloak is a 200 HTML page with an +// auto-submitting form carrying SAMLResponse/RelayState, not a redirect - +// so this test submits that form itself rather than following a redirect. +func TestSAMLLoginEndToEnd(t *testing.T) { + db, orgID, store, roleOf := setupTestServer(t) + + entityID := fmt.Sprintf("http://%s/api/v1/auth/sso/saml/%d/metadata", testListenAddr, orgID) + acsURL := fmt.Sprintf("http://%s/api/v1/auth/sso/saml/%d/acs", testListenAddr, orgID) + createSAMLClient(t, newKeycloakAdminToken(t), entityID, acsURL) + seedSAMLConfig(t, store, orgID) + + client := &http.Client{ + Jar: must(cookiejar.New(nil)), + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + + loginURL := fmt.Sprintf("http://%s/api/v1/auth/sso/saml/%d/login", testListenAddr, orgID) + _, loginPageHTML := followUntilPage(t, client, loginURL) + + m := formActionRe.FindStringSubmatch(loginPageHTML) + if m == nil { + t.Fatalf("could not find Keycloak login form action in page:\n%s", truncate(loginPageHTML)) + } + formAction := html.UnescapeString(m[1]) + + form := url.Values{"username": {testUserEmail}, "password": {testUserPass}, "credentialId": {""}} + req, err := http.NewRequest(http.MethodPost, formAction, strings.NewReader(form.Encode())) + if err != nil { + t.Fatalf("building login POST: %v", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + t.Fatalf("posting Keycloak login form: %v", err) + } + var autoSubmitHTML string + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + resp.Body.Close() + _, autoSubmitHTML = followUntilPage(t, client, loc) + } else { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + autoSubmitHTML = string(body) + } + + acsAction := extractFormAction(t, autoSubmitHTML) + samlResponse := extractInputValue(t, autoSubmitHTML, "SAMLResponse") + relayState := extractInputValue(t, autoSubmitHTML, "RelayState") + if samlResponse == "" { + t.Fatalf("could not find SAMLResponse in Keycloak's auto-submit page:\n%s", truncate(autoSubmitHTML)) + } + + // This is the step a real browser's JS would perform automatically: + // submit the SAMLResponse to our ACS endpoint. It hits our real + // handler: signature/condition validation, identity extraction, JIT + // provisioning, token issuance, then a redirect to the frontend. + acsForm := url.Values{"SAMLResponse": {samlResponse}, "RelayState": {relayState}} + acsReq, err := http.NewRequest(http.MethodPost, acsAction, strings.NewReader(acsForm.Encode())) + if err != nil { + t.Fatalf("building ACS POST: %v", err) + } + acsReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") + finalResp, err := client.Do(acsReq) + if err != nil { + t.Fatalf("posting to ACS %s: %v", acsAction, err) + } + finalResp.Body.Close() + if finalResp.StatusCode < 300 || finalResp.StatusCode >= 400 { + b, _ := io.ReadAll(finalResp.Body) + t.Fatalf("expected the ACS endpoint to redirect to the frontend, got %d: %s", finalResp.StatusCode, b) + } + finalLocation := finalResp.Header.Get("Location") + + parsed, err := url.Parse(finalLocation) + if err != nil { + t.Fatalf("parsing final redirect %q: %v", finalLocation, err) + } + if !strings.Contains(parsed.Path, "/sso/callback") { + t.Fatalf("expected final redirect to /sso/callback, got %q", finalLocation) + } + fragValues, err := url.ParseQuery(parsed.Fragment) + if err != nil { + t.Fatalf("parsing fragment %q: %v", parsed.Fragment, err) + } + accessToken := fragValues.Get("access_token") + if accessToken == "" { + t.Fatalf("final redirect had no access_token in fragment: %q", finalLocation) + } + + tokenService := auth.NewTokenService(db, testJWTSecret) + user, err := tokenService.ValidateAccessToken(accessToken) + if err != nil { + t.Fatalf("issued access token did not validate: %v", err) + } + if user.Email != testUserEmail { + t.Fatalf("expected provisioned user %s, got %s", testUserEmail, user.Email) + } + if role := roleOf(user.ID); role != "member" { + t.Fatalf("expected JIT-provisioned user to be role 'member' in org %d, got %q", orgID, role) + } +} + +func extractFormAction(t *testing.T, pageHTML string) string { + t.Helper() + re := regexp.MustCompile(`]*\baction="([^"]*)"`) + m := re.FindStringSubmatch(pageHTML) + if m == nil { + t.Fatalf("could not find a
in page:\n%s", truncate(pageHTML)) + } + return html.UnescapeString(m[1]) +} + +func extractInputValue(t *testing.T, pageHTML, name string) string { + t.Helper() + tagRe := regexp.MustCompile(`]*\bname="` + regexp.QuoteMeta(name) + `"[^>]*>`) + tag := tagRe.FindString(pageHTML) + if tag == "" { + return "" + } + valRe := regexp.MustCompile(`value="([^"]*)"`) + m := valRe.FindStringSubmatch(tag) + if m == nil { + return "" + } + return html.UnescapeString(m[1]) +} + +func must(cj *cookiejar.Jar, err error) *cookiejar.Jar { + if err != nil { + panic(err) + } + return cj +} + +func truncate(s string) string { + if len(s) > 2000 { + return s[:2000] + "...(truncated)" + } + return s +} diff --git a/internal/sso/oidc.go b/internal/sso/oidc.go new file mode 100644 index 000000000..0c4fd72a2 --- /dev/null +++ b/internal/sso/oidc.go @@ -0,0 +1,89 @@ +package sso + +import ( + "context" + "fmt" + "strings" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +// Identity is the normalized claim set extracted from a successful SSO +// login, OIDC or SAML - CompleteSSOLogin (internal/api/auth) only ever deals +// with this, never protocol-specific structures. +type Identity struct { + Email string + Name string +} + +// oidcClaims mirrors the standard OIDC claims LiveReview needs. Most IdPs +// (Okta, Azure AD/Entra, Google Workspace, Keycloak) populate all three. +type oidcClaims struct { + Email string `json:"email"` + Name string `json:"name"` +} + +// BuildOIDCOAuth2Config runs OIDC discovery against the configured issuer +// and returns an oauth2.Config plus the ID-token verifier for that issuer. +// Discovery happens per login (not cached) - login traffic is low-volume +// and this keeps the design simple; an admin rotating issuer URLs needs no +// cache invalidation story. +func BuildOIDCOAuth2Config(ctx context.Context, cfg *Config, redirectURL string) (*oauth2.Config, *oidc.IDTokenVerifier, error) { + if cfg.ProviderType != ProviderOIDC { + return nil, nil, fmt.Errorf("sso: config %d is not an OIDC config", cfg.ID) + } + provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer) + if err != nil { + return nil, nil, fmt.Errorf("sso: oidc discovery against %q failed: %w", cfg.OIDCIssuer, err) + } + scopes := []string{oidc.ScopeOpenID, "email", "profile"} + if strings.TrimSpace(cfg.OIDCScopes) != "" { + scopes = strings.Fields(cfg.OIDCScopes) + } + oauthCfg := &oauth2.Config{ + ClientID: cfg.OIDCClientID, + ClientSecret: cfg.OIDCClientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: redirectURL, + Scopes: scopes, + } + verifier := provider.Verifier(&oidc.Config{ClientID: cfg.OIDCClientID}) + return oauthCfg, verifier, nil +} + +// ExchangeOIDCCode exchanges the authorization code for tokens, verifies the +// ID token's signature/issuer/audience/expiry, and extracts the identity. +func ExchangeOIDCCode(ctx context.Context, oauthCfg *oauth2.Config, verifier *oidc.IDTokenVerifier, code string) (*Identity, error) { + token, err := oauthCfg.Exchange(ctx, code) + if err != nil { + return nil, fmt.Errorf("sso: oidc code exchange failed: %w", err) + } + rawIDToken, ok := token.Extra("id_token").(string) + if !ok || rawIDToken == "" { + return nil, fmt.Errorf("sso: oidc token response missing id_token") + } + idToken, err := verifier.Verify(ctx, rawIDToken) + if err != nil { + return nil, fmt.Errorf("sso: oidc id_token verification failed: %w", err) + } + var claims oidcClaims + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("sso: parsing oidc id_token claims: %w", err) + } + if claims.Email == "" { + return nil, fmt.Errorf("sso: oidc id_token has no email claim") + } + return &Identity{Email: claims.Email, Name: claims.Name}, nil +} + +// TestOIDCDiscovery is used by the config "test connection" endpoint - it +// only proves the issuer is reachable and speaks OIDC discovery, without a +// full login round trip. +func TestOIDCDiscovery(ctx context.Context, issuer string) error { + _, err := oidc.NewProvider(ctx, issuer) + if err != nil { + return fmt.Errorf("sso: oidc discovery against %q failed: %w", issuer, err) + } + return nil +} diff --git a/internal/sso/saml.go b/internal/sso/saml.go new file mode 100644 index 000000000..92f19802d --- /dev/null +++ b/internal/sso/saml.go @@ -0,0 +1,147 @@ +package sso + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + samllib "github.com/crewjam/saml" + "github.com/crewjam/saml/samlsp" +) + +// BuildServiceProvider constructs a crewjam/saml SP for this org's SAML +// config. Requests are sent unsigned (SP.Key/Certificate left nil) - most +// IdPs, Keycloak included, accept unsigned AuthnRequests by default, and +// this avoids LiveReview having to generate/rotate/store an SP signing +// keypair for an MVP. AllowIDPInitiated is set because LiveReview is +// otherwise stateless across the login round trip (same reasoning as +// OIDC's signed `state`): the signed RelayState (see state.go) already +// carries the anti-replay/short-TTL guarantee `InResponseTo` matching would +// give, so skipping that check is a deliberate, documented trade rather +// than an oversight. +func BuildServiceProvider(ctx context.Context, cfg *Config, metadataURL, acsURL string) (*samllib.ServiceProvider, error) { + if cfg.ProviderType != ProviderSAML { + return nil, fmt.Errorf("sso: config %d is not a SAML config", cfg.ID) + } + idpMetadata, err := loadIDPMetadata(ctx, cfg) + if err != nil { + return nil, err + } + metaURL, err := url.Parse(metadataURL) + if err != nil { + return nil, fmt.Errorf("sso: invalid metadata URL: %w", err) + } + acsU, err := url.Parse(acsURL) + if err != nil { + return nil, fmt.Errorf("sso: invalid ACS URL: %w", err) + } + sp := &samllib.ServiceProvider{ + EntityID: metadataURL, + MetadataURL: *metaURL, + AcsURL: *acsU, + IDPMetadata: idpMetadata, + AllowIDPInitiated: true, + } + return sp, nil +} + +func loadIDPMetadata(ctx context.Context, cfg *Config) (*samllib.EntityDescriptor, error) { + if strings.TrimSpace(cfg.SAMLIDPMetadataXML) != "" { + md, err := samlsp.ParseMetadata([]byte(cfg.SAMLIDPMetadataXML)) + if err != nil { + return nil, fmt.Errorf("sso: parsing stored SAML IdP metadata: %w", err) + } + return md, nil + } + if strings.TrimSpace(cfg.SAMLIDPMetadataURL) == "" { + return nil, fmt.Errorf("sso: SAML config %d has neither metadata XML nor a metadata URL", cfg.ID) + } + u, err := url.Parse(cfg.SAMLIDPMetadataURL) + if err != nil { + return nil, fmt.Errorf("sso: invalid SAML IdP metadata URL: %w", err) + } + md, err := samlsp.FetchMetadata(ctx, http.DefaultClient, *u) + if err != nil { + return nil, fmt.Errorf("sso: fetching SAML IdP metadata from %q: %w", cfg.SAMLIDPMetadataURL, err) + } + return md, nil +} + +// samlEmailAttributeNames covers the attribute names IdPs commonly use for +// email when it isn't carried in the NameID itself (Keycloak, Okta, Azure +// AD/Entra all use one of these under default mappings). +var samlEmailAttributeNames = []string{ + "email", + "Email", + "mail", + "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress", + "urn:oid:0.9.2342.19200300.100.1.3", +} + +// ExchangeSAMLResponse validates the POSTed SAMLResponse against the IdP +// and extracts the identity. ParseResponse covers signature verification, +// audience/recipient/expiry validation - only identity extraction is ours. +func ExchangeSAMLResponse(sp *samllib.ServiceProvider, r *http.Request) (*Identity, error) { + assertion, err := sp.ParseResponse(r, []string{}) + if err != nil { + if ire, ok := err.(*samllib.InvalidResponseError); ok { + return nil, fmt.Errorf("sso: saml response validation failed: %w (detail: %v)", err, ire.PrivateErr) + } + return nil, fmt.Errorf("sso: saml response validation failed: %w", err) + } + + identity := &Identity{} + if assertion.Subject != nil && assertion.Subject.NameID != nil { + v := strings.TrimSpace(assertion.Subject.NameID.Value) + if strings.Contains(v, "@") { + identity.Email = v + } + } + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + name := attr.Name + if attr.FriendlyName != "" { + name = attr.FriendlyName + } + if identity.Email == "" && containsFold(samlEmailAttributeNames, name) && len(attr.Values) > 0 { + identity.Email = strings.TrimSpace(attr.Values[0].Value) + } + if identity.Name == "" && (strings.EqualFold(name, "name") || strings.EqualFold(name, "displayName")) && len(attr.Values) > 0 { + identity.Name = strings.TrimSpace(attr.Values[0].Value) + } + } + } + if identity.Email == "" { + var nameIDVal, nameIDFmt string + if assertion.Subject != nil && assertion.Subject.NameID != nil { + nameIDVal, nameIDFmt = assertion.Subject.NameID.Value, assertion.Subject.NameID.Format + } + var attrNames []string + for _, stmt := range assertion.AttributeStatements { + for _, attr := range stmt.Attributes { + attrNames = append(attrNames, fmt.Sprintf("%s/%s=%v", attr.Name, attr.FriendlyName, attr.Values)) + } + } + return nil, fmt.Errorf("sso: saml assertion had no usable email (NameID=%q format=%q attrs=%v)", nameIDVal, nameIDFmt, attrNames) + } + return identity, nil +} + +func containsFold(candidates []string, name string) bool { + for _, c := range candidates { + if strings.EqualFold(c, name) { + return true + } + } + return false +} + +// TestSAMLMetadata is used by the config "test connection" endpoint - it +// only proves the IdP metadata (URL or pasted XML) parses, without a full +// login round trip. +func TestSAMLMetadata(ctx context.Context, cfg *Config) error { + _, err := loadIDPMetadata(ctx, cfg) + return err +} diff --git a/internal/sso/secretbox.go b/internal/sso/secretbox.go new file mode 100644 index 000000000..d6d73cc67 --- /dev/null +++ b/internal/sso/secretbox.go @@ -0,0 +1,82 @@ +// Package sso implements per-organization OIDC and SAML single sign-on for +// self-hosted enterprise deployments (see docs/sso.md). +package sso + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/hkdf" +) + +// SecretBox encrypts small secrets (OIDC client secrets, etc.) at rest using +// AES-256-GCM. The key is derived from the server's existing JWT_SECRET via +// HKDF (not used bare, so a lower-entropy JWT_SECRET doesn't translate +// one-to-one into a weak AES key) rather than a new dedicated env var, so +// deployments don't need an extra secret to manage. +type SecretBox struct { + gcm cipher.AEAD +} + +// NewSecretBox derives an AES-GCM cipher from an arbitrary-length secret +// (typically the JWT signing secret already required by every deployment). +func NewSecretBox(secret string) (*SecretBox, error) { + if secret == "" { + return nil, errors.New("sso: secret box key material is empty") + } + var key [32]byte + if _, err := io.ReadFull(hkdf.New(sha256.New, []byte(secret), nil, []byte("livereview-sso-secretbox-v1")), key[:]); err != nil { + return nil, fmt.Errorf("sso: deriving key: %w", err) + } + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, fmt.Errorf("sso: creating cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("sso: creating gcm: %w", err) + } + return &SecretBox{gcm: gcm}, nil +} + +// Encrypt returns a base64 string safe for storing in a TEXT column. +// Empty plaintext encrypts to an empty string (so clearing a secret is a +// no-op round trip, not a decrypt error later). +func (b *SecretBox) Encrypt(plaintext string) (string, error) { + if plaintext == "" { + return "", nil + } + nonce := make([]byte, b.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("sso: generating nonce: %w", err) + } + ciphertext := b.gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return base64.StdEncoding.EncodeToString(ciphertext), nil +} + +// Decrypt reverses Encrypt. An empty input decrypts to an empty string. +func (b *SecretBox) Decrypt(encoded string) (string, error) { + if encoded == "" { + return "", nil + } + data, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "", fmt.Errorf("sso: decoding ciphertext: %w", err) + } + nonceSize := b.gcm.NonceSize() + if len(data) < nonceSize { + return "", errors.New("sso: ciphertext too short") + } + nonce, ciphertext := data[:nonceSize], data[nonceSize:] + plaintext, err := b.gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("sso: decrypting: %w", err) + } + return string(plaintext), nil +} diff --git a/internal/sso/state.go b/internal/sso/state.go new file mode 100644 index 000000000..7f3978603 --- /dev/null +++ b/internal/sso/state.go @@ -0,0 +1,86 @@ +package sso + +import ( + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "io" + "strconv" + "strings" + "time" + + "golang.org/x/crypto/hkdf" +) + +// StateSigner produces and verifies the OAuth/SAML "state" parameter without +// needing a server-side session table: it's a signed, short-TTL token +// carrying the org ID the login was initiated for. Derived from the same +// JWT secret every deployment already configures, with a distinct HMAC key +// (never reuse the SecretBox's AES key for a MAC). +type StateSigner struct { + key []byte + ttl time.Duration +} + +func NewStateSigner(secret string) *StateSigner { + key := make([]byte, 32) + // HKDF rather than a bare hash: keeps this HMAC key independent of + // SecretBox's AES key even though both ultimately derive from the same + // JWT_SECRET, and doesn't degrade to the secret's raw entropy. + _, _ = io.ReadFull(hkdf.New(sha256.New, []byte(secret), nil, []byte("livereview-sso-state-v1")), key) + return &StateSigner{key: key, ttl: 10 * time.Minute} +} + +// Encode signs orgID + a random nonce (the nonce makes the token +// single-purpose/unguessable; it is not currently re-checked on decode, but +// keeps two concurrent logins from producing identical state values). +func (s *StateSigner) Encode(orgID int64, nonce string) string { + payload := fmt.Sprintf("%d|%s|%d", orgID, nonce, time.Now().Add(s.ttl).Unix()) + mac := s.sign(payload) + return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac) +} + +// Decode verifies signature + expiry and returns the org ID the login was +// initiated for. +func (s *StateSigner) Decode(token string) (orgID int64, err error) { + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return 0, errors.New("sso: malformed state token") + } + payloadRaw, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return 0, errors.New("sso: malformed state payload") + } + sigGiven, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return 0, errors.New("sso: malformed state signature") + } + if subtle.ConstantTimeCompare(sigGiven, s.sign(string(payloadRaw))) != 1 { + return 0, errors.New("sso: state signature mismatch") + } + fields := strings.SplitN(string(payloadRaw), "|", 3) + if len(fields) != 3 { + return 0, errors.New("sso: malformed state fields") + } + orgID, err = strconv.ParseInt(fields[0], 10, 64) + if err != nil { + return 0, errors.New("sso: malformed org id in state") + } + expiry, err := strconv.ParseInt(fields[2], 10, 64) + if err != nil { + return 0, errors.New("sso: malformed expiry in state") + } + if time.Now().Unix() > expiry { + return 0, errors.New("sso: state token expired") + } + return orgID, nil +} + +func (s *StateSigner) sign(payload string) []byte { + mac := hmac.New(sha256.New, s.key) + mac.Write([]byte(payload)) + return mac.Sum(nil) +} diff --git a/test/fixtures/keycloak/livereview-test-realm.json b/test/fixtures/keycloak/livereview-test-realm.json new file mode 100644 index 000000000..d69d74732 --- /dev/null +++ b/test/fixtures/keycloak/livereview-test-realm.json @@ -0,0 +1,41 @@ +{ + "realm": "livereview-test", + "enabled": true, + "sslRequired": "none", + "registrationAllowed": false, + "accessTokenLifespan": 300, + "clients": [ + { + "clientId": "livereview-oidc-test", + "name": "LiveReview SSO e2e test (OIDC)", + "enabled": true, + "protocol": "openid-connect", + "publicClient": false, + "clientAuthenticatorType": "client-secret", + "secret": "test-client-secret", + "standardFlowEnabled": true, + "directAccessGrantsEnabled": false, + "redirectUris": [ + "http://localhost:8888/api/v1/auth/sso/oidc/callback" + ], + "webOrigins": ["*"] + } + ], + "users": [ + { + "username": "sso-test@example.com", + "email": "sso-test@example.com", + "emailVerified": true, + "enabled": true, + "firstName": "SSO", + "lastName": "Tester", + "credentials": [ + { + "type": "password", + "value": "sso-test-password", + "temporary": false + } + ] + } + ] +} diff --git a/tests/GITLAB_COMMENT_DEBUG.md b/tests/GITLAB_COMMENT_DEBUG.md deleted file mode 100644 index f35d0b95a..000000000 --- a/tests/GITLAB_COMMENT_DEBUG.md +++ /dev/null @@ -1,50 +0,0 @@ -# GitLab Line Comment Debugging - -This directory contains test code and a potential fix for the issue with GitLab comments not being properly attached to specific file lines. - -## Problem Description - -When using the LiveReview tool to post comments to GitLab merge requests, comments that should be attached to specific files and line numbers are instead being posted as general comments on the merge request. For example: - -- Comment contents reference specific files and lines (e.g., "File: liveapi-backend/exam/metric_analysis.go, Line: 19") -- But in GitLab, the comment appears in the general thread instead of being attached to the specific line in the diff - -## Test Instructions - -1. Run the test script with your GitLab token to try different commenting methods: - -```bash -./test_gitlab_comments.sh YOUR_GITLAB_TOKEN -``` - -2. Check the GitLab merge request (MR #335) to see which comment methods successfully attached to line 19 in `liveapi-backend/exam/metric_analysis.go`. - -3. Once you identify which method works best, implement the fix by replacing the current `CreateMRLineComment` method in `http_client.go` with the appropriate solution from `http_client_fix.go`. - -## Potential Solutions - -The `http_client_fix.go` file contains a comprehensive implementation that tries multiple methods in sequence: - -1. **Simple API approach**: Uses the `/notes` endpoint with minimal parameters (`path`, `line`, `line_type`) -2. **Position-based approach**: Uses the `/discussions` endpoint with SHA information and position details - - Tries both `text` and `code` position types -3. **Direct line comment**: The current implementation -4. **Fallback method**: Posts a general comment with file/line information in the text - -## Implementation Steps - -After running the tests and determining which method works best: - -1. Review the results in the GitLab UI to see which comments are correctly attached to line 19 -2. Copy the appropriate method(s) from `http_client_fix.go` to replace the current implementation in `http_client.go` -3. Make any necessary adjustments based on the test results -4. Consider adding a fallback chain that tries multiple methods in sequence, starting with the most effective one - -The most likely fix will involve: -- Normalizing file paths (removing leading slashes) -- Using the correct position_type parameter -- Ensuring all required parameters are provided to the GitLab API - -## Additional Notes - -The issue might be specific to your GitLab instance configuration. Different GitLab versions and configurations might require different parameters for line comments to work correctly. diff --git a/tests/README.md b/tests/README.md index 6c560b637..958c479d7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -16,17 +16,14 @@ make -f config/Makefile.test test-prompt # Prompt construction make -f config/Makefile.test test-api # AI API calls make -f config/Makefile.test test-gitlab-mr # GitLab MR fetching make -f config/Makefile.test test-gitlab-comments # GitLab comment posting -make -f config/Makefile.test test-pipeline # Complete pipeline ``` **Run tests in this directory directly:** ```bash -go test -v ./tests -run TestCompletePipeline -go test -v ./tests -run TestFetchMR335Details +go test -v ./tests ``` ## Notes - Integration tests require credentials and may be skipped in short mode -- For debugging tools (Ollama/LLM connectivity), see [../debug/](../debug/) - For shell script tests (API/webhooks), see [../scripts/tests/](../scripts/tests/) diff --git a/tests/examples/final_line_comment.go b/tests/examples/final_line_comment.go deleted file mode 100644 index e7916fccd..000000000 --- a/tests/examples/final_line_comment.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "fmt" - "os" - "time" - - "github.com/livereview/internal/providers/gitlab" -) - -// Test the final implementation for GitLab line comments -func main() { - fmt.Println("Testing final GitLab line comment implementation") - - // Test token for git.apps.hexmos.com - hardcoded for testing only - token := "REDACTED_GITLAB_PAT_6" - if token == "" { - fmt.Println("Error: GITLAB_TOKEN environment variable not set") - os.Exit(1) - } - - // Configuration - config := gitlab.GitLabConfig{ - URL: "https://git.apps.hexmos.com", - Token: token, - } - - // Create a GitLab client - httpClient := gitlab.NewHTTPClient(config.URL, config.Token) - fmt.Println("Created GitLab HTTP client") - - // Target information for MR 335 and metric_analysis.go line 29 - projectID := "hexmos/liveapi" - mrIID := 335 - filePath := "liveapi-backend/exam/metric_analysis.go" - lineNum := 29 // Target line 29 - - // Create a test comment with timestamp - timestamp := time.Now().Format(time.RFC3339) - comment := fmt.Sprintf("FINAL IMPLEMENTATION TEST: This comment should appear on line %d of metric_analysis.go in the Changes tab. Created at %s", lineNum, timestamp) - - // Try to post the comment - fmt.Println("Posting line comment to:") - fmt.Printf("- Project: %s\n", projectID) - fmt.Printf("- MR: %d\n", mrIID) - fmt.Printf("- File: %s\n", filePath) - fmt.Printf("- Line: %d\n", lineNum) - - err := httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, comment) - if err != nil { - fmt.Printf("Error posting comment: %v\n", err) - os.Exit(1) - } - - fmt.Println("Comment posted successfully! Check GitLab to verify it's attached to the correct line.") -} diff --git a/tests/examples/fix_gitlab_comments.go b/tests/examples/fix_gitlab_comments.go deleted file mode 100644 index 7134167e1..000000000 --- a/tests/examples/fix_gitlab_comments.go +++ /dev/null @@ -1,51 +0,0 @@ -package main - -import ( - "fmt" - "strings" -) - -// This function demonstrates the fix we're going to implement in the GitLab client -// based on the test results. -func fixCreateMRLineComment() { - // The key problem with line comments in GitLab is that the API can be very - // specific about the format of the file paths and the parameters required. - - // Possible fixes we'll implement: - - // 1. Ensure full path is correctly formatted - check for leading slashes - fmt.Println("Checking file path format...") - - filePath := "liveapi-backend/exam/metric_analysis.go" - if !strings.HasPrefix(filePath, "/") { - // Some GitLab instances expect paths without leading slashes - fmt.Println("- Removing leading slash if present") - filePath = strings.TrimPrefix(filePath, "/") - } - - // 2. Make sure position_type is set correctly (text vs code) - fmt.Println("Setting position_type parameter...") - fmt.Println("- Try with position_type=text first") - fmt.Println("- Fall back to position_type=code if that fails") - - // 3. Add debug logging to see what's happening - fmt.Println("Adding detailed debug logging...") - fmt.Println("- Log full request details") - fmt.Println("- Log full response content") - - // 4. Try the simpler notes API with path/line parameters - fmt.Println("Implementing fallback to simpler API...") - fmt.Println("- Use /notes endpoint with path/line parameters") - - fmt.Println("\nBased on the test results, we'll implement the most effective approach.") -} - -func main() { - fmt.Println("GitLab Line Comment Fix Plan") - fmt.Println("============================") - - fixCreateMRLineComment() - - fmt.Println("\nAfter running the tests, implement the specific fix in http_client.go") - fmt.Println("based on which approach was most successful in posting line comments.") -} diff --git a/tests/gitlab_comment_test.go b/tests/gitlab_comment_test.go deleted file mode 100644 index 3cc23bc05..000000000 --- a/tests/gitlab_comment_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package livereview - -import ( - "context" - "fmt" - "io" - "net/http" - "net/url" - "strings" - "testing" - "time" - - "github.com/livereview/internal/providers/gitlab" - "github.com/livereview/pkg/models" - "github.com/stretchr/testify/assert" -) - -// TestGitLabLineCommentMethods tests different methods of posting line-specific comments to GitLab -// to help debug why comments aren't being properly attached to specific lines in files. -// -// This test specifically targets the issue with comments not being attached to -// liveapi-backend/exam/metric_analysis.go line 19 in MR 335. -func TestGitLabLineCommentMethods(t *testing.T) { - // Skip the test by default since it requires credentials and a real GitLab instance - // Run with -test.run=TestGitLabLineCommentMethods -test.v to execute - if testing.Short() { - t.Skip("Skipping integration test in short mode") - } - - // Replace these with your actual values from configuration - // Test token for git.apps.hexmos.com - hardcoded for testing only - token := "REDACTED_GITLAB_PAT_6" - if token == "" { - t.Skip("GITLAB_TOKEN environment variable not set, skipping test") - } - config := gitlab.GitLabConfig{ - URL: "https://git.apps.hexmos.com", // Replace with your GitLab URL - Token: token, - } - - // Create a GitLab provider with your config - provider, err := gitlab.New(config) - assert.NoError(t, err) - - // Create test context - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - // The MR ID and URL - mrID := "335" // MR ID 335 - mrURL := "https://git.apps.hexmos.com/hexmos/liveapi/-/merge_requests/335" // Full MR URL with project path - filePath := "liveapi-backend/exam/metric_analysis.go" // File path - lineNum := 19 // Line number - - // Create a test comment that clearly shows which method was used - reviewComment := &models.ReviewComment{ - FilePath: filePath, - Line: lineNum, - Content: "TEST COMMENT: This is a test comment posted via LiveReview debugging - " + time.Now().Format(time.RFC3339), - Severity: models.SeverityInfo, - Category: "test", - } - - // Method 1: Use the standard PostComment method - t.Run("StandardPostComment", func(t *testing.T) { - // Reset the provider's project ID cache before testing with a new URL - provider, err := gitlab.New(config) - assert.NoError(t, err) - - // First get the MR details to properly set up the provider - _, err = provider.GetMergeRequestDetails(ctx, mrURL) - if err != nil { - t.Logf("GetMergeRequestDetails failed: %v", err) - t.Fail() - return - } - - err = provider.PostComment(ctx, mrID, reviewComment) - if err != nil { - t.Logf("PostComment failed: %v", err) - t.Fail() - } else { - t.Logf("PostComment succeeded, check GitLab to see if it's attached correctly") - } - }) - - // Let's also test the HTTP client methods directly for comparison - httpClient := provider.GetHTTPClient() - if httpClient == nil { - t.Fatal("Failed to get HTTP client from provider") - } - - // Extract project ID - projectID := "hexmos/liveapi" // Replace with the actual project ID - mrIID := 335 // MR IID as an integer - - // Method 2: Directly use CreateMRLineComment - t.Run("DirectMRLineComment", func(t *testing.T) { - err := httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, - "TEST COMMENT (Direct): This is a test comment posted via direct line comment - "+time.Now().Format(time.RFC3339)) - if err != nil { - t.Logf("CreateMRLineComment failed: %v", err) - t.Fail() - } else { - t.Logf("CreateMRLineComment succeeded, check GitLab to see if it's attached correctly") - } - }) - - // Skip methods that require the internal functions for now - - // Try with different path prefix handling - if !strings.HasPrefix(filePath, "/") { - // Try with leading slash - err = httpClient.CreateMRLineComment(projectID, mrIID, "/"+filePath, lineNum, - "TEST COMMENT (with leading slash): Testing with leading slash in path - "+time.Now().Format(time.RFC3339)) - if err != nil { - t.Logf("Leading slash path failed: %v", err) - } - } - - // Method 6: Try with the simplest approach possible - using 'path' and 'line' parameters directly - t.Run("SimplestApproach", func(t *testing.T) { - requestURL := fmt.Sprintf("%s/projects/%s/merge_requests/%d/notes", - httpClient.GetBaseURL(), url.PathEscape(projectID), mrIID) - - // Create a direct request with minimal parameters - values := url.Values{} - values.Add("body", "TEST COMMENT (Simplest): Using minimal parameters - "+time.Now().Format(time.RFC3339)) - values.Add("path", filePath) - values.Add("line", fmt.Sprintf("%d", lineNum)) - - // Make the request directly - req, err := http.NewRequest("POST", requestURL, strings.NewReader(values.Encode())) - if err != nil { - t.Logf("Failed to create request: %v", err) - t.Fail() - return - } - - // Add headers - req.Header.Add("PRIVATE-TOKEN", httpClient.GetToken()) - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - - // Execute the request - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - t.Logf("Failed to execute request: %v", err) - t.Fail() - return - } - defer resp.Body.Close() - - // Check the response - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != http.StatusCreated { - t.Logf("API request failed with status %d: %s", resp.StatusCode, string(body)) - t.Fail() - } else { - t.Logf("Simplest approach succeeded with status %d", resp.StatusCode) - } - }) -} diff --git a/tests/mr_details_test.go b/tests/mr_details_test.go deleted file mode 100644 index ac230ef55..000000000 --- a/tests/mr_details_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package livereview_test - -import ( - "context" - "fmt" - "path/filepath" - "runtime" - "testing" - - "github.com/livereview/internal/config" - "github.com/livereview/internal/providers/gitlab" - "github.com/livereview/pkg/models" - "github.com/stretchr/testify/assert" -) - -func TestFetchMR335Details(t *testing.T) { - // Load configuration from livereview.toml - // Get the path to livereview.toml from project root - _, filename, _, _ := runtime.Caller(0) - projectRoot := filepath.Dir(filepath.Dir(filename)) - configPath := filepath.Join(projectRoot, "livereview.toml") - - cfg, err := config.LoadConfig(configPath) - assert.NoError(t, err, "Failed to load config") - - // Get GitLab configuration - gitlabCfg, ok := cfg.Providers["gitlab"] - assert.True(t, ok, "GitLab provider not found in config") - - url, ok := gitlabCfg["url"].(string) - assert.True(t, ok, "GitLab URL not found in config") - - token, ok := gitlabCfg["token"].(string) - assert.True(t, ok, "GitLab token not found in config") - - // Create a GitLab provider with the configuration from the file - provider, err := gitlab.New(gitlab.GitLabConfig{ - URL: url, - Token: token, - }) - assert.NoError(t, err) - - // The MR URL for MR 335 - mrURL := "https://git.apps.hexmos.com/hexmos/liveapi/-/merge_requests/335" - - // Create test context - ctx := context.Background() - - // Step 1: Get MR Details - fmt.Println("Fetching details for MR 335...") - mrDetails, err := provider.GetMergeRequestDetails(ctx, mrURL) - assert.NoError(t, err) - - // Print the MR details - fmt.Println("MR Details:") - fmt.Printf(" ID: %s\n", mrDetails.ID) - fmt.Printf(" Project ID: %s\n", mrDetails.ProjectID) - fmt.Printf(" Title: %s\n", mrDetails.Title) - fmt.Printf(" Description: %s\n", mrDetails.Description) - fmt.Printf(" Source Branch: %s\n", mrDetails.SourceBranch) - fmt.Printf(" Target Branch: %s\n", mrDetails.TargetBranch) - fmt.Printf(" Author: %s\n", mrDetails.Author) - fmt.Printf(" State: %s\n", mrDetails.State) - fmt.Printf(" URL: %s\n", mrDetails.URL) - - // Step 2: Get MR Changes - fmt.Println("\nFetching changes for MR 335...") - changes, err := provider.GetMergeRequestChanges(ctx, mrDetails.ID) - assert.NoError(t, err) - - // Print the changes summary - fmt.Printf("Found %d changed files:\n", len(changes)) - for i, change := range changes { - fmt.Printf(" %d. %s\n", i+1, change.FilePath) - if change.IsNew { - fmt.Println(" (New file)") - } - if change.IsDeleted { - fmt.Println(" (Deleted file)") - } - if change.IsRenamed { - fmt.Printf(" (Renamed from: %s)\n", change.OldFilePath) - } - - // Print a sample of the diff (first hunk only, truncated) - if len(change.Hunks) > 0 { - diffContent := change.Hunks[0].Content - if len(diffContent) > 200 { - diffContent = diffContent[:200] + "...[truncated]" - } - fmt.Printf(" Diff sample: %s\n", diffContent) - } - } - - // Step 3: Create a result structure to hold the merge request details and changes - // This could be passed to an AI provider for review - mrContext := struct { - ProjectID string - MergeReqID string - Title string - Description string - SourceBranch string - TargetBranch string - Diffs []*models.CodeDiff - }{ - ProjectID: mrDetails.ProjectID, - MergeReqID: mrDetails.ID, - Title: mrDetails.Title, - Description: mrDetails.Description, - SourceBranch: mrDetails.SourceBranch, - TargetBranch: mrDetails.TargetBranch, - Diffs: changes, - } - - // Print MR context summary - fmt.Println("\nMerge Request Context (ready for AI review):") - fmt.Printf(" Title: %s\n", mrContext.Title) - fmt.Printf(" Description: %s\n", mrContext.Description) - fmt.Printf(" Source Branch: %s\n", mrContext.SourceBranch) - fmt.Printf(" Target Branch: %s\n", mrContext.TargetBranch) - fmt.Printf(" Number of files: %d\n", len(mrContext.Diffs)) - - // Step 4: Verify that we could extract all necessary information - assert.NotEmpty(t, mrDetails.ID) - assert.NotEmpty(t, mrDetails.Title) - assert.NotEmpty(t, mrDetails.ProjectID) - assert.NotEmpty(t, changes) -} diff --git a/tests/pipeline_test.go b/tests/pipeline_test.go deleted file mode 100644 index 34907d928..000000000 --- a/tests/pipeline_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package livereview_test - -import ( - "testing" - - "github.com/livereview/internal/ai/gemini" - "github.com/livereview/internal/providers/gitlab" - "github.com/livereview/pkg/models" - "github.com/stretchr/testify/assert" -) - -func TestCompletePipeline(t *testing.T) { - // This test demonstrates how each step in the pipeline can be tested independently - // In a real integration test, these steps would be connected - - // STEP 1: Get MR context based on URL (using mock provider) - // Create a structure to hold MR context instead of using undefined type - mrContext := struct { - ProjectID string - MergeReqID string - Title string - Description string - SourceBranch string - TargetBranch string - Diffs []*models.CodeDiff - }{ - ProjectID: "123", - MergeReqID: "456", - Title: "Test MR", - Description: "Test Description", - SourceBranch: "feature-branch", - TargetBranch: "main", - Diffs: []*models.CodeDiff{ - { - FilePath: "test/file1.go", - Hunks: []models.DiffHunk{ // Use DiffHunk instead of undefined Hunk - { - OldStartLine: 1, - OldLineCount: 3, - NewStartLine: 1, - NewLineCount: 4, - Content: " func OldFunction() {\n+ // New comment\n fmt.Println(\"Hello\")\n }", - }, - }, - }, - }, - } - - // STEP 2: Construct prompt - // This would be a unit test in prompt_test.go - aiProvider, _ := gemini.New(gemini.GeminiConfig{ - APIKey: "dummy-key", - }) - prompt, err := aiProvider.TestConstructPrompt(mrContext.Diffs) - assert.NoError(t, err) - assert.Contains(t, prompt, "You are an expert code reviewer") - assert.Contains(t, prompt, "test/file1.go") - - // STEP 3: Submit prompt to AI provider - // This would be mocked in a unit test - // For demonstration purposes, we'll just create a mock response - mockResponse := `{ - "summary": "This is a test summary", - "filesChanged": ["test/file1.go"], - "comments": [ - { - "filePath": "test/file1.go", - "lineNumber": 2, - "content": "Good comment addition", - "severity": "info", - "suggestions": ["Maybe add more detail"] - } - ] - }` - - // STEP 4: Parse response from Gemini - reviewResult, err := aiProvider.TestParseResponse(mockResponse, mrContext.Diffs) - assert.NoError(t, err) - assert.Equal(t, "This is a test summary", reviewResult.Summary) - assert.Equal(t, 1, len(reviewResult.Comments)) - assert.Equal(t, "test/file1.go", reviewResult.Comments[0].FilePath) - assert.Equal(t, 2, reviewResult.Comments[0].Line) - - // STEP 5: Post comments to GitLab - // This would be mocked in a unit test - // Simulate a successful post - _, _ = gitlab.New(gitlab.GitLabConfig{ - URL: "https://gitlab.example.com", - Token: "dummy-token", - }) - - // In real tests, this would be mocked or skipped - // err = gitlabProvider.PostComments(context.Background(), mrContext.ProjectID, mrContext.MergeReqID, reviewResult) - // assert.NoError(t, err) - - // Demonstrate the complete pipeline - t.Log("Complete pipeline test demonstrates how to test each component individually") - t.Log("Step 1: Get MR context - Success") - t.Log("Step 2: Construct prompt - Success") - t.Log("Step 3: Submit prompt to AI - Success") - t.Log("Step 4: Parse response - Success") - t.Log("Step 5: Post comments - Would be tested separately") -} diff --git a/tests/test_gitlab_note_webhook.go b/tests/test_gitlab_note_webhook.go deleted file mode 100644 index f9e2debd5..000000000 --- a/tests/test_gitlab_note_webhook.go +++ /dev/null @@ -1,317 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "time" -) - -// Test payload structures -type TestGitLabNotePayload struct { - ObjectKind string `json:"object_kind"` - EventType string `json:"event_type"` - User TestGitLabUser `json:"user"` - ProjectID int `json:"project_id"` - Project TestGitLabProject `json:"project"` - Repository TestGitLabRepository `json:"repository"` - ObjectAttributes TestGitLabNoteObjectAttributes `json:"object_attributes"` - MergeRequest TestGitLabMergeRequest `json:"merge_request"` -} - -type TestGitLabUser struct { - ID int `json:"id"` - Username string `json:"username"` - Name string `json:"name"` - Email string `json:"email"` -} - -type TestGitLabProject struct { - ID int `json:"id"` - Name string `json:"name"` - PathWithNamespace string `json:"path_with_namespace"` - WebURL string `json:"web_url"` -} - -type TestGitLabRepository struct { - Name string `json:"name"` - URL string `json:"url"` - Description string `json:"description"` - Homepage string `json:"homepage"` -} - -type TestGitLabNoteObjectAttributes struct { - ID int `json:"id"` - Note string `json:"note"` - NoteableType string `json:"noteable_type"` - AuthorID int `json:"author_id"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ProjectID int `json:"project_id"` - System bool `json:"system"` - Action string `json:"action"` - URL string `json:"url"` - DiscussionID string `json:"discussion_id"` -} - -type TestGitLabMergeRequest struct { - ID int `json:"id"` - IID int `json:"iid"` - Title string `json:"title"` - URL string `json:"url"` -} - -func main() { - // Test webhook endpoint URL (adjust as needed) - webhookURL := "http://localhost:8888/api/v1/webhooks/gitlab/comments" - - // Test Case 1: Direct bot mention - fmt.Println("=== Test Case 1: Direct Bot Mention ===") - testDirectMention(webhookURL) - - // Test Case 2: General comment (should not trigger) - fmt.Println("\n=== Test Case 2: General Comment (No Response) ===") - testGeneralComment(webhookURL) - - // Test Case 3: Thread reply - fmt.Println("\n=== Test Case 3: Thread Reply ===") - testThreadReply(webhookURL) - - // Test Case 4: System comment (should not trigger) - fmt.Println("\n=== Test Case 4: System Comment ===") - testSystemComment(webhookURL) -} - -func testDirectMention(webhookURL string) { - payload := TestGitLabNotePayload{ - ObjectKind: "note", - EventType: "note", - Project: TestGitLabProject{ - ID: 101, - Name: "Test Project", - PathWithNamespace: "test/project", - WebURL: "https://gitlab.com/test/project", - }, - Repository: TestGitLabRepository{ - Name: "Test Project", - URL: "https://gitlab.com/test/project.git", - Homepage: "https://gitlab.com/test/project", - Description: "A test project", - }, - User: TestGitLabUser{ - ID: 1, - Name: "John Doe", - Username: "johndoe", - Email: "john@example.com", - }, - ObjectAttributes: TestGitLabNoteObjectAttributes{ - ID: 201, - Note: "@ai-bot Can you help me review this code?", - NoteableType: "MergeRequest", - AuthorID: 1, - CreatedAt: time.Now().Format(time.RFC3339), - UpdatedAt: time.Now().Format(time.RFC3339), - ProjectID: 101, - System: false, - Action: "create", - URL: "https://gitlab.com/test/project/-/merge_requests/1#note_201", - DiscussionID: "", - }, - MergeRequest: TestGitLabMergeRequest{ - ID: 301, - IID: 1, - Title: "Feature: Add new API endpoint", - URL: "https://gitlab.com/test/project/-/merge_requests/1", - }, - } - - sendTestWebhook(payload, "Direct bot mention test") -} - -func testGeneralComment(webhookURL string) { - payload := TestGitLabNotePayload{ - ObjectKind: "note", - EventType: "note", - User: TestGitLabUser{ - ID: 124, - Username: "developer2", - Name: "Developer Two", - Email: "dev2@example.com", - }, - ProjectID: 456, - Project: TestGitLabProject{ - ID: 456, - Name: "test-project", - PathWithNamespace: "group/test-project", - WebURL: "https://gitlab.com/group/test-project", - }, - Repository: TestGitLabRepository{ - Name: "test-project", - URL: "https://gitlab.com/group/test-project.git", - Description: "Test project for webhook", - Homepage: "https://gitlab.com/group/test-project", - }, - ObjectAttributes: TestGitLabNoteObjectAttributes{ - ID: 790, - Note: "This looks good to me! Nice work on the implementation.", - NoteableType: "MergeRequest", - AuthorID: 124, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - ProjectID: 456, - System: false, - Action: "create", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1#note_790", - DiscussionID: "", - }, - MergeRequest: TestGitLabMergeRequest{ - ID: 101, - IID: 1, - Title: "Add user authentication feature", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1", - }, - } - - sendTestWebhook(payload, "General comment test") -} - -func testThreadReply(webhookURL string) { - payload := TestGitLabNotePayload{ - ObjectKind: "note", - EventType: "note", - User: TestGitLabUser{ - ID: 125, - Username: "developer3", - Name: "Developer Three", - Email: "dev3@example.com", - }, - ProjectID: 456, - Project: TestGitLabProject{ - ID: 456, - Name: "test-project", - PathWithNamespace: "group/test-project", - WebURL: "https://gitlab.com/group/test-project", - }, - Repository: TestGitLabRepository{ - Name: "test-project", - URL: "https://gitlab.com/group/test-project.git", - Description: "Test project for webhook", - Homepage: "https://gitlab.com/group/test-project", - }, - ObjectAttributes: TestGitLabNoteObjectAttributes{ - ID: 791, - Note: "Thanks for the explanation! That makes sense now.", - NoteableType: "MergeRequest", - AuthorID: 125, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - ProjectID: 456, - System: false, - Action: "create", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1#note_791", - DiscussionID: "abc123def456", // This indicates it's part of a discussion thread - }, - MergeRequest: TestGitLabMergeRequest{ - ID: 101, - IID: 1, - Title: "Add user authentication feature", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1", - }, - } - - sendTestWebhook(payload, "Thread reply test") -} - -func testSystemComment(webhookURL string) { - payload := TestGitLabNotePayload{ - ObjectKind: "note", - EventType: "note", - User: TestGitLabUser{ - ID: 1, - Username: "root", - Name: "Administrator", - Email: "admin@example.com", - }, - ProjectID: 456, - Project: TestGitLabProject{ - ID: 456, - Name: "test-project", - PathWithNamespace: "group/test-project", - WebURL: "https://gitlab.com/group/test-project", - }, - Repository: TestGitLabRepository{ - Name: "test-project", - URL: "https://gitlab.com/group/test-project.git", - Description: "Test project for webhook", - Homepage: "https://gitlab.com/group/test-project", - }, - ObjectAttributes: TestGitLabNoteObjectAttributes{ - ID: 792, - Note: "assigned to @developer1", - NoteableType: "MergeRequest", - AuthorID: 1, - CreatedAt: time.Now().UTC().Format(time.RFC3339), - UpdatedAt: time.Now().UTC().Format(time.RFC3339), - ProjectID: 456, - System: true, // This is a system comment - Action: "create", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1#note_792", - DiscussionID: "", - }, - MergeRequest: TestGitLabMergeRequest{ - ID: 101, - IID: 1, - Title: "Add user authentication feature", - URL: "https://gitlab.com/group/test-project/-/merge_requests/1", - }, - } - - sendTestWebhook(payload, "System comment test") -} - -func sendTestWebhook(payload TestGitLabNotePayload, testName string) { - webhookURL := "http://localhost:8888/api/v1/webhooks/gitlab/comments" - - // Convert payload to JSON - jsonData, err := json.MarshalIndent(payload, "", " ") - if err != nil { - fmt.Printf("❌ %s: Failed to marshal JSON: %v\n", testName, err) - return - } - - // Create HTTP request - req, err := http.NewRequest("POST", webhookURL, bytes.NewBuffer(jsonData)) - if err != nil { - fmt.Printf("❌ %s: Failed to create request: %v\n", testName, err) - return - } - - // Set headers (mimic GitLab webhook) - req.Header.Set("Content-Type", "application/json") - req.Header.Set("X-Gitlab-Event", "Note Hook") - req.Header.Set("X-Gitlab-Instance", "https://gitlab.com") - req.Header.Set("User-Agent", "GitLab/16.5.0") - - // Send request - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) - if err != nil { - fmt.Printf("❌ %s: Failed to send request: %v\n", testName, err) - return - } - defer resp.Body.Close() - - // Read response - var responseBody bytes.Buffer - responseBody.ReadFrom(resp.Body) - - // Print results - if resp.StatusCode == http.StatusOK { - fmt.Printf("✅ %s: Success (Status: %d)\n", testName, resp.StatusCode) - fmt.Printf(" Response: %s\n", responseBody.String()) - } else { - fmt.Printf("❌ %s: Failed (Status: %d)\n", testName, resp.StatusCode) - fmt.Printf(" Response: %s\n", responseBody.String()) - } -} diff --git a/tests/verify_line_comment.go b/tests/verify_line_comment.go deleted file mode 100644 index c60d4b542..000000000 --- a/tests/verify_line_comment.go +++ /dev/null @@ -1,103 +0,0 @@ -package main - -import ( - "fmt" - "os" - "strings" - "time" - - "github.com/livereview/internal/providers/gitlab" -) - -// Test script to verify line-specific comments on GitLab -func main() { - fmt.Println("Testing GitLab line comment functionality") - - // Get GitLab URL and token from environment variables - gitlabURL := os.Getenv("GITLAB_URL") - // Test token for git.apps.hexmos.com - hardcoded for testing only - gitlabToken := "REDACTED_GITLAB_PAT_6" - - if gitlabURL == "" || gitlabToken == "" { - fmt.Println("Error: GITLAB_URL and GITLAB_TOKEN environment variables must be set") - fmt.Println("Example:") - fmt.Println(" export GITLAB_URL=https://git.example.com") - fmt.Println(" export GITLAB_TOKEN=your_access_token") - os.Exit(1) - } - - // Check command line arguments - if len(os.Args) < 4 { - fmt.Println("Usage: go run verify_line_comment.go [line_number]") - fmt.Println("Example: go run verify_line_comment.go hexmos/liveapi 335 liveapi-backend/exam/metric_analysis.go 19") - os.Exit(1) - } - - // Get parameters from command line - projectID := os.Args[1] - mrIID := 0 - fmt.Sscanf(os.Args[2], "%d", &mrIID) - filePath := os.Args[3] - lineNum := 1 // Default to line 1 - if len(os.Args) > 4 { - fmt.Sscanf(os.Args[4], "%d", &lineNum) - } - - // Configuration - config := gitlab.GitLabConfig{ - URL: gitlabURL, - Token: gitlabToken, - } - - // Create a GitLab client - httpClient := gitlab.NewHTTPClient(config.URL, config.Token) - fmt.Println("Created GitLab HTTP client") - - // Create a test comment with timestamp - timestamp := time.Now().Format(time.RFC3339) - comment := fmt.Sprintf("TEST COMMENT: This is a test comment at %s using improved line_code generation", timestamp) - - // Try to post the comment - fmt.Println("Posting line comment to:") - fmt.Printf("- Project: %s\n", projectID) - fmt.Printf("- MR: %d\n", mrIID) - fmt.Printf("- File: %s\n", filePath) - fmt.Printf("- Line: %d\n", lineNum) - - // Get the MR versions to verify they're accessible - versions, err := httpClient.GetMergeRequestVersions(projectID, mrIID) - if err != nil { - fmt.Printf("Error getting MR versions: %v\n", err) - os.Exit(1) - } - - if len(versions) == 0 { - fmt.Println("Error: No versions found for this merge request") - os.Exit(1) - } - - // Show version information - latestVersion := versions[0] - fmt.Println("MR version information:") - fmt.Printf("- Base SHA: %s\n", latestVersion.BaseCommitSHA) - fmt.Printf("- Head SHA: %s\n", latestVersion.HeadCommitSHA) - fmt.Printf("- Start SHA: %s\n", latestVersion.StartCommitSHA) - - // Generate the line_code that will be used - lineCode := gitlab.GenerateLineCode(latestVersion.StartCommitSHA, latestVersion.HeadCommitSHA, filePath, lineNum) - parts := strings.Split(lineCode, ":") - fmt.Println("Line code formats:") - fmt.Printf("- Combined format: %s\n", lineCode) - fmt.Printf("- New style: %s\n", parts[0]) - if len(parts) > 1 { - fmt.Printf("- Old style: %s\n", parts[1]) - } - - err = httpClient.CreateMRLineComment(projectID, mrIID, filePath, lineNum, comment) - if err != nil { - fmt.Printf("Error posting comment: %v\n", err) - os.Exit(1) - } - - fmt.Println("Comment posted successfully! Check GitLab to verify it's attached to the correct line.") -} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b56314f57..fe235e44c 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -25,6 +25,7 @@ const PullRequestReviewTrigger = React.lazy(() => import('./pages/Reviews/PullRe const Login = React.lazy(() => import('./pages/Auth/Login')); const SelfHosted = React.lazy(() => import('./pages/Auth/SelfHosted')); const GhLogin = React.lazy(() => import('./pages/Auth/GhLogin')); +const SSOCallback = React.lazy(() => import('./pages/Auth/SSOCallback')); const GetStartedGitHub = React.lazy(() => import('./pages/GetStarted/GetStartedGitHub')); const Setup = React.lazy(() => import('./pages/Setup/Setup')); const CodeHostCallback = React.lazy(() => import('./pages/Auth/CodeHostCallback')); @@ -294,6 +295,9 @@ const AppContent: React.FC = () => { if (location.pathname.startsWith('/gh-login')) { // "Continue with GitHub" landing page; shows the normal login itself when the feature is off. body = ; + } else if (location.pathname.startsWith('/sso/callback')) { + // Lands here right after a successful OIDC/SAML login; tokens are in the URL fragment. + body = ; } else if (isCloudMode() && location.pathname === '/admin') { body = ; } else { diff --git a/ui/src/components/Navbar/megaMenuData.ts b/ui/src/components/Navbar/megaMenuData.ts index 9c32b60d0..97000bea7 100644 --- a/ui/src/components/Navbar/megaMenuData.ts +++ b/ui/src/components/Navbar/megaMenuData.ts @@ -254,6 +254,7 @@ export const buildMegaMenuSections = (): MegaMenuSection[] => [ ], React.createElement(Icons.Reports)), group('Manage System', [ link('Storage', React.createElement(Icons.Folder), '/settings#storage', (ctx) => ctx.isSuperAdmin || (ctx.orgRole === 'owner' && !isCloudMode())), + link('Single Sign-On (SSO)', React.createElement(Icons.Settings), '/settings#sso', (ctx) => !isCloudMode() && (ctx.isSuperAdmin || ctx.orgRole === 'owner')), link('Managed AI', React.createElement(Icons.AI), '/settings#managed-ai', (ctx) => ctx.isSuperAdmin && isCloudMode()), link('Log Compaction', React.createElement(Icons.Clock), '/settings?section=log-compaction#storage', (ctx) => ctx.isSuperAdmin), link('Preloaded Changes Archival', React.createElement(Icons.Folder), '/settings?section=archival#storage', (ctx) => ctx.isSuperAdmin || (ctx.orgRole === 'owner' && !isCloudMode())), diff --git a/ui/src/pages/Auth/SSOCallback.tsx b/ui/src/pages/Auth/SSOCallback.tsx new file mode 100644 index 000000000..a36409935 --- /dev/null +++ b/ui/src/pages/Auth/SSOCallback.tsx @@ -0,0 +1,46 @@ +import React, { useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAppDispatch } from '../../store/configureStore'; +import { setTokens } from '../../store/Auth/reducer'; +import { notify } from '../../utils/notify'; + +/** + * Lands here after a successful OIDC/SAML login - the backend's + * completeSSOLogin redirects the browser to + * /sso/callback#access_token=...&refresh_token=...&expires_at=...&token_type=... + * (a URL fragment, not a query string, so the tokens are never sent to or + * logged by any server). This mirrors the normal password-login flow by + * dispatching the same setTokens action, which flips Auth.isAuthenticated, + * then hands off to the dashboard like any other login. + */ +const SSOCallback: React.FC = () => { + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + useEffect(() => { + const params = new URLSearchParams(window.location.hash.replace(/^#/, '')); + const access_token = params.get('access_token'); + const refresh_token = params.get('refresh_token'); + const expires_at = params.get('expires_at') || ''; + const token_type = params.get('token_type') || 'Bearer'; + + if (!access_token || !refresh_token) { + notify.error('Single sign-on login failed: no tokens received'); + navigate('/?sso_error=missing_tokens', { replace: true }); + return; + } + + dispatch(setTokens({ access_token, refresh_token, expires_at, token_type })); + window.history.replaceState(null, '', '/dashboard'); + notify.success('Login successful!'); + navigate('/dashboard', { replace: true }); + }, [dispatch, navigate]); + + return ( +
+
+
+ ); +}; + +export default SSOCallback; diff --git a/ui/src/pages/Auth/SelfHosted.tsx b/ui/src/pages/Auth/SelfHosted.tsx index 5e27e2fd2..bc4650364 100644 --- a/ui/src/pages/Auth/SelfHosted.tsx +++ b/ui/src/pages/Auth/SelfHosted.tsx @@ -1,10 +1,18 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useAppDispatch, useAppSelector } from '../../store/configureStore'; import { useNavigate } from 'react-router-dom'; import { login } from '../../store/Auth/reducer'; import { handleLoginError, getRedirectAfterLogin, clearRedirectAfterLogin } from '../../utils/authHelpers'; import { notify } from '../../utils/notify'; +import apiClient from '../../api/apiClient'; + +interface SSOProviderInfo { + org_id: number; + org_name: string; + provider_type: 'oidc' | 'saml'; + display_name: string; +} const SelfHosted: React.FC = () => { const dispatch = useAppDispatch(); @@ -12,6 +20,15 @@ const SelfHosted: React.FC = () => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); const { isLoading, error } = useAppSelector((state) => state.Auth); + const [ssoProviders, setSsoProviders] = useState([]); + const ssoErrorParam = new URLSearchParams(window.location.search).get('sso_error'); + + useEffect(() => { + apiClient + .get('/api/v1/auth/sso/providers') + .then((data) => setSsoProviders(data || [])) + .catch(() => setSsoProviders([])); + }, []); // Debug logging for login component console.log('🚨🚨🚨 === LOGIN COMPONENT DEBUG === 🚨🚨🚨'); @@ -161,6 +178,11 @@ const SelfHosted: React.FC = () => { {error &&
{error}
} + {ssoErrorParam && ( +
+ Single sign-on login failed ({ssoErrorParam}). Please try again or use your password below. +
+ )}
+ + {ssoProviders.length > 0 && ( +
+
+
+
+
+
+ or +
+
+
+ {ssoProviders.map((p) => ( + + Continue with {p.display_name} + + ))} +
+
+ )}
); diff --git a/ui/src/pages/Settings/SSOSettingsTab.tsx b/ui/src/pages/Settings/SSOSettingsTab.tsx new file mode 100644 index 000000000..ec4e12cbb --- /dev/null +++ b/ui/src/pages/Settings/SSOSettingsTab.tsx @@ -0,0 +1,343 @@ +import React, { useEffect, useState } from 'react'; +import { Button, Input, Icons } from '../../components/UIPrimitives'; +import apiClient from '../../api/apiClient'; +import { notify } from '../../utils/notify'; +import { useOrgContext } from '../../hooks/useOrgContext'; + +type ProviderType = 'oidc' | 'saml'; + +interface SSOConfigResponse { + provider_type: ProviderType; + display_name: string; + enabled: boolean; + oidc_issuer?: string; + oidc_client_id?: string; + oidc_client_secret_set: boolean; + oidc_scopes?: string; + saml_idp_metadata_url?: string; + saml_idp_entity_id?: string; + saml_metadata_set: boolean; +} + +interface FormState { + display_name: string; + enabled: boolean; + oidc_issuer: string; + oidc_client_id: string; + oidc_client_secret: string; + oidc_scopes: string; + saml_idp_metadata_url: string; + saml_idp_metadata_xml: string; +} + +const emptyForm: FormState = { + display_name: '', + enabled: false, + oidc_issuer: '', + oidc_client_id: '', + oidc_client_secret: '', + oidc_scopes: '', + saml_idp_metadata_url: '', + saml_idp_metadata_xml: '', +}; + +const ResultBanner: React.FC<{ type: 'success' | 'error'; message: string }> = ({ type, message }) => ( +
+ {message} +
+); + +const ProviderPanel: React.FC<{ + provider: ProviderType; + orgId: number; + existing: SSOConfigResponse | undefined; + onSaved: () => void; +}> = ({ provider, orgId, existing, onSaved }) => { + const [form, setForm] = useState(emptyForm); + const [isSaving, setIsSaving] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [result, setResult] = useState<{ type: 'success' | 'error'; message: string } | null>(null); + + useEffect(() => { + if (existing) { + setForm({ + display_name: existing.display_name, + enabled: existing.enabled, + oidc_issuer: existing.oidc_issuer || '', + oidc_client_id: existing.oidc_client_id || '', + oidc_client_secret: '', // never prefilled - left blank keeps the stored secret + oidc_scopes: existing.oidc_scopes || '', + saml_idp_metadata_url: existing.saml_idp_metadata_url || '', + saml_idp_metadata_xml: '', // never round-tripped - left blank keeps the stored metadata + }); + } + }, [existing]); + + const handleChange = (field: keyof FormState, value: string | boolean) => { + setForm(prev => ({ ...prev, [field]: value })); + }; + + const requestBody = () => { + const body: Record = { + display_name: form.display_name, + enabled: form.enabled, + }; + if (provider === 'oidc') { + body.oidc_issuer = form.oidc_issuer; + body.oidc_client_id = form.oidc_client_id; + body.oidc_scopes = form.oidc_scopes; + if (form.oidc_client_secret !== '') { + body.oidc_client_secret = form.oidc_client_secret; + } + } else { + body.saml_idp_metadata_url = form.saml_idp_metadata_url; + if (form.saml_idp_metadata_xml !== '') { + body.saml_idp_metadata_xml = form.saml_idp_metadata_xml; + } + } + return body; + }; + + const handleSave = async () => { + if (!form.display_name) { + notify.error('Please enter a display name (shown on the login page)'); + return; + } + setIsSaving(true); + setResult(null); + try { + await apiClient.put(`/api/v1/orgs/${orgId}/sso-config/${provider}`, requestBody()); + setResult({ type: 'success', message: 'SSO configuration saved successfully!' }); + notify.success('SSO configuration saved successfully!'); + onSaved(); + } catch (error: any) { + const message = error?.message || 'Failed to save SSO configuration'; + setResult({ type: 'error', message }); + notify.error(message); + } finally { + setIsSaving(false); + } + }; + + const handleTest = async () => { + setIsTesting(true); + setResult(null); + try { + const response = await apiClient.post<{ message: string }>(`/api/v1/orgs/${orgId}/sso-config/${provider}/test`, requestBody()); + const message = response?.message || 'Connection test succeeded'; + setResult({ type: 'success', message }); + notify.success(message); + } catch (error: any) { + const message = error?.message || 'Connection test failed'; + setResult({ type: 'error', message }); + notify.error(message); + } finally { + setIsTesting(false); + } + }; + + const handleDelete = async () => { + if (!window.confirm(`Delete the ${provider.toUpperCase()} SSO configuration? Users will no longer be able to sign in via this provider.`)) { + return; + } + setIsDeleting(true); + setResult(null); + try { + await apiClient.delete(`/api/v1/orgs/${orgId}/sso-config/${provider}`); + notify.success('SSO configuration deleted'); + setForm(emptyForm); + onSaved(); + } catch (error: any) { + const message = error?.message || 'Failed to delete SSO configuration'; + setResult({ type: 'error', message }); + notify.error(message); + } finally { + setIsDeleting(false); + } + }; + + const metadataURL = `${window.location.origin}/api/v1/auth/sso/saml/${orgId}/metadata`; + const acsURL = `${window.location.origin}/api/v1/auth/sso/saml/${orgId}/acs`; + + return ( +
+ {result && } + + handleChange('display_name', e.target.value)} + /> + + {provider === 'oidc' ? ( + <> + handleChange('oidc_issuer', e.target.value)} + /> +
+ handleChange('oidc_client_id', e.target.value)} + /> + handleChange('oidc_client_secret', e.target.value)} + /> +
+ handleChange('oidc_scopes', e.target.value)} + /> +

+ Redirect URI to register with your identity provider:{' '} + {window.location.origin}/api/v1/auth/sso/oidc/callback +

+ + ) : ( + <> + handleChange('saml_idp_metadata_url', e.target.value)} + /> +
+ +