From ed95764b51cb6ac7e2810a5005fbc227447de8f5 Mon Sep 17 00:00:00 2001
From: Shrijith Venkatramana
Date: Thu, 29 Oct 2026 16:41:14 +0000
Subject: [PATCH 1/4] Add SSO deprovisioning, session revocation, and SCIM
provisioning
Closes the offboarding gap in the OIDC/SAML SSO feature: SSO login no
longer silently reactivates a deactivated user, deactivation now revokes
live sessions immediately, and orgs can wire up SCIM 2.0 (Okta/Azure
AD/Google Workspace) for real-time automated deprovisioning. Adds an
OIDC back-channel logout endpoint and an optional reconciliation poll as
a fallback for OIDC orgs that skip SCIM. SAML Single Logout was not
implemented (documented gap in docs/sso.md) since the SAML library has
no safe primitive for verifying an inbound signed LogoutRequest.
LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
---
config/plan_catalog.json | 3 +-
.../20261029150000_create_scim_tokens.sql | 23 +
.../20261029150100_create_sso_identities.sql | 42 ++
...9150200_add_sso_reconciliation_columns.sql | 17 +
db/schema.sql | 196 ++++++++-
docs/audit-events.md | 7 +
docs/sso.md | 143 ++++++
internal/api/auth/handlers.go | 5 +-
internal/api/auth/sso_handlers.go | 101 ++++-
internal/api/auth/token_service.go | 8 +-
internal/api/scim_handlers.go | 410 ++++++++++++++++++
internal/api/server.go | 25 +-
internal/api/sso_config.go | 64 ++-
internal/api/users/handlers.go | 27 ++
internal/api/users/user_service.go | 175 +++++++-
internal/auditlog/catalog.go | 15 +
.../docs/routes_guide/settings/sso.md | 53 +++
internal/jobqueue/jobqueue.go | 22 +-
.../jobqueue/sso_reconciliation_worker.go | 88 ++++
internal/license/plans.go | 1 +
internal/scim/token.go | 153 +++++++
internal/scim/types.go | 78 ++++
internal/sso/config.go | 68 ++-
internal/sso/identity_store.go | 110 +++++
internal/sso/oidc.go | 61 ++-
internal/sso/saml.go | 1 +
ui/src/pages/Settings/SSOSettingsTab.tsx | 178 ++++++++
27 files changed, 2026 insertions(+), 48 deletions(-)
create mode 100644 db/migrations/20261029150000_create_scim_tokens.sql
create mode 100644 db/migrations/20261029150100_create_sso_identities.sql
create mode 100644 db/migrations/20261029150200_add_sso_reconciliation_columns.sql
create mode 100644 internal/api/scim_handlers.go
create mode 100644 internal/jobqueue/sso_reconciliation_worker.go
create mode 100644 internal/scim/token.go
create mode 100644 internal/scim/types.go
create mode 100644 internal/sso/identity_store.go
diff --git a/config/plan_catalog.json b/config/plan_catalog.json
index 6480e0b71..e709a10e1 100644
--- a/config/plan_catalog.json
+++ b/config/plan_catalog.json
@@ -123,7 +123,8 @@
"feature_flags": [
"unlimited_reviews",
"hosted_auto_model",
- "sso"
+ "sso",
+ "scim"
],
"trial_policy": {
"enabled": false,
diff --git a/db/migrations/20261029150000_create_scim_tokens.sql b/db/migrations/20261029150000_create_scim_tokens.sql
new file mode 100644
index 000000000..07b007971
--- /dev/null
+++ b/db/migrations/20261029150000_create_scim_tokens.sql
@@ -0,0 +1,23 @@
+-- migrate:up
+
+-- SCIM clients (Okta, Azure AD, Google Workspace) authenticate with a single
+-- static bearer token per org, per the SCIM 2.0 RFC - there is no user
+-- session involved. Modeled directly on api_keys' hashing scheme
+-- (internal/api/api_keys.go: GenerateAPIKey/HashAPIKey/GetKeyPrefix), but
+-- kept as its own table rather than reusing api_keys since a SCIM token is
+-- org-bound with no owning user.
+CREATE TABLE IF NOT EXISTS scim_tokens (
+ id BIGSERIAL PRIMARY KEY,
+ org_id BIGINT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ token_hash VARCHAR(64) NOT NULL UNIQUE,
+ token_prefix VARCHAR(16) NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ expires_at TIMESTAMPTZ,
+ revoked_at TIMESTAMPTZ,
+ created_by_user_id BIGINT REFERENCES users(id) ON DELETE SET NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_scim_tokens_org_id ON scim_tokens(org_id);
+
+-- migrate:down
+DROP TABLE IF EXISTS scim_tokens;
diff --git a/db/migrations/20261029150100_create_sso_identities.sql b/db/migrations/20261029150100_create_sso_identities.sql
new file mode 100644
index 000000000..7970a6508
--- /dev/null
+++ b/db/migrations/20261029150100_create_sso_identities.sql
@@ -0,0 +1,42 @@
+-- migrate:up
+
+-- Tracks which IdP subject (OIDC `sub` / SAML NameID) maps to which
+-- LiveReview user, per org and provider. Needed so an incoming OIDC
+-- back-channel logout token or SAML LogoutRequest - which only carries the
+-- IdP's own subject identifier, not a LiveReview user id - can be resolved
+-- to a user whose sessions should be revoked. Also holds the encrypted OIDC
+-- refresh token used by the optional reconciliation poll (see
+-- org_sso_configs.reconciliation_enabled) to silently re-check "is this
+-- account still valid at the IdP" between logins.
+CREATE TABLE IF NOT EXISTS sso_identities (
+ id BIGSERIAL PRIMARY KEY,
+ org_id BIGINT NOT NULL REFERENCES orgs(id) ON DELETE CASCADE,
+ user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ provider_type VARCHAR(16) NOT NULL CHECK (provider_type IN ('oidc', 'saml')),
+ subject VARCHAR(512) NOT NULL,
+ oidc_refresh_token_enc TEXT,
+ last_checked_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (org_id, provider_type, subject)
+);
+
+CREATE INDEX IF NOT EXISTS idx_sso_identities_user_id ON sso_identities(user_id);
+
+CREATE OR REPLACE FUNCTION set_sso_identities_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trg_sso_identities_updated_at
+ BEFORE UPDATE ON sso_identities
+ FOR EACH ROW
+ EXECUTE FUNCTION set_sso_identities_updated_at();
+
+-- migrate:down
+DROP TRIGGER IF EXISTS trg_sso_identities_updated_at ON sso_identities;
+DROP FUNCTION IF EXISTS set_sso_identities_updated_at();
+DROP TABLE IF EXISTS sso_identities;
diff --git a/db/migrations/20261029150200_add_sso_reconciliation_columns.sql b/db/migrations/20261029150200_add_sso_reconciliation_columns.sql
new file mode 100644
index 000000000..47ca39f13
--- /dev/null
+++ b/db/migrations/20261029150200_add_sso_reconciliation_columns.sql
@@ -0,0 +1,17 @@
+-- migrate:up
+
+-- Fallback deprovisioning for orgs that configure OIDC login but never wire
+-- up SCIM at their IdP (a separate integration step many admins skip). Only
+-- meaningful for provider_type='oidc' - SAML has no standing credential to
+-- silently re-check, so the settings UI only exposes this toggle for OIDC.
+ALTER TABLE org_sso_configs
+ ADD COLUMN IF NOT EXISTS reconciliation_enabled BOOLEAN NOT NULL DEFAULT FALSE,
+ ADD COLUMN IF NOT EXISTS reconciliation_interval TEXT NOT NULL DEFAULT 'daily'
+ CHECK (reconciliation_interval IN ('hourly', 'daily', 'weekly')),
+ ADD COLUMN IF NOT EXISTS last_reconciled_at TIMESTAMPTZ;
+
+-- migrate:down
+ALTER TABLE org_sso_configs
+ DROP COLUMN IF EXISTS reconciliation_enabled,
+ DROP COLUMN IF EXISTS reconciliation_interval,
+ DROP COLUMN IF EXISTS last_reconciled_at;
diff --git a/db/schema.sql b/db/schema.sql
index 894fa428b..294a1b7d7 100644
--- a/db/schema.sql
+++ b/db/schema.sql
@@ -302,6 +302,20 @@ CREATE FUNCTION public.river_job_state_in_bitmask(bitmask bit, state public.rive
$$;
+--
+-- Name: set_sso_identities_updated_at(); Type: FUNCTION; Schema: public; Owner: -
+--
+
+CREATE FUNCTION public.set_sso_identities_updated_at() RETURNS trigger
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$;
+
+
--
-- Name: trial_eligibility_set_updated_at(); Type: FUNCTION; Schema: public; Owner: -
--
@@ -2133,7 +2147,11 @@ CREATE TABLE public.org_sso_configs (
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[])))
+ reconciliation_enabled boolean DEFAULT false NOT NULL,
+ reconciliation_interval text DEFAULT 'daily'::text NOT NULL,
+ last_reconciled_at timestamp with time zone,
+ CONSTRAINT org_sso_configs_provider_type_check CHECK (((provider_type)::text = ANY ((ARRAY['oidc'::character varying, 'saml'::character varying])::text[]))),
+ CONSTRAINT org_sso_configs_reconciliation_interval_check CHECK ((reconciliation_interval = ANY (ARRAY['hourly'::text, 'daily'::text, 'weekly'::text])))
);
@@ -3428,6 +3446,78 @@ CREATE TABLE public.schema_migrations (
);
+--
+-- Name: scim_tokens; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.scim_tokens (
+ id bigint NOT NULL,
+ org_id bigint NOT NULL,
+ token_hash character varying(64) NOT NULL,
+ token_prefix character varying(16) NOT NULL,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ expires_at timestamp with time zone,
+ revoked_at timestamp with time zone,
+ created_by_user_id bigint
+);
+
+
+--
+-- Name: scim_tokens_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.scim_tokens_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: scim_tokens_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.scim_tokens_id_seq OWNED BY public.scim_tokens.id;
+
+
+--
+-- Name: sso_identities; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.sso_identities (
+ id bigint NOT NULL,
+ org_id bigint NOT NULL,
+ user_id bigint NOT NULL,
+ provider_type character varying(16) NOT NULL,
+ subject character varying(512) NOT NULL,
+ oidc_refresh_token_enc text,
+ last_checked_at timestamp with time zone,
+ created_at timestamp with time zone DEFAULT now() NOT NULL,
+ updated_at timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT sso_identities_provider_type_check CHECK (((provider_type)::text = ANY ((ARRAY['oidc'::character varying, 'saml'::character varying])::text[])))
+);
+
+
+--
+-- Name: sso_identities_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.sso_identities_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: sso_identities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.sso_identities_id_seq OWNED BY public.sso_identities.id;
+
+
--
-- Name: subscription_payments; Type: TABLE; Schema: public; Owner: -
--
@@ -4520,6 +4610,20 @@ ALTER TABLE ONLY public.scheduled_review_configs ALTER COLUMN id SET DEFAULT nex
ALTER TABLE ONLY public.scheduled_review_runs ALTER COLUMN id SET DEFAULT nextval('public.scheduled_review_runs_id_seq'::regclass);
+--
+-- Name: scim_tokens id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.scim_tokens ALTER COLUMN id SET DEFAULT nextval('public.scim_tokens_id_seq'::regclass);
+
+
+--
+-- Name: sso_identities id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.sso_identities ALTER COLUMN id SET DEFAULT nextval('public.sso_identities_id_seq'::regclass);
+
+
--
-- Name: subscription_payments id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -5340,6 +5444,38 @@ ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
+--
+-- Name: scim_tokens scim_tokens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.scim_tokens
+ ADD CONSTRAINT scim_tokens_pkey PRIMARY KEY (id);
+
+
+--
+-- Name: scim_tokens scim_tokens_token_hash_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.scim_tokens
+ ADD CONSTRAINT scim_tokens_token_hash_key UNIQUE (token_hash);
+
+
+--
+-- Name: sso_identities sso_identities_org_id_provider_type_subject_key; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.sso_identities
+ ADD CONSTRAINT sso_identities_org_id_provider_type_subject_key UNIQUE (org_id, provider_type, subject);
+
+
+--
+-- Name: sso_identities sso_identities_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.sso_identities
+ ADD CONSTRAINT sso_identities_pkey PRIMARY KEY (id);
+
+
--
-- Name: subscription_payments subscription_payments_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -7598,6 +7734,20 @@ CREATE INDEX idx_scheduled_review_configs_org_id ON public.scheduled_review_conf
CREATE INDEX idx_scheduled_review_runs_config_id ON public.scheduled_review_runs USING btree (config_id, started_at DESC);
+--
+-- Name: idx_scim_tokens_org_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_scim_tokens_org_id ON public.scim_tokens USING btree (org_id);
+
+
+--
+-- Name: idx_sso_identities_user_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX idx_sso_identities_user_id ON public.sso_identities USING btree (user_id);
+
+
--
-- Name: idx_subscription_payments_captured; Type: INDEX; Schema: public; Owner: -
--
@@ -9180,6 +9330,13 @@ CREATE TRIGGER trg_quota_operation_aggregates_updated_at BEFORE UPDATE ON public
CREATE TRIGGER trg_quota_policy_catalog_updated_at BEFORE UPDATE ON public.quota_policy_catalog FOR EACH ROW EXECUTE FUNCTION public.quota_policy_catalog_set_updated_at();
+--
+-- Name: sso_identities trg_sso_identities_updated_at; Type: TRIGGER; Schema: public; Owner: -
+--
+
+CREATE TRIGGER trg_sso_identities_updated_at BEFORE UPDATE ON public.sso_identities FOR EACH ROW EXECUTE FUNCTION public.set_sso_identities_updated_at();
+
+
--
-- Name: trial_eligibility trg_trial_eligibility_updated_at; Type: TRIGGER; Schema: public; Owner: -
--
@@ -9899,6 +10056,38 @@ ALTER TABLE ONLY public.scheduled_review_runs
ADD CONSTRAINT scheduled_review_runs_review_id_fkey FOREIGN KEY (review_id) REFERENCES public.reviews(id) ON DELETE SET NULL;
+--
+-- Name: scim_tokens scim_tokens_created_by_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.scim_tokens
+ ADD CONSTRAINT scim_tokens_created_by_user_id_fkey FOREIGN KEY (created_by_user_id) REFERENCES public.users(id) ON DELETE SET NULL;
+
+
+--
+-- Name: scim_tokens scim_tokens_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.scim_tokens
+ ADD CONSTRAINT scim_tokens_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE;
+
+
+--
+-- Name: sso_identities sso_identities_org_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.sso_identities
+ ADD CONSTRAINT sso_identities_org_id_fkey FOREIGN KEY (org_id) REFERENCES public.orgs(id) ON DELETE CASCADE;
+
+
+--
+-- Name: sso_identities sso_identities_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.sso_identities
+ ADD CONSTRAINT sso_identities_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) ON DELETE CASCADE;
+
+
--
-- Name: subscription_payments subscription_payments_subscription_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -10301,4 +10490,7 @@ INSERT INTO public.schema_migrations (version) VALUES
('20261025120100'),
('20261025130000'),
('20261029104047'),
- ('20261029134500');
+ ('20261029134500'),
+ ('20261029150000'),
+ ('20261029150100'),
+ ('20261029150200');
diff --git a/docs/audit-events.md b/docs/audit-events.md
index 3ce3bc80b..0d02adbbd 100644
--- a/docs/audit-events.md
+++ b/docs/audit-events.md
@@ -58,6 +58,7 @@ Generated from `internal/auditlog/catalog.go`.
| `auth.logout` | `auth` | session | info | Signed out | A user ended a session. |
| `auth.password.change` | `auth` | user | warning | Password changed | A user changed their password. |
| `auth.password.reset.complete` | `auth` | user | warning | Password reset completed | A user completed a required password reset. |
+| `auth.session.revoke` | `auth` | session | warning | Sessions revoked | A user's active sessions and refresh tokens were revoked, typically following deactivation. |
| `auth.sso.provision` | `auth` | user | info | SSO account provisioned | An account was provisioned through SSO. |
| `auth.token.refresh` | `auth` | session | info | Session refreshed | A session token was refreshed. |
| `auto_approve.policy.create` | `settings` | policy | warning | Auto-approve policy created | An auto-approve policy was created. |
@@ -106,6 +107,12 @@ Generated from `internal/auditlog/catalog.go`.
| `ruleset.create` | `repository` | ruleset | warning | Ruleset created | A CI ruleset was created. |
| `ruleset.delete` | `repository` | ruleset | warning | Ruleset deleted | A CI ruleset was deleted. |
| `ruleset.update` | `repository` | ruleset | warning | Ruleset updated | A CI ruleset was changed. |
+| `scim.token.create` | `auth` | scim_token | warning | SCIM token created | An org's SCIM bearer token was generated or rotated. |
+| `scim.token.revoke` | `auth` | scim_token | warning | SCIM token revoked | An org's SCIM bearer token was revoked. |
+| `scim.user.create` | `auth` | user | info | SCIM user provisioned | An identity provider created or linked a user via SCIM. |
+| `scim.user.deactivate` | `auth` | user | warning | SCIM user deactivated | An identity provider deactivated a user via SCIM. |
+| `scim.user.reactivate` | `auth` | user | warning | SCIM user reactivated | An identity provider reactivated a user via SCIM. |
+| `scim.user.update` | `auth` | user | info | SCIM user updated | An identity provider updated a user's attributes via SCIM. |
| `settings.auto_approve.update` | `settings` | setting | warning | Auto-approve updated | The auto-approve policy was changed. |
| `settings.managed_ai.update` | `settings` | setting | critical | Managed AI updated | LiveReview-managed AI configuration was changed. |
| `settings.prompts.update` | `settings` | prompt | warning | Prompts updated | Review prompts were changed. |
diff --git a/docs/sso.md b/docs/sso.md
index 902faa32f..2e9412f9c 100644
--- a/docs/sso.md
+++ b/docs/sso.md
@@ -373,3 +373,146 @@ and the four pre-merge security scanners (`make security-osv`,
`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.
+
+---
+
+## Deprovisioning & lifecycle sync (follow-up, 2026-10-29)
+
+The phases above cover provisioning only (JIT create/login). An audit found
+zero offboarding story: SSO login silently re-activated deactivated users,
+deactivation never revoked a live JWT session, there was no automated
+deprovisioning (SCIM), and no real-time session kill from the IdP. This
+section closes those gaps.
+
+### Phase 7 — Reactivation fix + session revocation — ✅ DONE
+- [x] `jitProvisionSSOUser` (`internal/api/auth/sso_handlers.go`) now checks
+ `is_active` before linking an existing user into an org; a deactivated
+ user's SSO login is rejected (`errSSOAccountDeactivated` →
+ `account_deactivated` failure redirect) instead of being silently
+ flipped back to active. The old unconditional
+ `UPDATE users SET ... is_active = TRUE` is gone.
+- [x] `POST /api/v1/orgs/:org_id/users/:user_id/reactivate` (new
+ `ReactivateUser` handler + `UserService.ReactivateUserInOrg`) - the
+ only path back to `is_active=true` now, since SSO deliberately never
+ does this itself.
+- [x] `UserService.DeactivateUserInOrg` now revokes every live `auth_tokens`
+ row for that user in the same transaction as the deactivation -
+ without this, a fired user's already-issued JWT kept working until
+ its own expiry regardless of admin action. New `ActionSessionRevoke`
+ (`auth.session.revoke`) audit action.
+- [x] `TokenService.ValidateAccessToken` now filters `is_active = true` on
+ the user lookup, as defense in depth (matching
+ `APIKeyManager.ValidateAPIKey`'s existing behavior) - belt-and-suspenders
+ in case a future deactivation path forgets to revoke tokens directly.
+- [x] Regression set green: `go test ./...` (pre-existing, unrelated
+ failures only - confirmed via `git status` that none of the failing
+ packages were touched), `go test ./internal/auditlog/...` clean.
+
+### Phase 8 — SCIM 2.0 provisioning/deprovisioning — ✅ DONE
+- [x] New `internal/scim` package: bearer-token hashing/store
+ (`token.go`, modeled on `internal/api/api_keys.go`'s scheme) + minimal
+ SCIM 2.0 protocol types (`types.go`) covering what Okta/Azure
+ AD/Google actually use for lifecycle sync.
+- [x] `scim_tokens` table (`db/migrations/20261029150000_create_scim_tokens.sql`)
+ - one static bearer token per org, no user session involved, per the
+ SCIM RFC.
+- [x] `internal/api/scim_handlers.go`: `GET/POST /scim/v2/Users`,
+ `GET/PATCH /scim/v2/Users/:id`, authenticated via
+ `requireSCIMBearerAuth` (resolves `org_id` from the token itself,
+ never a client-supplied id) and gated on the new `"scim"` plan feature
+ (`internal/license/plans.go` + `config/plan_catalog.json`, same
+ pattern as `"sso"`). `PATCH active:false` → `DeactivateUserInOrgBySCIM`
+ (new `UserService` method - same deactivate+revoke-tokens logic as
+ Phase 7, but with `deactivated_by_user_id = NULL` since the actor is
+ the IdP, not a LiveReview user); `active:true` → `ReactivateUserInOrgBySCIM`.
+ Create uses the new `UserService.ProvisionSCIMUser`, which mirrors the
+ SSO JIT-create path (random unusable password, member role) rather
+ than the owner-driven `CreateUserInOrg`, since SCIM provisioning must
+ never send an invitation email.
+- [x] Org-scoped, session-authenticated token management:
+ `GET/POST/DELETE /api/v1/orgs/:org_id/scim-token` (generate, rotate,
+ revoke - plaintext shown once, same reveal-once convention as an API
+ key), gated the same way as `/sso-config`.
+- [x] New audit actions `scim.user.create/update/deactivate/reactivate`,
+ `scim.token.create/revoke` in `internal/auditlog/catalog.go` +
+ `docs/audit-events.md`. SCIM-triggered events use
+ `auditRecorder.Webhook(...)` (no user session to attribute them to -
+ same category as a provider webhook).
+- [x] Settings UI: SCIM Provisioning panel at the bottom of
+ `SSOSettingsTab.tsx` - generate/rotate/revoke the token, shows the
+ SCIM base URL to paste into the IdP's SCIM app config.
+- [x] `npx tsc --noEmit` clean. Build clean, `go test ./internal/auditlog/...`
+ clean.
+
+### Phase 9 — OIDC back-channel logout / SAML SLO — PARTIAL (OIDC done, SAML not shipped)
+- [x] `sso_identities` table
+ (`db/migrations/20261029150100_create_sso_identities.sql`): maps
+ `(org_id, provider_type, subject)` → LiveReview `user_id`, populated
+ on every successful login (`internal/sso/identity_store.go`,
+ `IdentityStore.Upsert` called from `completeSSOLogin`). Needed
+ because an incoming logout event only ever carries the IdP's own
+ subject, never a LiveReview user id.
+- [x] `POST /api/v1/auth/sso/oidc/:org_id/backchannel-logout`
+ (`SSOOIDCBackChannelLogout`): verifies the IdP's `logout_token` JWT
+ the same way an ID token is verified (`sso.VerifyBackChannelLogoutToken`
+ - same issuer/signature/audience/expiry checks via `go-oidc`, plus the
+ OIDC Back-Channel Logout 1.0 `events` claim check), resolves the
+ subject via `sso_identities`, and revokes that user's `auth_tokens`
+ immediately. Per spec, always responds `200` once the token verifies,
+ even if there's nothing to revoke, so the endpoint never leaks account
+ existence.
+- [ ] **SAML Single Logout (SLO) was not implemented.** `crewjam/saml`
+ v0.5.1 only exposes signature verification for the full
+ `ParseResponse`/assertion flow; it has no public primitive for
+ verifying a *signed, IdP-initiated* `LogoutRequest` on its own. Hand-rolling
+ XML-dsig verification for this one message type was judged too high-risk
+ to ship quickly and correctly - accepting an unverified "logout" request
+ would let anyone force-logout a known subject without proving they're
+ the IdP. SAML orgs rely on Phases 7/8 (manual deactivation, SCIM) and
+ normal JWT expiry for this gap; revisit if a later `crewjam/saml`
+ release (or a vetted standalone XML-dsig verifier) exposes a safe
+ primitive for inbound `LogoutRequest` verification.
+- [x] Build clean; `go test ./internal/sso/...` and related packages pass
+ (no e2e back-channel-logout test added yet - would need a Keycloak
+ realm configured to actually send back-channel logout tokens, which
+ isn't wired into the existing `test/fixtures/keycloak/` fixture).
+
+### Phase 10 — Reconciliation poll (fallback for orgs without SCIM) — ✅ DONE
+- [x] `org_sso_configs` gains `reconciliation_enabled`,
+ `reconciliation_interval` (`hourly`/`daily`/`weekly`, default `daily`),
+ `last_reconciled_at`
+ (`db/migrations/20261029150200_add_sso_reconciliation_columns.sql`) -
+ OIDC-only; SAML has no standing credential to silently re-check, so
+ the UI only shows this toggle on the OIDC panel.
+- [x] When enabled, the OIDC auth flow requests the `offline_access` scope
+ (`sso.BuildOIDCOAuth2Config`) and `sso_identities` stores the
+ encrypted refresh token per identity (same AES-GCM box as the client
+ secret).
+- [x] `internal/jobqueue/sso_reconciliation_worker.go`
+ (`SSOReconciliationWorker`, registered as a 15-minute `river.PeriodicJob`
+ named `sso_reconciliation`): queries
+ `sso.Store.ListDueForReconciliation()` for configs whose interval has
+ elapsed, and for each, attempts a silent refresh-token exchange for
+ every stored identity. `invalid_grant`/any refresh failure (what
+ Okta/Azure/Google return for a disabled/deleted/de-authorized account)
+ → `DeactivateUserInOrgBySCIM`, same as a real SCIM deactivation. One
+ coordinator `Work()` call does the per-org fan-out inline, same shape
+ `RiskModelRefitWorker` already uses - no separate one-shot job type
+ needed.
+- [x] Settings UI: "Automatic deprovisioning check" toggle + interval
+ dropdown on the OIDC panel in `SSOSettingsTab.tsx`, shows last-checked
+ time when available.
+- [x] This is the direct answer to "how often does this sync": SCIM
+ (Phase 8) is real-time, push-based - there's no interval for it. This
+ poll's interval is the *maximum extra latency* for OIDC orgs that skip
+ SCIM; Phases 7's session revocation already cuts off a deactivated
+ user's existing session immediately regardless of this job's schedule.
+- [x] Build clean, `go test ./internal/jobqueue/...` and `./internal/sso/...`
+ pass, no import cycle introduced (`internal/jobqueue` importing
+ `internal/api/users` was checked explicitly - no reverse dependency).
+
+**Known gaps after Phases 7-10:** no e2e test harness yet for SCIM, the
+reconciliation poll, or OIDC back-channel logout against a real Keycloak
+(the existing `test/fixtures/keycloak/` realm only covers login); SAML SLO
+is not implemented (see Phase 9). These would be the natural next additions
+if a future session has the budget for them.
diff --git a/internal/api/auth/handlers.go b/internal/api/auth/handlers.go
index c18a643f8..69d410640 100644
--- a/internal/api/auth/handlers.go
+++ b/internal/api/auth/handlers.go
@@ -33,8 +33,9 @@ type AuthHandlers struct {
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
+ sso *sso.Store
+ ssoState *sso.StateSigner
+ ssoIdentities *sso.IdentityStore
}
// NewAuthHandlers creates a new authentication handlers instance
diff --git a/internal/api/auth/sso_handlers.go b/internal/api/auth/sso_handlers.go
index 43f1bd721..ca6598414 100644
--- a/internal/api/auth/sso_handlers.go
+++ b/internal/api/auth/sso_handlers.go
@@ -5,6 +5,7 @@ import (
"database/sql"
"encoding/hex"
"encoding/xml"
+ "errors"
"fmt"
"log"
"net/http"
@@ -19,6 +20,11 @@ import (
"golang.org/x/crypto/bcrypt"
)
+// errSSOAccountDeactivated is returned by jitProvisionSSOUser when the
+// matched user has been deactivated. SSO login must never resurrect a
+// deactivated account - that's an explicit admin action only.
+var errSSOAccountDeactivated = errors.New("sso: account is deactivated")
+
// 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.
@@ -27,6 +33,13 @@ func (h *AuthHandlers) SetSSO(store *sso.Store, jwtSecret string) {
h.ssoState = sso.NewStateSigner(jwtSecret)
}
+// SetSSOIdentities wires the subject->user identity store in, used to
+// resolve incoming OIDC back-channel logout tokens / SAML LogoutRequests to
+// a LiveReview user (Phase 4 of docs/sso.md's deprovisioning section).
+func (h *AuthHandlers) SetSSOIdentities(store *sso.IdentityStore) {
+ h.ssoIdentities = store
+}
+
// 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
@@ -159,6 +172,59 @@ func (h *AuthHandlers) SSOOIDCCallback(c echo.Context) error {
return h.completeSSOLogin(c, orgID, identity, "sso_oidc")
}
+// SSOOIDCBackChannelLogout is public: POST /api/v1/auth/sso/oidc/:org_id/backchannel-logout.
+// Per OIDC Back-Channel Logout 1.0, the IdP calls this directly (no
+// browser involved) when a user logs out or is disabled at the IdP, so
+// LiveReview can revoke that user's sessions in near real time rather than
+// waiting for their JWT to expire or the next SCIM/reconciliation sweep.
+// Best-effort and intentionally quiet on any resolution failure (unknown
+// subject, SSO not configured, etc.) - per spec, this endpoint must not
+// leak account existence, so it always responds 200 once the token itself
+// verifies, even if there's nothing on LiveReview's side to revoke.
+func (h *AuthHandlers) SSOOIDCBackChannelLogout(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.ProviderOIDC)
+ if err != nil || !cfg.Enabled {
+ return c.String(http.StatusNotFound, "sso not configured")
+ }
+ logoutToken := c.Request().PostFormValue("logout_token")
+ if logoutToken == "" {
+ return c.String(http.StatusBadRequest, "missing logout_token")
+ }
+ subject, err := sso.VerifyBackChannelLogoutToken(c.Request().Context(), cfg, logoutToken)
+ if err != nil {
+ log.Printf("[sso] back-channel logout token verification failed for org %d: %v", orgID, err)
+ return c.String(http.StatusBadRequest, "invalid logout_token")
+ }
+ if h.ssoIdentities == nil {
+ return c.NoContent(http.StatusOK)
+ }
+ userID, err := h.ssoIdentities.LookupUserBySubject(orgID, sso.ProviderOIDC, subject)
+ if err != nil {
+ return c.NoContent(http.StatusOK)
+ }
+ if _, err := h.db.Exec(`UPDATE auth_tokens SET is_active = false, revoked_at = NOW() WHERE user_id = $1 AND is_active = true`, userID); err != nil {
+ log.Printf("[sso] back-channel logout: failed to revoke sessions for user %d: %v", userID, err)
+ return c.NoContent(http.StatusOK)
+ }
+ h.recordAuthEvent(c, auditlog.Event{
+ OrgID: orgID,
+ ActorUserID: &userID,
+ ActorKind: auditlog.ActorKindMember,
+ Action: auditlog.ActionSessionRevoke,
+ Outcome: auditlog.OutcomeSuccess,
+ Severity: auditlog.SeverityWarning,
+ Details: auditlog.Details("method", "sso_oidc_backchannel_logout"),
+ })
+ return c.NoContent(http.StatusOK)
+}
+
// 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) {
@@ -284,7 +350,12 @@ func (h *AuthHandlers) completeSSOLogin(c echo.Context, orgID int64, identity *s
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)
+ reason := "provisioning_failed"
+ if errors.Is(err, errSSOAccountDeactivated) {
+ reason = "account_deactivated"
+ } else {
+ log.Printf("[sso] JIT provisioning failed for %s in org %d: %v", email, orgID, err)
+ }
h.recordAuthEvent(c, auditlog.Event{
OrgID: orgID,
ActorEmail: email,
@@ -292,9 +363,9 @@ func (h *AuthHandlers) completeSSOLogin(c echo.Context, orgID int64, identity *s
Action: auditlog.ActionLoginFailure,
Outcome: auditlog.OutcomeFailure,
Severity: auditlog.SeverityWarning,
- Details: auditlog.Details("method", method, "reason", "provisioning_failed"),
+ Details: auditlog.Details("method", method, "reason", reason),
})
- return ssoFailureRedirect(c, "provisioning_failed")
+ return ssoFailureRedirect(c, reason)
}
userAgent := c.Request().Header.Get("User-Agent")
@@ -307,6 +378,19 @@ func (h *AuthHandlers) completeSSOLogin(c echo.Context, orgID int64, identity *s
_, _ = h.db.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
+ if h.ssoIdentities != nil && identity.Subject != "" {
+ provider := sso.ProviderOIDC
+ if method == "sso_saml" {
+ provider = sso.ProviderSAML
+ }
+ if err := h.ssoIdentities.Upsert(orgID, user.ID, provider, identity.Subject, identity.RefreshToken); err != nil {
+ // Non-fatal: this only degrades back-channel logout resolution
+ // and the reconciliation poll for this login, never the login
+ // itself.
+ log.Printf("[sso] failed to record identity mapping for org %d user %d: %v", orgID, user.ID, err)
+ }
+ }
+
uid := user.ID
actorKind, actorCapacity := h.actorFor(user)
if created {
@@ -352,6 +436,7 @@ func (h *AuthHandlers) completeSSOLogin(c echo.Context, orgID int64, identity *s
// login path until an owner sets one).
func (h *AuthHandlers) jitProvisionSSOUser(orgID int64, email, displayName string) (*models.User, bool, error) {
var userID int64
+ var isActive bool
created := false
err := func() error {
@@ -361,7 +446,7 @@ func (h *AuthHandlers) jitProvisionSSOUser(orgID int64, email, displayName strin
}
defer tx.Rollback()
- err = tx.QueryRow(`SELECT id FROM users WHERE email = $1`, email).Scan(&userID)
+ err = tx.QueryRow(`SELECT id, is_active FROM users WHERE email = $1`, email).Scan(&userID, &isActive)
if err == sql.ErrNoRows {
randBytes := make([]byte, 32)
if _, rerr := rand.Read(randBytes); rerr != nil {
@@ -389,6 +474,12 @@ func (h *AuthHandlers) jitProvisionSSOUser(orgID int64, email, displayName strin
} else if err != nil {
return err
} else {
+ if !isActive {
+ // Deactivation is an explicit admin action; SSO login must
+ // never silently undo it. Reject here rather than
+ // reactivating - see errSSOAccountDeactivated.
+ return errSSOAccountDeactivated
+ }
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
@@ -402,7 +493,7 @@ func (h *AuthHandlers) jitProvisionSSOUser(orgID int64, email, displayName strin
}
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 {
+ if _, err := tx.Exec(`UPDATE users SET default_org_id = COALESCE(default_org_id, $1) WHERE id = $2`, orgID, userID); err != nil {
return err
}
}
diff --git a/internal/api/auth/token_service.go b/internal/api/auth/token_service.go
index 65bdbc5f3..93e6ce499 100644
--- a/internal/api/auth/token_service.go
+++ b/internal/api/auth/token_service.go
@@ -236,11 +236,15 @@ func (ts *TokenService) ValidateAccessToken(tokenString string) (*models.User, e
fmt.Printf("Warning: failed to update last_used_at: %v\n", err)
}
- // Get user details
+ // Get user details. The is_active filter is defense in depth: deactivation
+ // (internal/api/users/user_service.go's DeactivateUserInOrg) already
+ // revokes this user's auth_tokens rows directly, but this guards against
+ // any future deactivation path that forgets to, same as
+ // APIKeyManager.ValidateAPIKey already does for API keys.
user := &models.User{}
err = ts.db.QueryRow(`
SELECT id, email, password_hash, default_org_id, created_at, updated_at
- FROM users WHERE id = $1
+ FROM users WHERE id = $1 AND is_active = true
`, claims.UserID).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.DefaultOrgID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
diff --git a/internal/api/scim_handlers.go b/internal/api/scim_handlers.go
new file mode 100644
index 000000000..eb9159c12
--- /dev/null
+++ b/internal/api/scim_handlers.go
@@ -0,0 +1,410 @@
+package api
+
+import (
+ "database/sql"
+ "errors"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/labstack/echo/v4"
+ "github.com/livereview/internal/api/auth"
+ apimiddleware "github.com/livereview/internal/api/middleware"
+ "github.com/livereview/internal/api/users"
+ "github.com/livereview/internal/auditlog"
+ "github.com/livereview/internal/license"
+ "github.com/livereview/internal/scim"
+)
+
+// orgHasSCIMFeature mirrors orgHasSSOFeature (internal/api/sso_config.go) -
+// checks the org's own plan rather than a hardcoded self-hosted bit, so
+// adding "scim" to a cloud plan's Features later needs no code change here.
+func (s *Server) orgHasSCIMFeature(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("scim")
+}
+
+// requireSCIMBearerAuth authenticates SCIM clients (Okta, Azure AD, Google
+// Workspace) via their org's static bearer token - there is no user
+// session on this path, per the SCIM 2.0 RFC. On success it sets
+// "scim_org_id" in the echo context for the handlers below.
+func (s *Server) requireSCIMBearerAuth(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(c echo.Context) error {
+ authHeader := c.Request().Header.Get("Authorization")
+ const prefix = "Bearer "
+ if !strings.HasPrefix(authHeader, prefix) {
+ return c.JSON(http.StatusUnauthorized, scim.NewError(http.StatusUnauthorized, "missing bearer token"))
+ }
+ token := strings.TrimSpace(strings.TrimPrefix(authHeader, prefix))
+ orgID, err := s.scimTokens.Authenticate(token)
+ if err != nil {
+ return c.JSON(http.StatusUnauthorized, scim.NewError(http.StatusUnauthorized, "invalid, expired, or revoked token"))
+ }
+ if !s.orgHasSCIMFeature(orgID) {
+ return c.JSON(http.StatusForbidden, scim.NewError(http.StatusForbidden, "SCIM requires an enterprise self-hosted license"))
+ }
+ c.Set("scim_org_id", orgID)
+ return next(c)
+ }
+}
+
+func scimOrgID(c echo.Context) int64 {
+ return c.Get("scim_org_id").(int64)
+}
+
+// scimResourceFromUser converts an internal user record into the SCIM
+// shape IdPs expect.
+func scimResourceFromUser(u *users.UserWithRole) scim.UserResource {
+ first, last := "", ""
+ if u.FirstName != nil {
+ first = *u.FirstName
+ }
+ if u.LastName != nil {
+ last = *u.LastName
+ }
+ return scim.UserResource{
+ Schemas: []string{scim.SchemaUser},
+ ID: strconv.FormatInt(u.ID, 10),
+ UserName: u.Email,
+ Name: &scim.NameResource{GivenName: first, FamilyName: last},
+ Emails: []scim.EmailResource{{Value: u.Email, Primary: true}},
+ Active: u.IsActive,
+ Meta: &scim.MetaResource{
+ ResourceType: "User",
+ Created: u.CreatedAt.Format(time.RFC3339),
+ LastModified: u.UpdatedAt.Format(time.RFC3339),
+ },
+ }
+}
+
+// primaryEmail extracts the identifying email from a SCIM UserResource -
+// IdPs vary on whether they populate userName or emails[] with the address
+// that matters, so this checks both.
+func primaryEmail(r scim.UserResource) string {
+ for _, e := range r.Emails {
+ if e.Primary && e.Value != "" {
+ return strings.ToLower(strings.TrimSpace(e.Value))
+ }
+ }
+ if len(r.Emails) > 0 && r.Emails[0].Value != "" {
+ return strings.ToLower(strings.TrimSpace(r.Emails[0].Value))
+ }
+ if strings.Contains(r.UserName, "@") {
+ return strings.ToLower(strings.TrimSpace(r.UserName))
+ }
+ return ""
+}
+
+// parseEmailFilter extracts the target email from the simple SCIM filter
+// expressions IdPs actually send when checking "does this user already
+// exist" before creating - userName eq "x@y.com" or emails.value eq
+// "x@y.com". Full SCIM filter grammar (and/or/not, other attributes) is not
+// needed for this use case and isn't parsed.
+func parseEmailFilter(filter string) string {
+ filter = strings.TrimSpace(filter)
+ for _, attr := range []string{"userName", "emails.value", "emails"} {
+ prefix := attr + " eq "
+ if idx := strings.Index(filter, prefix); idx != -1 {
+ rest := strings.TrimSpace(filter[idx+len(prefix):])
+ rest = strings.Trim(rest, `"`)
+ return strings.ToLower(rest)
+ }
+ }
+ return ""
+}
+
+// SCIMListUsers handles GET /scim/v2/Users. IdPs call this both to page
+// through the directory and, far more commonly, with a `filter` to check
+// whether a user already exists before POSTing a create.
+func (s *Server) SCIMListUsers(c echo.Context) error {
+ orgID := scimOrgID(c)
+ email := parseEmailFilter(c.QueryParam("filter"))
+
+ type row struct {
+ id int64
+ email string
+ isActive bool
+ firstName, lastName sql.NullString
+ createdAt, updatedAt time.Time
+ }
+ var rows []row
+
+ if email != "" {
+ var r row
+ err := s.db.QueryRow(`
+ SELECT u.id, u.email, u.is_active, u.first_name, u.last_name, u.created_at, u.updated_at
+ FROM users u JOIN user_roles ur ON ur.user_id = u.id
+ WHERE u.email = $1 AND ur.org_id = $2
+ `, email, orgID).Scan(&r.id, &r.email, &r.isActive, &r.firstName, &r.lastName, &r.createdAt, &r.updatedAt)
+ if err == nil {
+ rows = append(rows, r)
+ } else if err != sql.ErrNoRows {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to query users"))
+ }
+ } else {
+ sqlRows, err := s.db.Query(`
+ SELECT u.id, u.email, u.is_active, u.first_name, u.last_name, u.created_at, u.updated_at
+ FROM users u JOIN user_roles ur ON ur.user_id = u.id
+ WHERE ur.org_id = $1 ORDER BY u.id LIMIT 200
+ `, orgID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to query users"))
+ }
+ defer sqlRows.Close()
+ for sqlRows.Next() {
+ var r row
+ if err := sqlRows.Scan(&r.id, &r.email, &r.isActive, &r.firstName, &r.lastName, &r.createdAt, &r.updatedAt); err != nil {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to read users"))
+ }
+ rows = append(rows, r)
+ }
+ }
+
+ resources := make([]scim.UserResource, 0, len(rows))
+ for _, r := range rows {
+ first, last := "", ""
+ if r.firstName.Valid {
+ first = r.firstName.String
+ }
+ if r.lastName.Valid {
+ last = r.lastName.String
+ }
+ resources = append(resources, scim.UserResource{
+ Schemas: []string{scim.SchemaUser},
+ ID: strconv.FormatInt(r.id, 10),
+ UserName: r.email,
+ Name: &scim.NameResource{GivenName: first, FamilyName: last},
+ Emails: []scim.EmailResource{{Value: r.email, Primary: true}},
+ Active: r.isActive,
+ Meta: &scim.MetaResource{
+ ResourceType: "User",
+ Created: r.createdAt.Format(time.RFC3339),
+ LastModified: r.updatedAt.Format(time.RFC3339),
+ },
+ })
+ }
+
+ return c.JSON(http.StatusOK, scim.ListResponse{
+ Schemas: []string{scim.SchemaListResponse},
+ TotalResults: len(resources),
+ ItemsPerPage: len(resources),
+ StartIndex: 1,
+ Resources: resources,
+ })
+}
+
+// SCIMCreateUser handles POST /scim/v2/Users.
+func (s *Server) SCIMCreateUser(c echo.Context) error {
+ orgID := scimOrgID(c)
+
+ var req scim.UserResource
+ if err := c.Bind(&req); err != nil {
+ return c.JSON(http.StatusBadRequest, scim.NewError(http.StatusBadRequest, "invalid request body"))
+ }
+ email := primaryEmail(req)
+ if email == "" {
+ return c.JSON(http.StatusBadRequest, scim.NewError(http.StatusBadRequest, "userName or emails is required"))
+ }
+ first, last := "", ""
+ if req.Name != nil {
+ first, last = req.Name.GivenName, req.Name.FamilyName
+ }
+
+ user, created, err := s.userService.ProvisionSCIMUser(orgID, email, first, last)
+ if err != nil {
+ if errors.Is(err, users.ErrSCIMAccountDeactivated) {
+ return c.JSON(http.StatusConflict, scim.NewError(http.StatusConflict, "this account was deactivated by an organization admin"))
+ }
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to provision user"))
+ }
+ if created && s.auditRecorder != nil {
+ _ = s.auditRecorder.Webhook(c.Request().Context(), orgID, auditlog.ActionSCIMUserCreate, auditlog.Details("email", email))
+ }
+ return c.JSON(http.StatusCreated, scimResourceFromUser(user))
+}
+
+// SCIMGetUser handles GET /scim/v2/Users/:id.
+func (s *Server) SCIMGetUser(c echo.Context) error {
+ orgID := scimOrgID(c)
+ userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, scim.NewError(http.StatusBadRequest, "invalid id"))
+ }
+ user, err := s.userService.GetUserInOrg(orgID, userID)
+ if err != nil {
+ return c.JSON(http.StatusNotFound, scim.NewError(http.StatusNotFound, "user not found"))
+ }
+ return c.JSON(http.StatusOK, scimResourceFromUser(user))
+}
+
+// SCIMPatchUser handles PATCH /scim/v2/Users/:id - the operation that
+// matters most for offboarding: IdPs send
+// {"Operations":[{"op":"replace","value":{"active":false}}]} to deprovision.
+func (s *Server) SCIMPatchUser(c echo.Context) error {
+ orgID := scimOrgID(c)
+ userID, err := strconv.ParseInt(c.Param("id"), 10, 64)
+ if err != nil {
+ return c.JSON(http.StatusBadRequest, scim.NewError(http.StatusBadRequest, "invalid id"))
+ }
+ if _, err := s.userService.GetUserInOrg(orgID, userID); err != nil {
+ return c.JSON(http.StatusNotFound, scim.NewError(http.StatusNotFound, "user not found"))
+ }
+
+ var req scim.PatchRequest
+ if err := c.Bind(&req); err != nil {
+ return c.JSON(http.StatusBadRequest, scim.NewError(http.StatusBadRequest, "invalid request body"))
+ }
+
+ for _, op := range req.Operations {
+ active, ok := patchActiveValue(op)
+ if !ok {
+ continue
+ }
+ if active {
+ if err := s.userService.ReactivateUserInOrgBySCIM(userID); err != nil {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to reactivate user"))
+ }
+ if s.auditRecorder != nil {
+ _ = s.auditRecorder.Webhook(c.Request().Context(), orgID, auditlog.ActionSCIMUserReactivate, auditlog.Details("user_id", userID))
+ }
+ } else {
+ if err := s.userService.DeactivateUserInOrgBySCIM(userID); err != nil {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to deactivate user"))
+ }
+ if s.auditRecorder != nil {
+ _ = s.auditRecorder.Webhook(c.Request().Context(), orgID, auditlog.ActionSCIMUserDeactivate, auditlog.Details("user_id", userID))
+ }
+ }
+ }
+
+ user, err := s.userService.GetUserInOrg(orgID, userID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, scim.NewError(http.StatusInternalServerError, "failed to reload user"))
+ }
+ return c.JSON(http.StatusOK, scimResourceFromUser(user))
+}
+
+// patchActiveValue reports whether op sets the SCIM "active" attribute,
+// and what it was set to. Handles both the common {"active": false}
+// whole-object replace and the path-qualified {"path":"active","value":false} form.
+func patchActiveValue(op scim.PatchOperation) (active bool, matched bool) {
+ if !strings.EqualFold(op.Op, "replace") {
+ return false, false
+ }
+ if strings.EqualFold(op.Path, "active") {
+ if b, ok := op.Value.(bool); ok {
+ return b, true
+ }
+ return false, false
+ }
+ if op.Path == "" {
+ if m, ok := op.Value.(map[string]any); ok {
+ if b, ok := m["active"].(bool); ok {
+ return b, true
+ }
+ }
+ }
+ return false, false
+}
+
+// --- SCIM token management (session-authenticated, owner-only) ---
+
+// requireSCIMConfigAccess mirrors requireSSOAccess (internal/api/sso_config.go):
+// Sensitive Operations Gate + Session-Only Gating + self-hosted-enterprise
+// feature check, for the org's SCIM token itself.
+func (s *Server) requireSCIMConfigAccess(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 SCIM provisioning"})
+ }
+ if err := pc.RequireOrgOwner(); err != nil {
+ return c.JSON(http.StatusForbidden, map[string]string{"error": "only org owners can manage SCIM provisioning"})
+ }
+ if apimiddleware.IsCloudMode() {
+ return c.JSON(http.StatusForbidden, map[string]string{"error": "SCIM is available on self-hosted enterprise deployments only"})
+ }
+ if !s.orgHasSCIMFeature(orgID) {
+ return c.JSON(http.StatusForbidden, map[string]string{"error": "SCIM requires an enterprise self-hosted license"})
+ }
+ return nil
+}
+
+type scimTokenResponse struct {
+ TokenPrefix string `json:"token_prefix"`
+ CreatedAt string `json:"created_at"`
+ Token *string `json:"token,omitempty"` // only populated once, on creation
+}
+
+// GetSCIMToken is GET /orgs/:org_id/scim-token - returns the existing
+// token's metadata (never the plaintext, which is only ever shown at
+// creation time).
+func (s *Server) GetSCIMToken(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.requireSCIMConfigAccess(c, orgID); err != nil {
+ return err
+ }
+ tok, err := s.scimTokens.Get(orgID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to load SCIM token"})
+ }
+ if tok == nil {
+ return c.JSON(http.StatusOK, map[string]any{"exists": false})
+ }
+ return c.JSON(http.StatusOK, scimTokenResponse{TokenPrefix: tok.TokenPrefix, CreatedAt: tok.CreatedAt.Format(time.RFC3339)})
+}
+
+// CreateSCIMToken is POST /orgs/:org_id/scim-token - generates a new token,
+// replacing (rotating out) any existing one. Registered via s.audited with
+// ActionSCIMTokenCreate.
+func (s *Server) CreateSCIMToken(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.requireSCIMConfigAccess(c, orgID); err != nil {
+ return err
+ }
+ pc := auth.GetPermissionContext(c)
+ var actorUserID int64
+ if pc != nil && pc.User != nil {
+ actorUserID = pc.User.ID
+ }
+
+ if err := s.scimTokens.Revoke(orgID); err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to rotate SCIM token"})
+ }
+ plaintext, tok, err := s.scimTokens.Create(orgID, actorUserID)
+ if err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create SCIM token"})
+ }
+ auditlog.MarkRecorded(c)
+ return c.JSON(http.StatusOK, scimTokenResponse{
+ TokenPrefix: tok.TokenPrefix,
+ CreatedAt: tok.CreatedAt.Format(time.RFC3339),
+ Token: &plaintext,
+ })
+}
+
+// RevokeSCIMToken is DELETE /orgs/:org_id/scim-token.
+func (s *Server) RevokeSCIMToken(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.requireSCIMConfigAccess(c, orgID); err != nil {
+ return err
+ }
+ if err := s.scimTokens.Revoke(orgID); err != nil {
+ return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to revoke SCIM token"})
+ }
+ auditlog.MarkRecorded(c)
+ return c.JSON(http.StatusOK, map[string]string{"message": "SCIM token revoked"})
+}
diff --git a/internal/api/server.go b/internal/api/server.go
index 6207891fa..429e20dfe 100644
--- a/internal/api/server.go
+++ b/internal/api/server.go
@@ -49,6 +49,7 @@ import (
"github.com/livereview/internal/providers/azuredevops"
reviewprocessor "github.com/livereview/internal/review_processor"
"github.com/livereview/internal/scheduledreview"
+ "github.com/livereview/internal/scim"
"github.com/livereview/internal/slackbot"
"github.com/livereview/internal/sso"
"github.com/livereview/internal/teamsbot"
@@ -149,7 +150,8 @@ 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)
+ ssoStore *sso.Store // nil only if the secret box failed to init (see SetSSO wiring)
+ scimTokens *scim.TokenStore
githubApp *githubapp.Config // nil unless GITHUB_APP_* is configured
githubAppSecret []byte // HMAC key for the GitHub App OAuth state
tokenService *auth.TokenService
@@ -326,11 +328,14 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) {
// 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
+ var ssoIdentities *sso.IdentityStore
if ssoBox, err := sso.NewSecretBox(jwtSecret); err != nil {
log.Printf("[sso] disabled: %v", err)
} else {
ssoStore = sso.NewStore(db, ssoBox)
authHandlers.SetSSO(ssoStore, jwtSecret)
+ ssoIdentities = sso.NewIdentityStore(db, ssoBox)
+ authHandlers.SetSSOIdentities(ssoIdentities)
}
// Audit trail framework: recorder + route-spec registry. The HTTP
@@ -392,6 +397,7 @@ func appContext(port int, versionInfo *VersionInfo) (*Server, error) {
deploymentConfig: deploymentConfig,
authHandlers: authHandlers,
ssoStore: ssoStore,
+ scimTokens: scim.NewTokenStore(db),
githubApp: githubapp.Load(),
githubAppSecret: []byte(jwtSecret),
tokenService: tokenService,
@@ -1173,10 +1179,23 @@ func (s *Server) setupRoutes() {
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.POST("/auth/sso/oidc/:org_id/backchannel-logout", s.authHandlers.SSOOIDCBackChannelLogout)
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)
+ // SCIM 2.0 (Okta/Azure AD/Google Workspace user lifecycle sync) - see
+ // docs/sso.md. Public route group by necessity (SCIM clients authenticate
+ // via their own org-scoped bearer token, not a LiveReview session);
+ // org-scoping comes from that token alone, enforced inside
+ // requireSCIMBearerAuth, never from a client-supplied id.
+ scimGroup := public.Group("/scim/v2")
+ scimGroup.Use(s.requireSCIMBearerAuth)
+ scimGroup.GET("/Users", s.SCIMListUsers)
+ scimGroup.POST("/Users", s.SCIMCreateUser)
+ scimGroup.GET("/Users/:id", s.SCIMGetUser)
+ scimGroup.PATCH("/Users/:id", s.SCIMPatchUser)
+
// 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
@@ -1331,6 +1350,7 @@ func (s *Server) setupRoutes() {
orgGroup.GET("/users/:user_id", s.userHandlers.GetUser)
s.audited(orgGroup, echo.PUT, "/users/:user_id", auditlog.Spec{Action: auditlog.ActionUserUpdate, Category: auditlog.CategoryUser, ResourceType: "user", ResourceIDParam: "user_id", Sensitive: true}, s.userHandlers.UpdateUser)
s.audited(orgGroup, echo.DELETE, "/users/:user_id", auditlog.Spec{Action: auditlog.ActionUserDeactivate, Category: auditlog.CategoryUser, ResourceType: "user", ResourceIDParam: "user_id", Sensitive: true}, s.userHandlers.DeactivateUser)
+ s.audited(orgGroup, echo.POST, "/users/:user_id/reactivate", auditlog.Spec{Action: auditlog.ActionUserReactivate, Category: auditlog.CategoryUser, ResourceType: "user", ResourceIDParam: "user_id", Sensitive: true}, s.userHandlers.ReactivateUser)
s.audited(orgGroup, echo.PUT, "/users/:user_id/role", auditlog.Spec{Action: auditlog.ActionUserRoleChange, Category: auditlog.CategoryUser, ResourceType: "user", ResourceIDParam: "user_id", Sensitive: true}, s.userHandlers.ChangeUserRole)
s.audited(orgGroup, echo.POST, "/users/:user_id/force-password-reset", auditlog.Spec{Action: auditlog.ActionPasswordForceReset, Category: auditlog.CategoryUser, ResourceType: "user", ResourceIDParam: "user_id", Sensitive: true}, s.userHandlers.ForcePasswordReset)
orgGroup.GET("/users/:user_id/audit-log", s.userHandlers.GetUserAuditLog)
@@ -1350,6 +1370,9 @@ func (s *Server) setupRoutes() {
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)
+ orgGroup.GET("/scim-token", s.GetSCIMToken)
+ s.audited(orgGroup, echo.POST, "/scim-token", auditlog.Spec{Action: auditlog.ActionSCIMTokenCreate, Category: auditlog.CategoryAuth, ResourceType: "scim_token", Sensitive: true}, s.CreateSCIMToken)
+ s.audited(orgGroup, echo.DELETE, "/scim-token", auditlog.Spec{Action: auditlog.ActionSCIMTokenRevoke, Category: auditlog.CategoryAuth, ResourceType: "scim_token", Sensitive: true}, s.RevokeSCIMToken)
// Super admin routes - Production
adminGroup := v1.Group("/admin")
diff --git a/internal/api/sso_config.go b/internal/api/sso_config.go
index ee816e291..3179881c7 100644
--- a/internal/api/sso_config.go
+++ b/internal/api/sso_config.go
@@ -39,6 +39,11 @@ type ssoConfigRequest struct {
SAMLIDPMetadataURL string `json:"saml_idp_metadata_url,omitempty"`
SAMLIDPMetadataXML string `json:"saml_idp_metadata_xml,omitempty"`
SAMLIDPEntityID string `json:"saml_idp_entity_id,omitempty"`
+
+ // Reconciliation only applies to provider_type=oidc - see Phase 5 of
+ // docs/sso.md's deprovisioning section. Ignored for SAML.
+ ReconciliationEnabled bool `json:"reconciliation_enabled,omitempty"`
+ ReconciliationInterval string `json:"reconciliation_interval,omitempty"`
}
// ssoConfigResponse never carries secrets - this is what GET/PUT return.
@@ -53,21 +58,31 @@ type ssoConfigResponse struct {
SAMLIDPMetadataURL string `json:"saml_idp_metadata_url,omitempty"`
SAMLIDPEntityID string `json:"saml_idp_entity_id,omitempty"`
SAMLMetadataSet bool `json:"saml_metadata_set"`
+
+ ReconciliationEnabled bool `json:"reconciliation_enabled,omitempty"`
+ ReconciliationInterval string `json:"reconciliation_interval,omitempty"`
+ LastReconciledAt string `json:"last_reconciled_at,omitempty"`
}
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 != "",
- }
+ resp := 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 != "",
+ ReconciliationEnabled: cfg.ReconciliationEnabled,
+ ReconciliationInterval: cfg.ReconciliationInterval,
+ }
+ if cfg.LastReconciledAt != nil {
+ resp.LastReconciledAt = cfg.LastReconciledAt.Format(time.RFC3339)
+ }
+ return resp
}
func parseProviderType(raw string) (sso.ProviderType, bool) {
@@ -148,21 +163,26 @@ func (s *Server) UpsertSSOConfig(c echo.Context) error {
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid request body"})
}
+ if req.ReconciliationInterval != "" && req.ReconciliationInterval != "hourly" && req.ReconciliationInterval != "daily" && req.ReconciliationInterval != "weekly" {
+ return c.JSON(http.StatusBadRequest, map[string]string{"error": "reconciliation_interval must be 'hourly', 'daily', or 'weekly'"})
+ }
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,
+ 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,
+ ReconciliationEnabled: provider == sso.ProviderOIDC && req.ReconciliationEnabled,
+ ReconciliationInterval: req.ReconciliationInterval,
}
// Preserve the existing secret/metadata unless the caller explicitly
diff --git a/internal/api/users/handlers.go b/internal/api/users/handlers.go
index fe7a55c58..5e3e41337 100644
--- a/internal/api/users/handlers.go
+++ b/internal/api/users/handlers.go
@@ -313,6 +313,33 @@ func (uh *UserHandlers) DeactivateUser(c echo.Context) error {
})
}
+// ReactivateUser handles restoring a previously deactivated user in an
+// organization. This is the only way a deactivated user becomes active
+// again - SSO login deliberately never does this on its own.
+func (uh *UserHandlers) ReactivateUser(c echo.Context) error {
+ permCtx := auth.MustGetPermissionContext(c)
+
+ if !permCtx.HasPermission(auth.PermissionDeleteUsers) {
+ return echo.NewHTTPError(http.StatusForbidden, "Permission denied: cannot reactivate users")
+ }
+
+ userIDStr := c.Param("user_id")
+ userID, err := strconv.ParseInt(userIDStr, 10, 64)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusBadRequest, "Invalid user ID")
+ }
+
+ err = uh.userService.ReactivateUserInOrg(permCtx.OrgID, userID, permCtx.User.ID)
+ if err != nil {
+ return echo.NewHTTPError(http.StatusInternalServerError, "Failed to reactivate user")
+ }
+ auditlog.MarkRecorded(c)
+
+ return c.JSON(http.StatusOK, map[string]string{
+ "message": "User reactivated successfully",
+ })
+}
+
// ChangeUserRole handles changing a user's role in an organization
func (uh *UserHandlers) ChangeUserRole(c echo.Context) error {
// Get permission context from middleware
diff --git a/internal/api/users/user_service.go b/internal/api/users/user_service.go
index eb59b836e..b0f59a648 100644
--- a/internal/api/users/user_service.go
+++ b/internal/api/users/user_service.go
@@ -2,7 +2,9 @@ package users
import (
"context"
+ "crypto/rand"
"database/sql"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -968,12 +970,16 @@ func (us *UserService) UpdateUserInOrg(orgID, userID, updatedByUserID int64, req
return us.GetUserInOrg(orgID, userID)
}
-// DeactivateUserInOrg deactivates a user in a specific organization
+// DeactivateUserInOrg deactivates a user in a specific organization and
+// revokes every live session/refresh token they currently hold. Without the
+// revocation, a deactivated user's already-issued JWT would keep working
+// until its own natural expiry - deactivation needs to cut off access
+// immediately, not just block future logins.
func (us *UserService) DeactivateUserInOrg(orgID, userID, deactivatedByUserID int64) error {
return us.store.WithTx(func(tx *sql.Tx) error {
// Deactivate user
_, err := us.store.TxExec(tx, `
- UPDATE users
+ UPDATE users
SET is_active = false, deactivated_at = NOW(), deactivated_by_user_id = $1, updated_at = NOW()
WHERE id = $2
`, deactivatedByUserID, userID)
@@ -981,14 +987,177 @@ func (us *UserService) DeactivateUserInOrg(orgID, userID, deactivatedByUserID in
return fmt.Errorf("failed to deactivate user: %w", err)
}
+ revokedCount, err := us.store.TxExec(tx, `
+ UPDATE auth_tokens SET is_active = false, revoked_at = NOW()
+ WHERE user_id = $1 AND is_active = true
+ `, userID)
+ if err != nil {
+ return fmt.Errorf("failed to revoke active sessions: %w", err)
+ }
+
// Add audit log
err = us.addUserAuditLog(tx, orgID, userID, deactivatedByUserID, "deactivated", map[string]interface{}{})
if err != nil {
return fmt.Errorf("failed to add audit log: %w", err)
}
+ if rows, rerr := revokedCount.RowsAffected(); rerr == nil && rows > 0 {
+ if aerr := us.addUserAuditLog(tx, orgID, userID, deactivatedByUserID, "session_revoked", map[string]interface{}{"revoked_count": rows}); aerr != nil {
+ return fmt.Errorf("failed to add session revocation audit log: %w", aerr)
+ }
+ }
+
+ return nil
+ })
+}
+
+// ReactivateUserInOrg restores a previously deactivated user. This is the
+// only path back to is_active=true - SSO login deliberately never does this
+// itself (see errSSOAccountDeactivated in internal/api/auth/sso_handlers.go).
+func (us *UserService) ReactivateUserInOrg(orgID, userID, reactivatedByUserID int64) error {
+ return us.store.WithTx(func(tx *sql.Tx) error {
+ _, err := us.store.TxExec(tx, `
+ UPDATE users
+ SET is_active = true, deactivated_at = NULL, deactivated_by_user_id = NULL, updated_at = NOW()
+ WHERE id = $1
+ `, userID)
+ if err != nil {
+ return fmt.Errorf("failed to reactivate user: %w", err)
+ }
+
+ if err := us.addUserAuditLog(tx, orgID, userID, reactivatedByUserID, "reactivated", map[string]interface{}{}); err != nil {
+ return fmt.Errorf("failed to add audit log: %w", err)
+ }
+
+ return nil
+ })
+}
+
+// ErrSCIMAccountDeactivated is returned by ProvisionSCIMUser when the
+// matched user has been manually deactivated. Like SSO JIT login, SCIM
+// provisioning must never silently resurrect a deactivated account.
+var ErrSCIMAccountDeactivated = errors.New("scim: account is deactivated")
+
+// ProvisionSCIMUser creates or links a user into orgID via SCIM. It mirrors
+// the SSO JIT-provisioning create path (internal/api/auth/sso_handlers.go's
+// jitProvisionSSOUser): a random unusable password and member role, with no
+// invitation email - the IdP is the source of truth for this account, not
+// an email invite, which is why this doesn't just call the owner-driven
+// CreateUserInOrg above. Returns the user and whether a new user_roles row
+// was created (true also covers "newly created user" since the row is
+// always new in that case).
+func (us *UserService) ProvisionSCIMUser(orgID int64, email, firstName, lastName string) (*UserWithRole, bool, error) {
+ var userID int64
+ created := false
+
+ err := us.store.WithTx(func(tx *sql.Tx) error {
+ var existingUserID int64
+ var isActive bool
+ err := us.store.TxQueryRow(tx, `SELECT id, is_active FROM users WHERE email = $1`, email).Scan(&existingUserID, &isActive)
+ 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
+ }
+ if err := us.store.TxQueryRow(tx, `
+ 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 := us.store.TxExec(tx, `
+ 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
+ return nil
+ } else if err != nil {
+ return err
+ }
+
+ userID = existingUserID
+ if !isActive {
+ return ErrSCIMAccountDeactivated
+ }
+ var isMember bool
+ if err := us.store.TxQueryRow(tx, `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 := us.store.TxExec(tx, `
+ 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 := us.store.TxExec(tx, `UPDATE users SET default_org_id = COALESCE(default_org_id, $1) WHERE id = $2`, orgID, userID); err != nil {
+ return err
+ }
return nil
})
+ if err != nil {
+ return nil, false, err
+ }
+
+ user, err := us.GetUserInOrg(orgID, userID)
+ if err != nil {
+ return nil, false, err
+ }
+ return user, created, nil
+}
+
+func nullableStr(s string) interface{} {
+ if s == "" {
+ return nil
+ }
+ return s
+}
+
+// DeactivateUserInOrgBySCIM deactivates a user as part of SCIM-driven
+// deprovisioning (PATCH active:false) and revokes their live sessions, same
+// as DeactivateUserInOrg. There is no human actor to record here - the
+// actor is the IdP, via SCIM - so deactivated_by_user_id is left NULL and
+// the unified audit event is emitted by the caller
+// (internal/api/scim_handlers.go) via auditRecorder.Webhook instead of the
+// user_management_audit mirror addUserAuditLog uses, which requires a real
+// performed_by_user_id (NOT NULL, FK to users).
+func (us *UserService) DeactivateUserInOrgBySCIM(userID int64) error {
+ return us.store.WithTx(func(tx *sql.Tx) error {
+ if _, err := us.store.TxExec(tx, `
+ UPDATE users SET is_active = false, deactivated_at = NOW(), deactivated_by_user_id = NULL, updated_at = NOW()
+ WHERE id = $1
+ `, userID); err != nil {
+ return fmt.Errorf("failed to deactivate user: %w", err)
+ }
+ if _, err := us.store.TxExec(tx, `
+ UPDATE auth_tokens SET is_active = false, revoked_at = NOW()
+ WHERE user_id = $1 AND is_active = true
+ `, userID); err != nil {
+ return fmt.Errorf("failed to revoke active sessions: %w", err)
+ }
+ return nil
+ })
+}
+
+// ReactivateUserInOrgBySCIM mirrors DeactivateUserInOrgBySCIM for PATCH active:true.
+func (us *UserService) ReactivateUserInOrgBySCIM(userID int64) error {
+ _, err := us.db.Exec(`
+ UPDATE users SET is_active = true, deactivated_at = NULL, deactivated_by_user_id = NULL, updated_at = NOW()
+ WHERE id = $1
+ `, userID)
+ if err != nil {
+ return fmt.Errorf("failed to reactivate user: %w", err)
+ }
+ return nil
}
// ForcePasswordReset forces a user to reset their password on next login
@@ -1123,6 +1292,8 @@ func userAuditAction(action string) (auditlog.Action, bool) {
return auditlog.ActionUserDeactivate, true
case "reactivated":
return auditlog.ActionUserReactivate, true
+ case "session_revoked":
+ return auditlog.ActionSessionRevoke, true
case "role_changed":
return auditlog.ActionUserRoleChange, true
case "password_reset":
diff --git a/internal/auditlog/catalog.go b/internal/auditlog/catalog.go
index f1c47c707..14a05d884 100644
--- a/internal/auditlog/catalog.go
+++ b/internal/auditlog/catalog.go
@@ -114,6 +114,14 @@ const (
ActionIdentityAdd Action = "auth.identity.add"
ActionIdentityRemove Action = "auth.identity.remove"
ActionSSOProvision Action = "auth.sso.provision"
+ ActionSessionRevoke Action = "auth.session.revoke"
+
+ ActionSCIMUserCreate Action = "scim.user.create"
+ ActionSCIMUserUpdate Action = "scim.user.update"
+ ActionSCIMUserDeactivate Action = "scim.user.deactivate"
+ ActionSCIMUserReactivate Action = "scim.user.reactivate"
+ ActionSCIMTokenCreate Action = "scim.token.create"
+ ActionSCIMTokenRevoke Action = "scim.token.revoke"
ActionUserCreate Action = "user.create"
ActionUserUpdate Action = "user.update"
@@ -255,6 +263,13 @@ var actionCatalog = func() map[Action]ActionMeta {
meta(ActionIdentityAdd, CategoryAuth, "identity", "Identity linked", "A login identity was linked to a user.", SeverityWarning),
meta(ActionIdentityRemove, CategoryAuth, "identity", "Identity unlinked", "A login identity was removed from a user.", SeverityWarning),
meta(ActionSSOProvision, CategoryAuth, "user", "SSO account provisioned", "An account was provisioned through SSO.", SeverityInfo),
+ sensitive(meta(ActionSessionRevoke, CategoryAuth, "session", "Sessions revoked", "A user's active sessions and refresh tokens were revoked, typically following deactivation.", SeverityWarning)),
+ sensitive(meta(ActionSCIMUserCreate, CategoryAuth, "user", "SCIM user provisioned", "An identity provider created or linked a user via SCIM.", SeverityInfo)),
+ sensitive(meta(ActionSCIMUserUpdate, CategoryAuth, "user", "SCIM user updated", "An identity provider updated a user's attributes via SCIM.", SeverityInfo)),
+ sensitive(meta(ActionSCIMUserDeactivate, CategoryAuth, "user", "SCIM user deactivated", "An identity provider deactivated a user via SCIM.", SeverityWarning)),
+ sensitive(meta(ActionSCIMUserReactivate, CategoryAuth, "user", "SCIM user reactivated", "An identity provider reactivated a user via SCIM.", SeverityWarning)),
+ sensitive(meta(ActionSCIMTokenCreate, CategoryAuth, "scim_token", "SCIM token created", "An org's SCIM bearer token was generated or rotated.", SeverityWarning)),
+ sensitive(meta(ActionSCIMTokenRevoke, CategoryAuth, "scim_token", "SCIM token revoked", "An org's SCIM bearer token was revoked.", SeverityWarning)),
sensitive(meta(ActionUserCreate, CategoryUser, "user", "User created", "A user was added to the organization.", SeverityWarning)),
sensitive(meta(ActionUserUpdate, CategoryUser, "user", "User updated", "A user's profile or settings were changed.", SeverityWarning)),
diff --git a/internal/docindex/docs/routes_guide/settings/sso.md b/internal/docindex/docs/routes_guide/settings/sso.md
index d46d5d84b..1c5088f97 100644
--- a/internal/docindex/docs/routes_guide/settings/sso.md
+++ b/internal/docindex/docs/routes_guide/settings/sso.md
@@ -58,6 +58,59 @@ real gatekeeper.
- 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.
+## Deprovisioning: how a removed user actually loses access
+
+JIT provisioning only covers getting users *in*. There are three distinct
+mechanisms for getting a removed/disabled IdP user *out*, each covering a
+different gap:
+
+1. **Manual deactivation always works.** An owner deactivating a user
+ (Settings → Users) immediately revokes every one of that user's live
+ sessions - their JWT stops working on the very next request, not just on
+ future logins. SSO login itself can never undo a deactivation: if a
+ deactivated user's IdP credentials still work, login is rejected rather
+ than silently reactivating them. Reactivating is an explicit admin action.
+2. **SCIM (below) is the real-time, automated answer**, if the org wires it
+ up at their IdP. Without it, deprovisioning depends on a human noticing
+ or the two fallbacks below.
+3. **The reconciliation poll** (OIDC panel, "Automatic deprovisioning
+ check") and **OIDC back-channel logout** are two best-effort fallbacks
+ for orgs that skip SCIM - see their own sections below. Neither applies
+ to SAML; a SAML-only org that wants automated deprovisioning needs SCIM.
+
+## SCIM provisioning
+
+A "SCIM Provisioning" panel sits below the OIDC/SAML tabs on this same page,
+independent of which login protocol(s) are configured. This lets the IdP
+itself (Okta, Azure AD/Entra ID, Google Workspace) push user lifecycle
+events directly - most importantly, deactivating a LiveReview user the
+moment the IdP admin removes or disables them there, rather than waiting for
+that user to attempt another login.
+
+- **Generate Token** creates the org's SCIM bearer token, shown once (same
+ reveal-once convention as an API key) - copy it into the IdP's SCIM app
+ configuration alongside the shown **SCIM base URL**
+ (`{origin}/api/v1/scim/v2`).
+- **Rotate Token** replaces the token; the old one stops working
+ immediately, so the IdP's SCIM config needs updating with the new one.
+- **Revoke** disables SCIM entirely until a new token is generated.
+- Requires the `scim` plan feature (same plan entry as `sso`,
+ `internal/license/plans.go`'s `PlanEnterpriseSelfhosted`).
+- The IdP authenticates every SCIM call with this bearer token alone - there
+ is no user session involved, and the org is resolved from the token
+ itself, never a client-supplied id.
+
+## Automatic deprovisioning check (OIDC reconciliation poll)
+
+Only shown on the OIDC panel. A fallback for orgs that configure OIDC login
+but never wire up SCIM above (a separate integration step many admins skip).
+When enabled, LiveReview periodically re-checks each signed-in user against
+the IdP (silently refreshing a stored OAuth token) and deactivates them here
+if the IdP rejects it - the same outcome a SCIM deactivation would produce,
+just on a delay bounded by the interval you pick (hourly/daily/weekly,
+default daily) instead of instantly. Not available for SAML - there's no
+equivalent standing credential to silently re-check with that protocol.
+
## Where this shows up elsewhere
- The self-hosted login page (`ui/src/pages/Auth/SelfHosted.tsx`) calls the
diff --git a/internal/jobqueue/jobqueue.go b/internal/jobqueue/jobqueue.go
index d886dc306..a61209f73 100644
--- a/internal/jobqueue/jobqueue.go
+++ b/internal/jobqueue/jobqueue.go
@@ -35,17 +35,17 @@ import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/livereview/internal/githubapp"
+ "github.com/livereview/internal/policyengine"
"github.com/livereview/internal/providers"
"github.com/livereview/internal/providers/gitea"
- "github.com/livereview/internal/policyengine"
"github.com/livereview/internal/seed_demo"
networkjobqueue "github.com/livereview/network/jobqueue"
storagejobqueue "github.com/livereview/storage/jobqueue"
- "github.com/robfig/cron/v3"
"github.com/livereview/storage/providers/pullrequests"
"github.com/riverqueue/river"
"github.com/riverqueue/river/riverdriver/riverpgxv5"
"github.com/riverqueue/river/rivermigrate"
+ "github.com/robfig/cron/v3"
)
// GitLab API response structures
@@ -2449,6 +2449,7 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) {
river.AddWorker(workers, seedDemoWorker)
river.AddWorker(workers, &BlastRadiusWorker{db: db})
river.AddWorker(workers, &RiskModelRefitWorker{db: db})
+ river.AddWorker(workers, &SSOReconciliationWorker{db: db})
coordinatorInterval := config.RepoSyncConfig.CoordinatorInterval
if coordinatorInterval <= 0 {
@@ -2568,15 +2569,22 @@ func NewJobQueue(databaseURL string, db *sql.DB) (*JobQueue, error) {
},
&river.PeriodicJobOpts{ID: "risk_model_refit", RunOnStart: false},
),
+ river.NewPeriodicJob(
+ river.PeriodicInterval(15*time.Minute),
+ func() (river.JobArgs, *river.InsertOpts) {
+ return SSOReconciliationJobArgs{}, &river.InsertOpts{Queue: "repo_sync", MaxAttempts: 3}
+ },
+ &river.PeriodicJobOpts{ID: "sso_reconciliation", RunOnStart: false},
+ ),
river.NewPeriodicJob(
archivalSchedule,
func() (river.JobArgs, *river.InsertOpts) {
return PreloadedChangesArchivalSweepJobArgs{
- RetentionDays: archivalRetentionDays,
- }, &river.InsertOpts{
- Queue: "preloaded_changes_archival_sweep",
- MaxAttempts: 3,
- }
+ RetentionDays: archivalRetentionDays,
+ }, &river.InsertOpts{
+ Queue: "preloaded_changes_archival_sweep",
+ MaxAttempts: 3,
+ }
},
&river.PeriodicJobOpts{
ID: "preloaded_changes_archival_sweep",
diff --git a/internal/jobqueue/sso_reconciliation_worker.go b/internal/jobqueue/sso_reconciliation_worker.go
new file mode 100644
index 000000000..6f9855f6a
--- /dev/null
+++ b/internal/jobqueue/sso_reconciliation_worker.go
@@ -0,0 +1,88 @@
+package jobqueue
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "os"
+
+ "github.com/livereview/internal/api/users"
+ "github.com/livereview/internal/sso"
+ "github.com/riverqueue/river"
+ "golang.org/x/oauth2"
+)
+
+// SSOReconciliationJobArgs is a fixed-interval sweep (see jobqueue.go's
+// PeriodicJobs) that silently re-checks "is this account still valid at the
+// IdP" for every OIDC org that has opted into the reconciliation poll (see
+// org_sso_configs.reconciliation_enabled) - a fallback deprovisioning path
+// for orgs that configure OIDC login but never wire up SCIM at their IdP.
+// Per org/identity work happens inline in one Work() call, the same
+// "coordinator does the fan-out itself" shape RiskModelRefitWorker already
+// uses, rather than enqueueing a separate one-shot job per org.
+type SSOReconciliationJobArgs struct{}
+
+func (SSOReconciliationJobArgs) Kind() string { return "sso_reconciliation" }
+
+type SSOReconciliationWorker struct {
+ river.WorkerDefaults[SSOReconciliationJobArgs]
+ db *sql.DB
+}
+
+func (w *SSOReconciliationWorker) Work(ctx context.Context, job *river.Job[SSOReconciliationJobArgs]) error {
+ box, err := sso.NewSecretBox(os.Getenv("JWT_SECRET"))
+ if err != nil {
+ // Same "disabled, not fatal" treatment server.go gives a missing/empty
+ // JWT_SECRET for SSO generally - nothing to reconcile without it.
+ return nil
+ }
+ configStore := sso.NewStore(w.db, box)
+ identityStore := sso.NewIdentityStore(w.db, box)
+ userService := users.NewUserService(w.db, nil)
+
+ due, err := configStore.ListDueForReconciliation()
+ if err != nil {
+ return fmt.Errorf("sso reconciliation: listing due configs: %w", err)
+ }
+
+ for _, cfg := range due {
+ // Best-effort per org: one org's IdP being unreachable shouldn't
+ // block the sweep for everyone else.
+ w.reconcileOrg(ctx, cfg, configStore, identityStore, userService)
+ }
+ return nil
+}
+
+func (w *SSOReconciliationWorker) reconcileOrg(ctx context.Context, cfg *sso.Config, configStore *sso.Store, identityStore *sso.IdentityStore, userService *users.UserService) {
+ fullCfg, err := configStore.Get(cfg.OrgID, sso.ProviderOIDC)
+ if err != nil {
+ return
+ }
+ identities, err := identityStore.ListWithRefreshToken(cfg.OrgID, sso.ProviderOIDC)
+ if err != nil {
+ return
+ }
+ if len(identities) == 0 {
+ _ = configStore.MarkReconciled(cfg.OrgID, sso.ProviderOIDC)
+ return
+ }
+
+ oauthCfg, _, err := sso.BuildOIDCOAuth2Config(ctx, fullCfg, "")
+ if err != nil {
+ return
+ }
+
+ for _, identity := range identities {
+ _, refreshErr := oauthCfg.TokenSource(ctx, &oauth2.Token{RefreshToken: identity.RefreshToken}).Token()
+ if refreshErr != nil {
+ // The IdP rejected the refresh - invalid_grant/invalid_token is
+ // exactly what Okta/Azure/Google return for a disabled, deleted,
+ // or de-authorized account. Deactivate, same as a SCIM
+ // PATCH active:false would.
+ _ = userService.DeactivateUserInOrgBySCIM(identity.UserID)
+ continue
+ }
+ _ = identityStore.MarkChecked(identity.ID)
+ }
+ _ = configStore.MarkReconciled(cfg.OrgID, sso.ProviderOIDC)
+}
diff --git a/internal/license/plans.go b/internal/license/plans.go
index f87ced7ee..de5acaf8d 100644
--- a/internal/license/plans.go
+++ b/internal/license/plans.go
@@ -162,6 +162,7 @@ var PlanDefinitions = map[PlanType]PlanLimits{
"email_support",
"priority_support",
"sso",
+ "scim",
},
},
}
diff --git a/internal/scim/token.go b/internal/scim/token.go
new file mode 100644
index 000000000..a4644fe6c
--- /dev/null
+++ b/internal/scim/token.go
@@ -0,0 +1,153 @@
+// Package scim implements the subset of SCIM 2.0 that enterprise IdPs
+// (Okta, Azure AD, Google Workspace) actually use for user lifecycle sync:
+// create, read, list-by-filter, and PATCH active:false/true. See
+// docs/sso.md for the user-facing setup flow and internal/api/scim_handlers.go
+// for the HTTP layer that wires this package into internal/api/users.
+package scim
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/base32"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// tokenPrefix mirrors api_keys.go's "lr_" scheme (see
+// internal/api/api_keys.go's GenerateAPIKey) so SCIM tokens are visually
+// distinct from regular API keys and session tokens.
+const tokenPrefix = "scim_"
+
+var (
+ ErrTokenNotFound = errors.New("scim: token not found")
+ ErrTokenRevoked = errors.New("scim: token revoked")
+ ErrTokenExpired = errors.New("scim: token expired")
+)
+
+// GenerateToken creates a new SCIM bearer token in the form "scim_".
+func GenerateToken() (string, error) {
+ randomBytes := make([]byte, 32)
+ if _, err := rand.Read(randomBytes); err != nil {
+ return "", fmt.Errorf("failed to generate random bytes: %w", err)
+ }
+ encoded := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(randomBytes)
+ return tokenPrefix + strings.ToLower(encoded), nil
+}
+
+// HashToken returns the SHA-256 hash of a SCIM token, which is what's
+// actually stored - the plaintext token is only ever shown once, at
+// creation time, same as an API key.
+func HashToken(token string) string {
+ hash := sha256.Sum256([]byte(token))
+ return hex.EncodeToString(hash[:])
+}
+
+// TokenPrefix extracts a short, non-secret prefix for display in the
+// settings UI (e.g. "scim_abcd1234..."), same convention as
+// api_keys.go's GetKeyPrefix.
+func TokenPrefix(token string) string {
+ stripped := strings.TrimPrefix(token, tokenPrefix)
+ if len(stripped) > 8 {
+ stripped = stripped[:8]
+ }
+ return tokenPrefix + stripped
+}
+
+// Token is a SCIM bearer token record for one org.
+type Token struct {
+ ID int64
+ OrgID int64
+ TokenPrefix string
+ CreatedAt time.Time
+ ExpiresAt *time.Time
+ RevokedAt *time.Time
+ CreatedByUserID *int64
+}
+
+// TokenStore manages the scim_tokens table.
+type TokenStore struct {
+ db *sql.DB
+}
+
+func NewTokenStore(db *sql.DB) *TokenStore {
+ return &TokenStore{db: db}
+}
+
+// Create generates a new token for orgID, replacing any existing one (an
+// org has at most one live SCIM token at a time - rotating means the old
+// one stops working, same mental model as rotating an API key). Returns the
+// plaintext token, which the caller must show to the user exactly once.
+func (s *TokenStore) Create(orgID int64, createdByUserID int64) (plaintext string, tok *Token, err error) {
+ plaintext, err = GenerateToken()
+ if err != nil {
+ return "", nil, err
+ }
+ hash := HashToken(plaintext)
+ prefix := TokenPrefix(plaintext)
+
+ tok = &Token{}
+ err = s.db.QueryRow(`
+ INSERT INTO scim_tokens (org_id, token_hash, token_prefix, created_by_user_id, created_at)
+ VALUES ($1, $2, $3, $4, NOW())
+ RETURNING id, org_id, token_prefix, created_at, expires_at, revoked_at, created_by_user_id
+ `, orgID, hash, prefix, createdByUserID).Scan(
+ &tok.ID, &tok.OrgID, &tok.TokenPrefix, &tok.CreatedAt, &tok.ExpiresAt, &tok.RevokedAt, &tok.CreatedByUserID,
+ )
+ if err != nil {
+ return "", nil, fmt.Errorf("failed to create scim token: %w", err)
+ }
+ return plaintext, tok, nil
+}
+
+// Authenticate resolves a plaintext bearer token to the org it belongs to.
+// Returns ErrTokenNotFound/ErrTokenRevoked/ErrTokenExpired so the HTTP layer
+// can give a precise SCIM error response.
+func (s *TokenStore) Authenticate(plaintext string) (orgID int64, err error) {
+ hash := HashToken(plaintext)
+ var revokedAt *time.Time
+ var expiresAt *time.Time
+ row := s.db.QueryRow(`SELECT org_id, revoked_at, expires_at FROM scim_tokens WHERE token_hash = $1`, hash)
+ if scanErr := row.Scan(&orgID, &revokedAt, &expiresAt); scanErr != nil {
+ if scanErr == sql.ErrNoRows {
+ return 0, ErrTokenNotFound
+ }
+ return 0, fmt.Errorf("failed to look up scim token: %w", scanErr)
+ }
+ if revokedAt != nil {
+ return 0, ErrTokenRevoked
+ }
+ if expiresAt != nil && expiresAt.Before(time.Now()) {
+ return 0, ErrTokenExpired
+ }
+ return orgID, nil
+}
+
+// Get returns orgID's current live token, if any (nil, nil if none exists).
+func (s *TokenStore) Get(orgID int64) (*Token, error) {
+ tok := &Token{}
+ err := s.db.QueryRow(`
+ SELECT id, org_id, token_prefix, created_at, expires_at, revoked_at, created_by_user_id
+ FROM scim_tokens WHERE org_id = $1 AND revoked_at IS NULL
+ ORDER BY created_at DESC LIMIT 1
+ `, orgID).Scan(&tok.ID, &tok.OrgID, &tok.TokenPrefix, &tok.CreatedAt, &tok.ExpiresAt, &tok.RevokedAt, &tok.CreatedByUserID)
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to load scim token: %w", err)
+ }
+ return tok, nil
+}
+
+// Revoke invalidates orgID's current token immediately.
+func (s *TokenStore) Revoke(orgID int64) error {
+ _, err := s.db.Exec(`UPDATE scim_tokens SET revoked_at = NOW() WHERE org_id = $1 AND revoked_at IS NULL`, orgID)
+ if err != nil {
+ return fmt.Errorf("failed to revoke scim token: %w", err)
+ }
+ return nil
+}
diff --git a/internal/scim/types.go b/internal/scim/types.go
new file mode 100644
index 000000000..63d9594db
--- /dev/null
+++ b/internal/scim/types.go
@@ -0,0 +1,78 @@
+package scim
+
+import "strconv"
+
+const (
+ SchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User"
+ SchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
+ SchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
+ SchemaError = "urn:ietf:params:scim:api:messages:2.0:Error"
+)
+
+// UserResource is the minimal SCIM User representation LiveReview speaks -
+// enough for Okta/Azure AD/Google's lifecycle-sync use case (create,
+// look up by userName/email, toggle active). Fields no customer IdP relies
+// on for this use case (groups, x509Certificates, etc.) are intentionally
+// omitted rather than stubbed out.
+type UserResource struct {
+ Schemas []string `json:"schemas"`
+ ID string `json:"id"`
+ UserName string `json:"userName"`
+ Name *NameResource `json:"name,omitempty"`
+ Emails []EmailResource `json:"emails,omitempty"`
+ Active bool `json:"active"`
+ Meta *MetaResource `json:"meta,omitempty"`
+}
+
+type NameResource struct {
+ GivenName string `json:"givenName,omitempty"`
+ FamilyName string `json:"familyName,omitempty"`
+}
+
+type EmailResource struct {
+ Value string `json:"value"`
+ Primary bool `json:"primary,omitempty"`
+}
+
+type MetaResource struct {
+ ResourceType string `json:"resourceType"`
+ Created string `json:"created,omitempty"`
+ LastModified string `json:"lastModified,omitempty"`
+}
+
+type ListResponse struct {
+ Schemas []string `json:"schemas"`
+ TotalResults int `json:"totalResults"`
+ ItemsPerPage int `json:"itemsPerPage"`
+ StartIndex int `json:"startIndex"`
+ Resources []UserResource `json:"Resources"`
+}
+
+// PatchRequest is the body of a SCIM PATCH /Users/:id call. IdPs send this
+// to deprovision on offboarding: {"Operations":[{"op":"replace","value":{"active":false}}]}.
+type PatchRequest struct {
+ Schemas []string `json:"schemas"`
+ Operations []PatchOperation `json:"Operations"`
+}
+
+type PatchOperation struct {
+ Op string `json:"op"`
+ Path string `json:"path,omitempty"`
+ Value any `json:"value,omitempty"`
+}
+
+// ErrorResponse is the SCIM-shaped error body every handler returns on
+// failure, per RFC 7644 §3.12.
+type ErrorResponse struct {
+ Schemas []string `json:"schemas"`
+ Status string `json:"status"`
+ Detail string `json:"detail"`
+}
+
+func NewError(status int, detail string) ErrorResponse {
+ return ErrorResponse{
+ Schemas: []string{SchemaError},
+ Status: strconv.Itoa(status),
+ Detail: detail,
+ }
+}
diff --git a/internal/sso/config.go b/internal/sso/config.go
index 43bcfc085..270da12b1 100644
--- a/internal/sso/config.go
+++ b/internal/sso/config.go
@@ -4,6 +4,7 @@ import (
"database/sql"
"errors"
"fmt"
+ "time"
)
// ProviderType is the SSO protocol a config row speaks.
@@ -31,6 +32,14 @@ type Config struct {
SAMLIDPMetadataURL string
SAMLIDPMetadataXML string
SAMLIDPEntityID string
+
+ // Reconciliation fields only apply to provider_type=oidc - see Phase 5
+ // of docs/sso.md's deprovisioning section. SAML has no standing
+ // credential to silently re-check, so these are always false/zero for
+ // SAML configs.
+ ReconciliationEnabled bool
+ ReconciliationInterval string // "hourly" | "daily" | "weekly"
+ LastReconciledAt *time.Time
}
// Store persists org_sso_configs, transparently encrypting/decrypting the
@@ -51,7 +60,8 @@ 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, '')
+ COALESCE(saml_idp_metadata_url, ''), COALESCE(saml_idp_metadata_xml, ''), COALESCE(saml_idp_entity_id, ''),
+ reconciliation_enabled, reconciliation_interval, last_reconciled_at
FROM org_sso_configs
WHERE org_id = $1 AND provider_type = $2
`, orgID, string(provider))
@@ -60,7 +70,8 @@ func (s *Store) Get(orgID int64, provider ProviderType) (*Config, error) {
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 {
+ &cfg.SAMLIDPMetadataURL, &cfg.SAMLIDPMetadataXML, &cfg.SAMLIDPEntityID,
+ &cfg.ReconciliationEnabled, &cfg.ReconciliationInterval, &cfg.LastReconciledAt); err != nil {
if err == sql.ErrNoRows {
return nil, ErrNotFound
}
@@ -122,13 +133,18 @@ func (s *Store) UpsertTx(exec sqlExecer, cfg *Config, createdByUserID int64) err
if err != nil {
return fmt.Errorf("sso: encrypting client secret: %w", err)
}
+ reconciliationInterval := cfg.ReconciliationInterval
+ if reconciliationInterval == "" {
+ reconciliationInterval = "daily"
+ }
_, 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,
+ reconciliation_enabled, reconciliation_interval,
created_by_user_id
- ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)
ON CONFLICT (org_id, provider_type) DO UPDATE SET
display_name = EXCLUDED.display_name,
enabled = EXCLUDED.enabled,
@@ -139,10 +155,13 @@ func (s *Store) UpsertTx(exec sqlExecer, cfg *Config, createdByUserID int64) err
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,
+ reconciliation_enabled = EXCLUDED.reconciliation_enabled,
+ reconciliation_interval = EXCLUDED.reconciliation_interval,
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),
+ cfg.ReconciliationEnabled, reconciliationInterval,
nullableID(createdByUserID))
if err != nil {
return fmt.Errorf("sso: upserting config: %w", err)
@@ -163,6 +182,49 @@ func (s *Store) DeleteTx(exec sqlExecer, orgID int64, provider ProviderType) err
return nil
}
+// ListDueForReconciliation returns every OIDC config with the reconciliation
+// poll enabled whose interval has elapsed since it last ran (or that has
+// never run). Used by the coordinator job (internal/jobqueue/sso_reconciliation_worker.go)
+// on a fixed short tick to decide which orgs to fan out one-shot work to -
+// see Phase 5 of docs/sso.md's deprovisioning section for why this is a
+// coordinator+fan-out job rather than one River PeriodicJob per org.
+func (s *Store) ListDueForReconciliation() ([]*Config, error) {
+ rows, err := s.db.Query(`
+ SELECT id, org_id, provider_type, reconciliation_interval, last_reconciled_at
+ FROM org_sso_configs
+ WHERE provider_type = 'oidc' AND reconciliation_enabled = TRUE AND (
+ last_reconciled_at IS NULL
+ OR (reconciliation_interval = 'hourly' AND last_reconciled_at < NOW() - INTERVAL '1 hour')
+ OR (reconciliation_interval = 'daily' AND last_reconciled_at < NOW() - INTERVAL '1 day')
+ OR (reconciliation_interval = 'weekly' AND last_reconciled_at < NOW() - INTERVAL '7 days')
+ )
+ `)
+ if err != nil {
+ return nil, fmt.Errorf("sso: listing configs due for reconciliation: %w", err)
+ }
+ defer rows.Close()
+
+ var out []*Config
+ for rows.Next() {
+ cfg := &Config{}
+ if err := rows.Scan(&cfg.ID, &cfg.OrgID, &cfg.ProviderType, &cfg.ReconciliationInterval, &cfg.LastReconciledAt); err != nil {
+ return nil, fmt.Errorf("sso: scanning config due for reconciliation: %w", err)
+ }
+ out = append(out, cfg)
+ }
+ return out, rows.Err()
+}
+
+// MarkReconciled stamps last_reconciled_at = NOW() once a reconciliation
+// sweep finishes for (orgID, provider).
+func (s *Store) MarkReconciled(orgID int64, provider ProviderType) error {
+ _, err := s.db.Exec(`UPDATE org_sso_configs SET last_reconciled_at = NOW() WHERE org_id = $1 AND provider_type = $2`, orgID, string(provider))
+ if err != nil {
+ return fmt.Errorf("sso: marking config reconciled: %w", err)
+ }
+ return nil
+}
+
func nullable(s string) interface{} {
if s == "" {
return nil
diff --git a/internal/sso/identity_store.go b/internal/sso/identity_store.go
new file mode 100644
index 000000000..194143171
--- /dev/null
+++ b/internal/sso/identity_store.go
@@ -0,0 +1,110 @@
+package sso
+
+import (
+ "database/sql"
+ "fmt"
+)
+
+// IdentityStore persists sso_identities: the mapping from an IdP's own
+// subject identifier (OIDC `sub` / SAML NameID) to a LiveReview user, per
+// org and provider. Two things need this mapping, neither of which a
+// LiveReview user id alone can satisfy:
+// - An incoming OIDC back-channel logout token or SAML LogoutRequest only
+// ever carries the IdP's subject, never a LiveReview user id.
+// - The reconciliation poll (Phase 5) needs an encrypted refresh token per
+// identity to silently re-check "is this account still valid at the IdP".
+type IdentityStore struct {
+ db *sql.DB
+ box *SecretBox
+}
+
+func NewIdentityStore(db *sql.DB, box *SecretBox) *IdentityStore {
+ return &IdentityStore{db: db, box: box}
+}
+
+// Upsert records (or refreshes) the subject->user mapping for a successful
+// login, and the refresh token if one was issued (empty string clears it -
+// callers pass "" when the IdP didn't grant one, e.g. reconciliation isn't
+// enabled for this config).
+func (s *IdentityStore) Upsert(orgID, userID int64, provider ProviderType, subject, refreshToken string) error {
+ encRefresh, err := s.box.Encrypt(refreshToken)
+ if err != nil {
+ return fmt.Errorf("sso: encrypting refresh token: %w", err)
+ }
+ _, err = s.db.Exec(`
+ INSERT INTO sso_identities (org_id, user_id, provider_type, subject, oidc_refresh_token_enc, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, NOW(), NOW())
+ ON CONFLICT (org_id, provider_type, subject) DO UPDATE SET
+ user_id = EXCLUDED.user_id,
+ oidc_refresh_token_enc = CASE WHEN EXCLUDED.oidc_refresh_token_enc IS NOT NULL THEN EXCLUDED.oidc_refresh_token_enc ELSE sso_identities.oidc_refresh_token_enc END,
+ updated_at = NOW()
+ `, orgID, userID, string(provider), subject, nullable(encRefresh))
+ if err != nil {
+ return fmt.Errorf("sso: upserting identity: %w", err)
+ }
+ return nil
+}
+
+// LookupUserBySubject resolves an incoming logout event's subject to a
+// LiveReview user id, for back-channel logout / SAML SLO (Phase 4).
+func (s *IdentityStore) LookupUserBySubject(orgID int64, provider ProviderType, subject string) (userID int64, err error) {
+ err = s.db.QueryRow(`
+ SELECT user_id FROM sso_identities WHERE org_id = $1 AND provider_type = $2 AND subject = $3
+ `, orgID, string(provider), subject).Scan(&userID)
+ if err != nil {
+ if err == sql.ErrNoRows {
+ return 0, fmt.Errorf("sso: no identity found for subject")
+ }
+ return 0, fmt.Errorf("sso: looking up identity: %w", err)
+ }
+ return userID, nil
+}
+
+// IdentityWithRefreshToken is one row the reconciliation worker checks.
+type IdentityWithRefreshToken struct {
+ ID int64
+ UserID int64
+ Subject string
+ RefreshToken string
+}
+
+// ListWithRefreshToken returns every stored identity for orgID that has a
+// refresh token on file, for the reconciliation worker
+// (internal/jobqueue/sso_reconciliation_worker.go) to silently re-check.
+func (s *IdentityStore) ListWithRefreshToken(orgID int64, provider ProviderType) ([]IdentityWithRefreshToken, error) {
+ rows, err := s.db.Query(`
+ SELECT id, user_id, subject, oidc_refresh_token_enc
+ FROM sso_identities
+ WHERE org_id = $1 AND provider_type = $2 AND oidc_refresh_token_enc IS NOT NULL
+ `, orgID, string(provider))
+ if err != nil {
+ return nil, fmt.Errorf("sso: listing identities with refresh tokens: %w", err)
+ }
+ defer rows.Close()
+
+ var out []IdentityWithRefreshToken
+ for rows.Next() {
+ var row IdentityWithRefreshToken
+ var encRefresh string
+ if err := rows.Scan(&row.ID, &row.UserID, &row.Subject, &encRefresh); err != nil {
+ return nil, fmt.Errorf("sso: scanning identity: %w", err)
+ }
+ refresh, err := s.box.Decrypt(encRefresh)
+ if err != nil {
+ return nil, fmt.Errorf("sso: decrypting refresh token: %w", err)
+ }
+ row.RefreshToken = refresh
+ out = append(out, row)
+ }
+ return out, rows.Err()
+}
+
+// MarkChecked stamps last_checked_at = NOW() for one identity after a
+// successful reconciliation refresh.
+func (s *IdentityStore) MarkChecked(id int64) error {
+ _, err := s.db.Exec(`UPDATE sso_identities SET last_checked_at = NOW() WHERE id = $1`, id)
+ if err != nil {
+ return fmt.Errorf("sso: marking identity checked: %w", err)
+ }
+ return nil
+}
diff --git a/internal/sso/oidc.go b/internal/sso/oidc.go
index 0c4fd72a2..894e8acc7 100644
--- a/internal/sso/oidc.go
+++ b/internal/sso/oidc.go
@@ -15,6 +15,17 @@ import (
type Identity struct {
Email string
Name string
+ // Subject is the IdP's own identifier for this identity (OIDC `sub` /
+ // SAML NameID). Needed to resolve an incoming OIDC back-channel logout
+ // token or SAML LogoutRequest - which only carry this, never a
+ // LiveReview user id - back to a user (see internal/sso identity_store.go
+ // and Phase 4 of docs/sso.md's deprovisioning section).
+ Subject string
+ // RefreshToken is only populated for OIDC configs with the
+ // reconciliation poll enabled (see Config.ReconciliationEnabled) - it
+ // requests the "offline_access" scope specifically so the poll can
+ // silently re-check "is this account still valid" between logins.
+ RefreshToken string
}
// oidcClaims mirrors the standard OIDC claims LiveReview needs. Most IdPs
@@ -41,6 +52,9 @@ func BuildOIDCOAuth2Config(ctx context.Context, cfg *Config, redirectURL string)
if strings.TrimSpace(cfg.OIDCScopes) != "" {
scopes = strings.Fields(cfg.OIDCScopes)
}
+ if cfg.ReconciliationEnabled && !containsFold(scopes, "offline_access") {
+ scopes = append(scopes, "offline_access")
+ }
oauthCfg := &oauth2.Config{
ClientID: cfg.OIDCClientID,
ClientSecret: cfg.OIDCClientSecret,
@@ -74,7 +88,52 @@ func ExchangeOIDCCode(ctx context.Context, oauthCfg *oauth2.Config, verifier *oi
if claims.Email == "" {
return nil, fmt.Errorf("sso: oidc id_token has no email claim")
}
- return &Identity{Email: claims.Email, Name: claims.Name}, nil
+ return &Identity{
+ Email: claims.Email,
+ Name: claims.Name,
+ Subject: idToken.Subject,
+ RefreshToken: token.RefreshToken,
+ }, nil
+}
+
+// backChannelLogoutEvent is the OIDC Back-Channel Logout 1.0 event URI a
+// valid logout_token's "events" claim must contain.
+const backChannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
+
+type backChannelLogoutClaims struct {
+ Subject string `json:"sub"`
+ Events map[string]interface{} `json:"events"`
+}
+
+// VerifyBackChannelLogoutToken verifies an incoming OIDC back-channel
+// logout token (RFC: OIDC Back-Channel Logout 1.0) the same way an ID
+// token is verified - same issuer, signature (JWKS), audience, and expiry
+// checks via go-oidc - then confirms the logout-specific "events" claim is
+// present, and returns the subject whose sessions should be revoked.
+func VerifyBackChannelLogoutToken(ctx context.Context, cfg *Config, rawToken string) (string, error) {
+ if cfg.ProviderType != ProviderOIDC {
+ return "", fmt.Errorf("sso: config %d is not an OIDC config", cfg.ID)
+ }
+ provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuer)
+ if err != nil {
+ return "", fmt.Errorf("sso: oidc discovery against %q failed: %w", cfg.OIDCIssuer, err)
+ }
+ verifier := provider.Verifier(&oidc.Config{ClientID: cfg.OIDCClientID})
+ idToken, err := verifier.Verify(ctx, rawToken)
+ if err != nil {
+ return "", fmt.Errorf("sso: logout_token verification failed: %w", err)
+ }
+ var claims backChannelLogoutClaims
+ if err := idToken.Claims(&claims); err != nil {
+ return "", fmt.Errorf("sso: parsing logout_token claims: %w", err)
+ }
+ if _, ok := claims.Events[backChannelLogoutEvent]; !ok {
+ return "", fmt.Errorf("sso: logout_token missing required backchannel-logout event claim")
+ }
+ if claims.Subject == "" {
+ return "", fmt.Errorf("sso: logout_token has no sub claim")
+ }
+ return claims.Subject, nil
}
// TestOIDCDiscovery is used by the config "test connection" endpoint - it
diff --git a/internal/sso/saml.go b/internal/sso/saml.go
index 92f19802d..352fb87cd 100644
--- a/internal/sso/saml.go
+++ b/internal/sso/saml.go
@@ -95,6 +95,7 @@ func ExchangeSAMLResponse(sp *samllib.ServiceProvider, r *http.Request) (*Identi
identity := &Identity{}
if assertion.Subject != nil && assertion.Subject.NameID != nil {
v := strings.TrimSpace(assertion.Subject.NameID.Value)
+ identity.Subject = v
if strings.Contains(v, "@") {
identity.Email = v
}
diff --git a/ui/src/pages/Settings/SSOSettingsTab.tsx b/ui/src/pages/Settings/SSOSettingsTab.tsx
index ec4e12cbb..b8b5fd4ef 100644
--- a/ui/src/pages/Settings/SSOSettingsTab.tsx
+++ b/ui/src/pages/Settings/SSOSettingsTab.tsx
@@ -6,6 +6,8 @@ import { useOrgContext } from '../../hooks/useOrgContext';
type ProviderType = 'oidc' | 'saml';
+type ReconciliationInterval = 'hourly' | 'daily' | 'weekly';
+
interface SSOConfigResponse {
provider_type: ProviderType;
display_name: string;
@@ -17,6 +19,9 @@ interface SSOConfigResponse {
saml_idp_metadata_url?: string;
saml_idp_entity_id?: string;
saml_metadata_set: boolean;
+ reconciliation_enabled?: boolean;
+ reconciliation_interval?: ReconciliationInterval;
+ last_reconciled_at?: string;
}
interface FormState {
@@ -28,6 +33,8 @@ interface FormState {
oidc_scopes: string;
saml_idp_metadata_url: string;
saml_idp_metadata_xml: string;
+ reconciliation_enabled: boolean;
+ reconciliation_interval: ReconciliationInterval;
}
const emptyForm: FormState = {
@@ -39,6 +46,8 @@ const emptyForm: FormState = {
oidc_scopes: '',
saml_idp_metadata_url: '',
saml_idp_metadata_xml: '',
+ reconciliation_enabled: false,
+ reconciliation_interval: 'daily',
};
const ResultBanner: React.FC<{ type: 'success' | 'error'; message: string }> = ({ type, message }) => (
@@ -70,6 +79,8 @@ const ProviderPanel: React.FC<{
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
+ reconciliation_enabled: existing.reconciliation_enabled || false,
+ reconciliation_interval: existing.reconciliation_interval || 'daily',
});
}
}, [existing]);
@@ -90,6 +101,8 @@ const ProviderPanel: React.FC<{
if (form.oidc_client_secret !== '') {
body.oidc_client_secret = form.oidc_client_secret;
}
+ body.reconciliation_enabled = form.reconciliation_enabled;
+ body.reconciliation_interval = form.reconciliation_interval;
} else {
body.saml_idp_metadata_url = form.saml_idp_metadata_url;
if (form.saml_idp_metadata_xml !== '') {
@@ -203,6 +216,46 @@ const ProviderPanel: React.FC<{
Redirect URI to register with your identity provider:{' '}
{window.location.origin}/api/v1/auth/sso/oidc/callback
+
+
+
+ handleChange('reconciliation_enabled', e.target.checked)}
+ className="w-4 h-4 text-indigo-600 bg-slate-700 border-slate-600 rounded focus:ring-indigo-500 focus:ring-2"
+ />
+
+ Automatic deprovisioning check
+
+
+
+ Fallback for orgs that don't configure SCIM below: periodically re-checks each signed-in
+ user against the identity provider and deactivates them here if the account was disabled
+ or removed there. SCIM (if configured) is real-time; this is a backstop, bounded by the
+ interval you pick.
+
+ {form.reconciliation_enabled && (
+
+
Check interval
+
handleChange('reconciliation_interval', e.target.value)}
+ className="bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200"
+ >
+ Hourly
+ Daily
+ Weekly
+
+ {existing?.last_reconciled_at && (
+
+ Last checked: {new Date(existing.last_reconciled_at).toLocaleString()}
+
+ )}
+
+ )}
+
>
) : (
<>
@@ -261,6 +314,129 @@ const ProviderPanel: React.FC<{
);
};
+interface ScimTokenResponse {
+ exists?: boolean;
+ token_prefix?: string;
+ created_at?: string;
+ token?: string; // only present immediately after create/rotate
+}
+
+const ScimProvisioningPanel: React.FC<{ orgId: number }> = ({ orgId }) => {
+ const [tokenInfo, setTokenInfo] = useState(null);
+ const [newToken, setNewToken] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isWorking, setIsWorking] = useState(false);
+
+ const fetchToken = async () => {
+ try {
+ const data = await apiClient.get(`/api/v1/orgs/${orgId}/scim-token`);
+ setTokenInfo(data);
+ } catch (error) {
+ console.error('Failed to fetch SCIM token status:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ useEffect(() => {
+ if (orgId) {
+ fetchToken();
+ }
+ }, [orgId]);
+
+ const handleGenerate = async () => {
+ const verb = tokenInfo?.token_prefix ? 'rotate' : 'generate';
+ if (tokenInfo?.token_prefix && !window.confirm('Rotating the SCIM token immediately invalidates the old one. Your identity provider will need the new token. Continue?')) {
+ return;
+ }
+ setIsWorking(true);
+ try {
+ const data = await apiClient.post(`/api/v1/orgs/${orgId}/scim-token`, {});
+ setTokenInfo(data);
+ setNewToken(data.token || null);
+ notify.success(`SCIM token ${verb}d`);
+ } catch (error: any) {
+ notify.error(error?.message || `Failed to ${verb} SCIM token`);
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ const handleRevoke = async () => {
+ if (!window.confirm('Revoke the SCIM token? Your identity provider will no longer be able to sync users until a new token is generated.')) {
+ return;
+ }
+ setIsWorking(true);
+ try {
+ await apiClient.delete(`/api/v1/orgs/${orgId}/scim-token`);
+ setTokenInfo({ exists: false });
+ setNewToken(null);
+ notify.success('SCIM token revoked');
+ } catch (error: any) {
+ notify.error(error?.message || 'Failed to revoke SCIM token');
+ } finally {
+ setIsWorking(false);
+ }
+ };
+
+ if (isLoading) {
+ return null;
+ }
+
+ const scimBaseURL = `${window.location.origin}/api/v1/scim/v2`;
+
+ return (
+
+
+
+
+
+
+
SCIM Provisioning
+
+ Let Okta, Azure AD, or Google Workspace automatically create and deactivate users here when
+ they're added or removed at the identity provider - the real-time counterpart to the
+ reconciliation check above.
+
+
+
+
+ {newToken && (
+
+
+ Copy this token now - it won't be shown again.
+
+
{newToken}
+
+ )}
+
+
+ SCIM base URL: {scimBaseURL}
+
+
+ {tokenInfo?.token_prefix ? (
+
+ Current token: {tokenInfo.token_prefix}...
+ {tokenInfo.created_at && ` (created ${new Date(tokenInfo.created_at).toLocaleString()})`}
+
+ ) : (
+
No SCIM token configured yet.
+ )}
+
+
+
+ {tokenInfo?.token_prefix ? 'Rotate Token' : 'Generate Token'}
+
+ {tokenInfo?.token_prefix && (
+
+ Revoke
+
+ )}
+
+
+ );
+};
+
const SSOSettingsTab: React.FC = () => {
const { currentOrgId } = useOrgContext();
const orgId = currentOrgId ?? 0;
@@ -336,6 +512,8 @@ const SSOSettingsTab: React.FC = () => {
existing={existingForActive}
onSaved={fetchConfigs}
/>
+
+
);
};
From b32f9f873c9f548a30cef1902ecbb39398e894fe Mon Sep 17 00:00:00 2001
From: Shrijith Venkatramana
Date: Thu, 29 Oct 2026 17:16:20 +0000
Subject: [PATCH 2/4] Fix the full pre-existing test suite: stale renames,
dropped notes, and env-gated Razorpay tests
go test ./... had five failing packages unrelated to any feature work, left
unfixed since before this branch. Root-caused and fixed each:
- internal/license/payment: Razorpay sandbox integration tests failed
outright without RAZORPAY_TEST_KEY/SECRET instead of skipping; they now
skip cleanly when credentials are absent.
- internal/provider_input/bitbucket: a capture fixture's path was never
updated after a repo-wide docs/raw/ reorg moved it.
- internal/provider_input/azuredevops: test asserted a raw trailing
newline that stripMentionTokens intentionally trims.
- internal/provider_output/github: test predated the intentional
blockquote-reply feature for issue comments.
- internal/mcpagent: classify_test.go still used the shape names
("chat", "count_query") from before they were renamed to
"product_guidance"/"analytics", so the classify call never actually
parsed and every downstream assertion was exercising degraded-fallback
behavior instead of the real branch. Fixing the names surfaced two real
bugs the stale tests had been masking: assembleAnalyticsResponse
discarded per-report notes (no-data/failure sentences) and always
returned a generic fallback, and the analytics-shape dispatch branch
never swapped history[0]'s system prompt like every other branch does,
leaking the previous turn's prompt into analytics turns. Also added one
corrective retry to the multi-interpret call on unparseable JSON,
matching the retry budget the SQL repair phases already have.
LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
---
internal/license/payment/integration_test.go | 1 +
internal/license/payment/payment_test.go | 5 ++
.../license/payment/razorpay_live_test.go | 18 +++++++
internal/license/payment/setup_plans_test.go | 1 +
internal/license/payment/subscription_test.go | 6 +++
internal/mcpagent/agent.go | 17 ++++++
internal/mcpagent/analytics.go | 42 +++++++++++----
internal/mcpagent/analytics_pipeline_test.go | 12 ++---
internal/mcpagent/classify_test.go | 54 ++++++++++++++-----
internal/mcpagent/laws_contract_test.go | 2 +-
.../azuredevops_conversion_test.go | 2 +-
.../bitbucket_comment_hierarchy_test.go | 2 +-
.../github/output_client_test.go | 2 +-
13 files changed, 132 insertions(+), 32 deletions(-)
create mode 100644 internal/license/payment/razorpay_live_test.go
diff --git a/internal/license/payment/integration_test.go b/internal/license/payment/integration_test.go
index eaaa3f2d7..2c912eb0c 100644
--- a/internal/license/payment/integration_test.go
+++ b/internal/license/payment/integration_test.go
@@ -13,6 +13,7 @@ import (
// TestSubscriptionIntegration tests the complete subscription flow
// Run with: go test -v ./internal/license/payment -run TestSubscriptionIntegration
func TestSubscriptionIntegration(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// Skip if not in integration test mode
if testing.Short() {
t.Skip("Skipping integration test in short mode")
diff --git a/internal/license/payment/payment_test.go b/internal/license/payment/payment_test.go
index e40073016..28c04d0ed 100644
--- a/internal/license/payment/payment_test.go
+++ b/internal/license/payment/payment_test.go
@@ -7,6 +7,7 @@ import (
)
func TestGetRazoarPayKeys(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
accessKey, secretKey, err := GetRazorpayKeys("test")
if err != nil {
t.Errorf("Error getting Razorpay keys: %v", err)
@@ -20,6 +21,7 @@ func TestGetRazoarPayKeys(t *testing.T) {
}
func TestCreateMonthlyPlan(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
plan, err := CreatePlan("test", "monthly")
if err != nil {
t.Errorf("Error creating monthly plan: %v", err)
@@ -41,6 +43,7 @@ func TestCreateMonthlyPlan(t *testing.T) {
}
func TestCreateYearlyPlan(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
plan, err := CreatePlan("test", "yearly")
if err != nil {
t.Errorf("Error creating yearly plan: %v", err)
@@ -62,6 +65,7 @@ func TestCreateYearlyPlan(t *testing.T) {
}
func TestGetAllPlans(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
planList, err := GetAllPlans("test")
if err != nil {
t.Errorf("Error fetching all plans: %v", err)
@@ -80,6 +84,7 @@ func TestGetAllPlans(t *testing.T) {
}
func TestGetPlanByID(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, get all plans to get a valid plan ID
planList, err := GetAllPlans("test")
if err != nil {
diff --git a/internal/license/payment/razorpay_live_test.go b/internal/license/payment/razorpay_live_test.go
new file mode 100644
index 000000000..96429a867
--- /dev/null
+++ b/internal/license/payment/razorpay_live_test.go
@@ -0,0 +1,18 @@
+package payment
+
+import (
+ "os"
+ "testing"
+)
+
+// skipWithoutRazorpayTestKeys skips a test that hits the real Razorpay test-mode
+// API when RAZORPAY_TEST_KEY/RAZORPAY_TEST_SECRET aren't configured, rather than
+// failing - these are manual verification scripts against a live sandbox
+// account, not hermetic unit tests, and most environments (CI, local dev
+// without sandbox credentials) have no reason to run them.
+func skipWithoutRazorpayTestKeys(t *testing.T) {
+ t.Helper()
+ if os.Getenv("RAZORPAY_TEST_KEY") == "" || os.Getenv("RAZORPAY_TEST_SECRET") == "" {
+ t.Skip("skipping: RAZORPAY_TEST_KEY/RAZORPAY_TEST_SECRET not set (live Razorpay sandbox test)")
+ }
+}
diff --git a/internal/license/payment/setup_plans_test.go b/internal/license/payment/setup_plans_test.go
index 6d8dfa6b5..623711df8 100644
--- a/internal/license/payment/setup_plans_test.go
+++ b/internal/license/payment/setup_plans_test.go
@@ -9,6 +9,7 @@ import (
// Run with: go test -v ./internal/license/payment -run TestSetupPlans
// Use -args test or -args live to specify mode (default: test)
func TestSetupPlans(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// Get mode from test args (defaults to "test")
mode := "test"
// Note: In real usage, you'd pass mode via environment variable:
diff --git a/internal/license/payment/subscription_test.go b/internal/license/payment/subscription_test.go
index d6accd9c9..e3a278e63 100644
--- a/internal/license/payment/subscription_test.go
+++ b/internal/license/payment/subscription_test.go
@@ -8,6 +8,7 @@ import (
)
func TestCreateSubscription(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, get a plan to subscribe to
planList, err := GetAllPlans("test")
if err != nil {
@@ -56,6 +57,7 @@ func TestCreateSubscription(t *testing.T) {
}
func TestGetAllSubscriptions(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
subList, err := GetAllSubscriptions("test")
if err != nil {
t.Fatalf("Error fetching all subscriptions: %v", err)
@@ -73,6 +75,7 @@ func TestGetAllSubscriptions(t *testing.T) {
}
func TestGetSubscriptionByID(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, get all subscriptions to get a valid subscription ID
subList, err := GetAllSubscriptions("test")
if err != nil {
@@ -100,6 +103,7 @@ func TestGetSubscriptionByID(t *testing.T) {
}
func TestUpdateSubscriptionQuantity(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, create a subscription
planList, err := GetAllPlans("test")
if err != nil {
@@ -157,6 +161,7 @@ func TestUpdateSubscriptionQuantity(t *testing.T) {
}
func TestCancelSubscription(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, create a subscription
planList, err := GetAllPlans("test")
if err != nil {
@@ -208,6 +213,7 @@ func TestCancelSubscription(t *testing.T) {
}
func TestCancelSubscriptionAtCycleEnd(t *testing.T) {
+ skipWithoutRazorpayTestKeys(t)
// First, create a subscription
planList, err := GetAllPlans("test")
if err != nil {
diff --git a/internal/mcpagent/agent.go b/internal/mcpagent/agent.go
index 7773e3e42..f45660bc9 100644
--- a/internal/mcpagent/agent.go
+++ b/internal/mcpagent/agent.go
@@ -181,6 +181,23 @@ func (a *Agent) RunTurnWithArtifacts(ctx context.Context, history []HistoryEntry
// single LLM call returns SQL + chart specs for up to 5
// interpretations. No plan→finalize two-step.
clog.BranchSelected("analytics", 0, 0)
+
+ // This branch never runs through runStepLoop (which otherwise
+ // does this swap - see its own comment), so it has to do the
+ // same history[0] swap itself. Without it, a turn landing here
+ // right after an action/product_guidance turn keeps that
+ // turn's stale system prompt (missing the SQL rules, or
+ // leaking the tool list into an analytics turn).
+ analyticsSystemPrompt := a.interpretPrompt()
+ switch {
+ case len(history) == 0:
+ history = append(history, HistoryEntry{"role": "system", "content": analyticsSystemPrompt})
+ case history[0]["role"] == "system":
+ history[0] = HistoryEntry{"role": "system", "content": analyticsSystemPrompt}
+ default:
+ history = append([]HistoryEntry{{"role": "system", "content": analyticsSystemPrompt}}, history...)
+ }
+
text, artifacts, debugArt, err := a.runMultiInterpret(ctx, userText, clog, a.mcpSession.OrgID, a.mcpSession.OrgName)
if err != nil {
return text, history, artifacts, debugArt, err
diff --git a/internal/mcpagent/analytics.go b/internal/mcpagent/analytics.go
index 6d83955a3..05a51dd8e 100644
--- a/internal/mcpagent/analytics.go
+++ b/internal/mcpagent/analytics.go
@@ -1427,18 +1427,29 @@ var DefaultAIErrorSuggestedQuestions = []SuggestedQuestionCategory{
// rightly read as a bug, because it is one.
func assembleAnalyticsResponse(reports []vlrender.VegaLiteReport, notes []string, hasArtifacts bool) string {
if len(reports) == 0 {
- if hasArtifacts {
- return "I've put the results in a file you can download."
+ if len(notes) == 0 {
+ if hasArtifacts {
+ // A file-only turn still needs words. Returning "" renders
+ // as an empty bubble next to a download the user has no
+ // context for.
+ return "I've put the results in a file you can download."
+ }
+ return NoDataAnalyticsResponseText
}
- return NoDataAnalyticsResponseText
+ return strings.Join(notes, "\n\n")
}
payload, err := json.Marshal(map[string]any{"reports": reports})
if err != nil {
log.Error().Err(err).Msg("failed to marshal analytics reports")
- return NoDataAnalyticsResponseText
+ return strings.Join(notes, "\n\n")
+ }
+ out := string(payload)
+ if len(notes) > 0 {
+ // Notes precede the JSON: the render path strips the JSON block and
+ // keeps surrounding prose as the message text.
+ out = strings.Join(notes, "\n\n") + "\n" + out
}
- // Notes (skip reasons) are only in debug artifacts, not in chat response.
- return string(payload)
+ return out
}
// rowsToPreviewCSV converts rows to a CSV string for debug preview.
@@ -1688,10 +1699,23 @@ func (a *Agent) runMultiInterpret(
interps, ok := parseInterpretations(raw)
if !ok {
- log.Warn().Str("raw_preview", truncateContent(raw, 200)).Msg("could not parse interpretations from LLM response")
+ // One corrective retry, the same budget the SQL repair phases already
+ // get (see repairSQL) - recovers truncated/malformed replies instead
+ // of giving up the whole report on the first bad JSON.
+ log.Warn().Str("raw_preview", truncateContent(raw, 200)).Msg("could not parse interpretations from LLM response, retrying with correction")
+ retryUserMsg := userMsg + "\n\nYour previous reply could not be parsed:\n" + truncateContent(raw, 500) +
+ "\n\nReply again with ONLY a valid JSON object of the exact {\"analytics_plan\": [...]} shape described above - no prose, no truncation, nothing else."
+ retryRaw, retryErr := a.completeOnce(ctx, clog, 2, "interpret", "multi", 2, system, retryUserMsg)
+ if retryErr == nil {
+ raw = retryRaw
+ debug.LLMRawResponse = raw
+ interps, ok = parseInterpretations(raw)
+ }
+ }
+ if !ok {
+ log.Warn().Str("raw_preview", truncateContent(raw, 200)).Msg("could not parse interpretations from LLM response after retry")
debug.RawLLMError = "Failed to parse LLM output as JSON (often caused by max token limits). Raw output:\n" + raw
- formatMsg := "> **Model Output Error**\n> \n> The AI returned data in an unexpected format. Please try rephrasing your question."
- return formatMsg, nil, debug, nil
+ return "I could not work out a full query for that - try rephrasing it.", nil, debug, nil
}
debug.Interpretations = interps
diff --git a/internal/mcpagent/analytics_pipeline_test.go b/internal/mcpagent/analytics_pipeline_test.go
index 9ad129b10..6e8f5c105 100644
--- a/internal/mcpagent/analytics_pipeline_test.go
+++ b/internal/mcpagent/analytics_pipeline_test.go
@@ -460,12 +460,12 @@ func TestAnalyticsPipelineProducesCSV(t *testing.T) {
t.Fatalf("expected 1 CSV artifact, got %d", len(artifacts))
}
// A CSV-only turn (no chart, no notes) must not also claim nothing was
- // found - the file itself, delivered separately, IS the answer. Regression
- // guard for the exact contradiction seen in production: a correctly
- // attached CSV alongside the chat text "I could not find anything to
- // answer that."
- if text != "" {
- t.Fatalf("expected empty response text alongside the CSV artifact, got %q", text)
+ // found - the file itself, delivered separately, IS the answer. An empty
+ // response text renders as a context-free empty bubble next to the
+ // download, so a short acknowledgement is used instead (never the
+ // contradictory "I could not find anything to answer that.").
+ if text != "I've put the results in a file you can download." {
+ t.Fatalf("expected the file-download acknowledgement alongside the CSV artifact, got %q", text)
}
art := artifacts[0]
if art.Filename != "completed.csv" {
diff --git a/internal/mcpagent/classify_test.go b/internal/mcpagent/classify_test.go
index d84f2e22d..10dcaa772 100644
--- a/internal/mcpagent/classify_test.go
+++ b/internal/mcpagent/classify_test.go
@@ -6,6 +6,10 @@ import (
"strings"
"testing"
+ "time"
+
+ "github.com/livereview/internal/aiconnectors"
+ "github.com/livereview/internal/database"
storageanalytics "github.com/livereview/storage/analytics"
"github.com/tmc/langchaingo/llms"
)
@@ -146,8 +150,24 @@ func (nopAnalyticsEngine) Query(context.Context, string, int) (*storageanalytics
// database/tool-server behind it - every case below is designed to be
// resolved by the guard's own validation (rejecting a table that isn't in
// the catalog) or to never call a tool, so nothing here needs live
-// infrastructure.
-func dispatchTestAgent(prov *recordingProvider) *Agent {
+// infrastructure. It still has to wait on the real package-wide schema
+// index, though: RunTurnWithArtifacts refuses any turn up front when
+// analyticsEnabled() && !schemaIndexReady() (agent.go), and that index is a
+// process-wide singleton shared with the testAgent-based tests in
+// analytics_pipeline_test.go - without this wait, this test is a race
+// against whichever test happens to trigger the (slow, first-time) build.
+func dispatchTestAgent(t *testing.T, prov *recordingProvider) *Agent {
+ t.Helper()
+ if dsn, err := database.LoadDatabaseURL(); err == nil {
+ InitSchemaIndex(dsn)
+ }
+ if schemaIdx != nil {
+ select {
+ case <-schemaIdx.Ready():
+ case <-time.After(60 * time.Second):
+ }
+ }
+
session := &MCPSession{
OrgID: 1,
OrgName: "TestOrg",
@@ -171,21 +191,22 @@ func dispatchTestAgent(prov *recordingProvider) *Agent {
// this replaced), turns 2 and 3 would still see turn 1's prompt.
func TestDispatchSwapsSystemPromptPerTurn(t *testing.T) {
prov := &recordingProvider{replies: []string{
- // Turn 1: chat
- `{"response":"chat"}`,
+ // Turn 1: product_guidance (the old "chat" shape, renamed)
+ `{"response":"product_guidance"}`,
"Hi! I'm Livi, how can I help you?",
// Turn 2: action (no tool call in the reply - just confirms the
// prompt/tools selection, not the tool-calling loop itself, which
// is unchanged and already covered elsewhere)
`{"response":"action"}`,
"Which repository would you like me to review?",
- // Turn 3: count_query - a table outside the catalog so the guard
- // rejects it deterministically, without touching a database.
- `{"response":"count_query"}`,
+ // Turn 3: analytics (the old "count_query" shape, renamed) - a table
+ // outside the catalog so the guard rejects it deterministically,
+ // without touching a database.
+ `{"response":"analytics"}`,
`{"analytics_plan":[{"id":"r1","question":"bad","count_sql":"SELECT count(*) FROM secret_table"}]}`,
"SELECT count(*) AS n FROM public.reviews", // repair attempt, also rejected
}}
- agent := dispatchTestAgent(prov)
+ agent := dispatchTestAgent(t, prov)
var history []HistoryEntry
@@ -300,13 +321,13 @@ func TestDispatchSwapsSystemPromptPerTurn(t *testing.T) {
// UI would just render raw JSON. See looksLikeUnrecognizedJSON.
func TestChatBranchRejectsFabricatedJSON(t *testing.T) {
prov := &recordingProvider{replies: []string{
- `{"response":"chat"}`,
+ `{"response":"product_guidance"}`,
// The model invents its own ad hoc chart shape instead of answering
// in prose - none of parseToolCalls/parseAnalyticsPlan recognize it.
`{"chart": {"title": "x"}, "learning": {"title": "y"}}`,
"I don't have live data for that right now - try asking it as a data question.",
}}
- agent := dispatchTestAgent(prov)
+ agent := dispatchTestAgent(t, prov)
text, _, _, _, err := agent.RunTurnWithArtifacts(context.Background(), nil, "are engineers using this daily?", "s1", "test")
if err != nil {
@@ -334,7 +355,7 @@ func TestCountQueryBranchRejectsTruncatedJSON(t *testing.T) {
`{"analytics_plan": [{"id": "r1", "question": "q", "count_sql": "SELECT count(*) FROM reviews`,
"I could not work out a full query for that - try rephrasing it.",
}}
- agent := dispatchTestAgent(prov)
+ agent := dispatchTestAgent(t, prov)
text, _, _, _, err := agent.RunTurnWithArtifacts(context.Background(), nil, "what's our billing status?", "s1", "test")
if err != nil {
@@ -354,13 +375,20 @@ func TestDispatchClassifyFailureDegradesToChat(t *testing.T) {
"Still not sure what you mean.", // classify call attempt 2 (the one corrective retry): also unparseable
"Sure, I'm happy to help with that.", // the degraded chat-branch call
}}
- agent := dispatchTestAgent(prov)
+ agent := dispatchTestAgent(t, prov)
text, history, _, _, err := agent.RunTurnWithArtifacts(context.Background(), nil, "hello", "s1", "test")
if err != nil {
t.Fatalf("expected the turn to degrade gracefully, not error: %v", err)
}
- if text != "Sure, I'm happy to help with that." {
+ // A genuine classify-call error (not just an unparseable shape) now
+ // surfaces an error banner ahead of the degraded reply, so the user
+ // knows call #0 failed instead of silently getting a plain chat answer
+ // - see agent.go's classifyErr handling.
+ wantPrefix := LLMErrorTemplates[aiconnectors.ErrCategoryUnknown].Message + "\n\n" +
+ "However, here is some general product guidance:\n\n---\n\n" +
+ "Sure, I'm happy to help with that."
+ if text != wantPrefix {
t.Fatalf("unexpected response %q", text)
}
if len(prov.toolsPerCall) != 3 {
diff --git a/internal/mcpagent/laws_contract_test.go b/internal/mcpagent/laws_contract_test.go
index 828b5e185..ccf0c8b20 100644
--- a/internal/mcpagent/laws_contract_test.go
+++ b/internal/mcpagent/laws_contract_test.go
@@ -36,7 +36,7 @@ func TestLawbookCarriesMachineContracts(t *testing.T) {
text string
want []string
}{
- {"classify", p.classify, []string{`{"response"`, "analytics", "product_guidance", "`action`", "unclassified"}},
+ {"classify", p.classify, []string{`"response":`, "analytics", "product_guidance", "`action`", "unclassified"}},
{"plan", p.planTail, []string{"analytics_plan", "count_sql", "`id`"}},
{"finalize", p.finalizeTail, []string{"response_type", "data_sql", "encoding", "csv"}},
} {
diff --git a/internal/provider_input/azuredevops/azuredevops_conversion_test.go b/internal/provider_input/azuredevops/azuredevops_conversion_test.go
index da2ed7e4b..262f1f043 100644
--- a/internal/provider_input/azuredevops/azuredevops_conversion_test.go
+++ b/internal/provider_input/azuredevops/azuredevops_conversion_test.go
@@ -72,7 +72,7 @@ func TestConvertAzureDevOpsCommentEvent_RealPayload(t *testing.T) {
if event.Comment == nil {
t.Fatal("event.Comment is nil, want populated")
}
- if got, want := event.Comment.Body, "explain the issue\n"; got != want {
+ if got, want := event.Comment.Body, "explain the issue"; got != want {
t.Errorf("Comment.Body = %q, want %q", got, want)
}
if got, want := event.Comment.ID, "2"; got != want {
diff --git a/internal/provider_input/bitbucket/bitbucket_comment_hierarchy_test.go b/internal/provider_input/bitbucket/bitbucket_comment_hierarchy_test.go
index bbe102bab..90efc6a59 100644
--- a/internal/provider_input/bitbucket/bitbucket_comment_hierarchy_test.go
+++ b/internal/provider_input/bitbucket/bitbucket_comment_hierarchy_test.go
@@ -40,7 +40,7 @@ func TestBitbucketCommentHierarchyFromCapture(t *testing.T) {
provider := server.BitbucketProviderV2()
fileStore := storagecore.NewFileOpsStore()
- capturePath := filepath.Join("captures", "bitbucket", "20251014-194407", "bitbucket-webhook-pullrequest_comment_created-body-0025.json")
+ capturePath := filepath.Join("docs", "raw", "captures", "bitbucket", "20251014-194407", "bitbucket-webhook-pullrequest_comment_created-body-0025.json")
body, err := fileStore.ReadFile(capturePath)
if err != nil {
t.Fatalf("failed to read capture file %s: %v", capturePath, err)
diff --git a/internal/provider_output/github/output_client_test.go b/internal/provider_output/github/output_client_test.go
index 7a2c381be..459d05d15 100644
--- a/internal/provider_output/github/output_client_test.go
+++ b/internal/provider_output/github/output_client_test.go
@@ -61,7 +61,7 @@ func TestPostCommentReply_IssueCommentUsesIssueEndpoint(t *testing.T) {
require.NoError(t, client.PostCommentReply(event, "token", "hello"))
require.Equal(t, "https://api.github.com/repos/owner/repo/issues/42/comments", capturedURL)
- require.Equal(t, "hello", capturedBody["body"])
+ require.Equal(t, "> @livereviewbot please help\n\n\nhello", capturedBody["body"])
require.NotContains(t, capturedBody, "in_reply_to")
}
From 132735c5dfe93c7b38863a33f95e6c131ba3e2a4 Mon Sep 17 00:00:00 2001
From: Shrijith Venkatramana
Date: Thu, 29 Oct 2026 17:28:25 +0000
Subject: [PATCH 3/4] Add SSO deprovisioning feature screenshots for PR
description
Settings SSO tab showing the new reconciliation-check toggle (OIDC) and
SCIM Provisioning section with a freshly generated, reveal-once token,
captured against a running local instance.
LiveReview Pre-Commit Check: skipped (iter:1, coverage:0%)
---
.git-hooks-binary-allowlist | 2 ++
.../04-settings-sso-scim-baseline.png | Bin 0 -> 191444 bytes
...-settings-sso-reconciliation-scim-token.png | Bin 0 -> 209929 bytes
3 files changed, 2 insertions(+)
create mode 100644 assets/screenshots/2026-10-29/04-settings-sso-scim-baseline.png
create mode 100644 assets/screenshots/2026-10-29/05-settings-sso-reconciliation-scim-token.png
diff --git a/.git-hooks-binary-allowlist b/.git-hooks-binary-allowlist
index 4dad983f8..82b1aab9c 100644
--- a/.git-hooks-binary-allowlist
+++ b/.git-hooks-binary-allowlist
@@ -69,3 +69,5 @@ 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
+assets/screenshots/2026-10-29/04-settings-sso-scim-baseline.png
+assets/screenshots/2026-10-29/05-settings-sso-reconciliation-scim-token.png
diff --git a/assets/screenshots/2026-10-29/04-settings-sso-scim-baseline.png b/assets/screenshots/2026-10-29/04-settings-sso-scim-baseline.png
new file mode 100644
index 0000000000000000000000000000000000000000..c40707a8d7f458924bc889716eb2d9e24887b9a8
GIT binary patch
literal 191444
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