Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .git-hooks-binary-allowlist
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 2 additions & 1 deletion config/plan_catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,8 @@
"feature_flags": [
"unlimited_reviews",
"hosted_auto_model",
"sso"
"sso",
"scim"
],
"trial_policy": {
"enabled": false,
Expand Down
23 changes: 23 additions & 0 deletions db/migrations/20261029150000_create_scim_tokens.sql
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

Ensure token_hash uses a strong, salted hashing algorithm. This is critical for security.

Suggestions:

  1. Confirm the application-level hashing implementation uses bcrypt, Argon2, or a similar KDF.
  2. Document the hashing algorithm used for SCIM tokens.

token_prefix VARCHAR(16) NOT NULL,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Verify token_prefix length is sufficient for display but short enough to not reveal too much of the token.

Suggestions:

  1. Confirm the application generates a prefix that is not guessable and does not expose sensitive information.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Consider adding a revoked_by_user_id column for full auditability of token revocation events.

Suggestions:

  1. Add revoked_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;
42 changes: 42 additions & 0 deletions db/migrations/20261029150100_create_sso_identities.sql
Original file line number Diff line number Diff line change
@@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

A VARCHAR(512) for subject is generally sufficient, but confirm IdP sub claims do not exceed this length.

Suggestions:

  1. Verify maximum sub claim length from common IdPs (Okta, Azure AD, Google Workspace) to prevent truncation.

oidc_refresh_token_enc TEXT,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

Ensure strong encryption is used for oidc_refresh_token_enc at the application layer.

Suggestions:

  1. Confirm the encryption method uses authenticated encryption (e.g., AES-256-GCM) with a robust key management strategy.
  2. Avoid storing encryption keys directly in code or environment variables without proper rotation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: warning

Consider if TEXT is the most efficient storage for encrypted tokens, or if BYTEA would be more appropriate.

Suggestions:

  1. If the encrypted token is binary data, BYTEA might offer slight storage or performance benefits over 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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The unique constraint on (org_id, provider_type, subject) is crucial for preventing identity collisions and ensuring correct mapping.

);

CREATE INDEX IF NOT EXISTS idx_sso_identities_user_id ON sso_identities(user_id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

An index on user_id is beneficial for quickly finding all SSO identities associated with a specific user.


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;
17 changes: 17 additions & 0 deletions db/migrations/20261029150200_add_sso_reconciliation_columns.sql
Original file line number Diff line number Diff line change
@@ -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')),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The CHECK constraint on reconciliation_interval is a good practice for data validation.

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;
196 changes: 194 additions & 2 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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])))
);


Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -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: -
--
Expand Down Expand Up @@ -10301,4 +10490,7 @@ INSERT INTO public.schema_migrations (version) VALUES
('20261025120100'),
('20261025130000'),
('20261029104047'),
('20261029134500');
('20261029134500'),
('20261029150000'),
('20261029150100'),
('20261029150200');
7 changes: 7 additions & 0 deletions docs/audit-events.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: critical

Ensure auth.session.revoke is triggered whenever a user's sessions are invalidated, especially during deactivation or password resets.

Suggestions:

  1. Verify all code paths that revoke user sessions explicitly log this event.

| `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. |
Expand Down Expand Up @@ -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. |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Severity: info

The new SCIM audit events are crucial for tracking identity lifecycle management and detecting suspicious activity.

| `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. |
Expand Down
Loading
Loading