Guides

SSO, RBAC & Audit Log

Team-tier deployments add single sign-on (SAML 2.0 and OIDC), a four-role RBAC model (viewer < member < admin < owner), and an append-only auth audit trail. All three ship inside the web container's auth layer — self-hosted, no external identity service of ours in the path. This guide covers the environment-variable surface that turns them on and the security invariants that hold once they are.

What ships, and where

SSO, RBAC, and the audit log live in the ghost-web container — the same control-plane process that serves the dashboard and the /v1/* API. There is no separate auth service to deploy and no callback to a Gostly-hosted endpoint: the SAML SP and the OIDC client run in your own stack and talk only to your identity provider. Multi-user role management and the team activity surface are gated behind the multi_user_workspace and team_activity features (Team tier).

SSO

SAML 2.0 and OIDC auth-code flow, selected per deployment via GOSTLY_AUTH_MODE. Password auth stays available unless you disable it.

RBAC

Four ranked roles enforced on every mutating endpoint. IdP group claims map to roles; owner is never granted by a group.

Audit log

An append-only auth_audit_events table records every login, logout, and user-management action — successes and failures alike.

Single-tenant deployment

Each licensed deployment is single-tenant: the tenant id defaults to default and one license key gates one stack. Per-tenant Postgres row-level-security policies are defined as a defense-in-depth layer, but the default configuration is not engine-enforced multi-tenancy — isolation comes from the single-tenant deployment model with RLS as a defined policy layer beneath it. See the security model for the full posture.

Selecting auth methods

The set of enabled login methods is a comma-separated list in GOSTLY_AUTH_MODE. The default is password, which preserves the local-admin login. Adding saml or oidc enables that backend alongside the password form; listing only an SSO method hides the password form entirely.

# Password only (default)
GOSTLY_AUTH_MODE=password

# Password form + an SSO button on the login page
GOSTLY_AUTH_MODE=password,saml

# SSO only — the password form is hidden in the dashboard
GOSTLY_AUTH_MODE=oidc

The dashboard's login page renders itself from GET /v1/auth/methods, which returns the enabled backends (and their display names) with no secrets. A first-boot deployment always has at least password auth enabled, with an admin user bootstrapped from GOSTLY_ADMIN_EMAIL + GOSTLY_ADMIN_PASSWORD.

Sessions, not bearer tokens for the dashboard

On success any IdP mints a server-side session: a 256-bit random token is set as the gostly_session cookie (HttpOnly, Secure, SameSite=Lax), and the server stores only its SHA-256 hash. A database leak yields no usable session credentials. Session TTL is GOSTLY_AUTH_SESSION_TTL_HOURS (default 12). The data-plane control endpoints (/mocks, /v1/mode, …) are gated separately by the GHOST_API_KEY, not by the login session.

SAML 2.0

The SAML backend is a standard SP-initiated flow. Configure the SP and point it at your IdP's metadata; the SP exposes its own metadata XML for you to import on the IdP side.

GOSTLY_AUTH_MODE=password,saml

# IdP — supply either a metadata URL or inline metadata XML
GOSTLY_SAML_IDP_METADATA_URL=https://idp.example.com/app/metadata
# (or) GOSTLY_SAML_IDP_METADATA_XML=<...>
GOSTLY_SAML_IDP_ENTITY_ID=https://idp.example.com/saml

# SP — this deployment
GOSTLY_SAML_SP_ENTITY_ID=https://gostly.your-co.internal/saml
GOSTLY_SAML_SP_ACS_URL=https://gostly.your-co.internal/v1/auth/saml/acs

# Assertion attribute names (defaults shown)
GOSTLY_SAML_EMAIL_ATTRIBUTE=email
GOSTLY_SAML_NAME_ATTRIBUTE=displayName
GOSTLY_SAML_GROUP_ATTRIBUTE=memberOf

The SAML surface is three endpoints:

GET /v1/auth/saml/metadata

Renders the SP metadata XML. Import this on the IdP side to register Gostly as a service provider.

GET /v1/auth/saml/login

Initiates the AuthnRequest and 303-redirects the browser to the IdP. Accepts a relay_state query param for post-login return.

POST /v1/auth/saml/acs

The Assertion Consumer Service callback. Validates the signed SAMLResponse, resolves the user, mints a session, and returns to a same-origin RelayState path (open-redirects are rejected).

The IdP's group attribute (memberOf by default) feeds the group-to-role mapping described under RBAC.

OIDC

The OIDC backend runs the authorization-code flow with provider discovery and id_token validation. Point it at your issuer and supply the client credentials registered with your IdP.

GOSTLY_AUTH_MODE=oidc

GOSTLY_OIDC_ISSUER=https://idp.example.com
GOSTLY_OIDC_CLIENT_ID=gostly
GOSTLY_OIDC_CLIENT_SECRET=<client-secret>
GOSTLY_OIDC_REDIRECT_URI=https://gostly.your-co.internal/v1/auth/oidc/callback
GOSTLY_OIDC_SCOPES="openid email profile groups"

# Claim names (defaults shown)
GOSTLY_OIDC_EMAIL_CLAIM=email
GOSTLY_OIDC_NAME_CLAIM=name
GOSTLY_OIDC_GROUP_CLAIM=groups

Two endpoints carry the flow:

GET /v1/auth/oidc/login

Builds the authorization URL, sets a short-lived gostly_oidc_state cookie (HttpOnly, Secure, 10-minute Max-Age) for CSRF protection, and 303-redirects to the IdP.

GET /v1/auth/oidc/callback

Exchanges the authorization code, validates the id_token against the discovered JWKS and the round-tripped state, resolves the user, mints a session, and clears the state cookie.

The groups claim feeds the same group-to-role mapping SAML uses — one mapping config covers both backends.

Just-in-time provisioning

SAML and OIDC share one provisioning path. When GOSTLY_AUTH_AUTO_PROVISION=true(the default), a first-time SSO login JIT-creates the local user; with it off, an admin must pre-create the user before SSO login is allowed. On every login the user's name, group memberships, and derived role are refreshed from the assertion — IdP-side group changes propagate to Gostly on the next login, with no manual re-sync.

Users are matched first by the IdP's stable subject identifier (so a user surviving an email change keeps their account), then by email. An optional email-domain allowlist gates provisioning entirely:

GOSTLY_AUTH_AUTO_PROVISION=true
GOSTLY_AUTH_ALLOWED_DOMAINS=your-co.com,your-co.internal

An assertion whose email domain is outside the allowlist is rejected before any user row is touched, and the rejection is recorded in the audit log.

RBAC — the four roles

Roles are ranked, not flat. A check is a floor comparison — "at least admin" rather than "exactly admin" — so a higher role always satisfies a lower requirement. Every mutating endpoint declares its required floor and returns a 403 for any user below the threshold.

viewer

rank 0

Read-only across mocks, services, recordings, and dashboards. The default role for an SSO user whose groups map to nothing.

member

rank 1

Read + write on services, mocks, and recordings. Cannot manage users.

admin

rank 2

Manage users, scrub config, license, and the audit log. The floor for every user-management mutation.

owner

rank 3

Full control plus owner-only actions. Bootstrap role; cannot be demoted while last in the tenant, and only an existing owner can grant or revoke owner.

Roles are mapped from IdP group claims per deployment via GOSTLY_AUTH_GROUP_TO_ROLE_MAP (JSON). A user gets the highest role any of their groups map to; a user with no matching group falls back to GOSTLY_AUTH_DEFAULT_ROLE (default viewer).

GOSTLY_AUTH_GROUP_TO_ROLE_MAP={"gostly-admins":"admin","engineering":"member"}
GOSTLY_AUTH_DEFAULT_ROLE=viewer

Owner cannot be granted by a group

The single inviolable mapping rule: no group claim can ever map a user to owner — a group literally named owner is silently demoted to admin. Owner is only ever granted by an existing owner through the dashboard. This closes the "add me to the owner group at the IdP" privilege-escalation path. A role change also revokes the user's active sessions, so an open browser tab can't keep an escalated role.

User-management endpoints (Team tier, behind multi_user_workspace):

GET /v1/users

List users in the tenant. Admin+.

POST /v1/users

Create a local-password user. Admin+.

PATCH /v1/users/{id}/role

Change a user's role. Admin+; owner-only for any change touching owner; cannot demote the last owner (409).

PATCH /v1/users/{id}/status

Suspend or reactivate. Admin+; cannot suspend the last owner. Suspending revokes the user's sessions.

DELETE /v1/users/{id}

Soft-delete a user. Admin+; cannot delete the last owner.

POST /v1/users/{id}/sessions/revoke

Kick a user — revoke all of their active sessions. Admin+.

Free and Pro deployments run single-user: the bootstrapped admin can log in on every tier, but the multi-user mutations above are gated to Team. Pair the role floor with a feature gate so an unlicensed tenant gets a license-shaped 403, not an auth-shaped one.

The append-only audit log

Every authentication and user-management action writes a row to the auth_audit_events table. The trail is append-only — the auth layer only ever inserts; it never updates or deletes a recorded event. Both successes and failures are captured, so failed-login telemetry is available for rate-limit and intrusion-detection use. Each row records the actor (user id + email), the event type, the IdP kind, client IP and user-agent (best-effort), a success flag, an error message on failures, a JSON metadata blob, and the timestamp.

Representative event types written today:

Login / logout

login.password.success, login.saml.success, login.oidc.success, the matching .fail variants, and logout.

User management

user.created, user.role.changed (metadata carries the new role), user.suspended, user.deleted, and session.revoked.admin.

The event-type set is enforced by a database CHECKconstraint, so a typo'd or unrecognised event type fails the insert rather than landing silently. Writing the audit row is best-effort and isolated: an audit-write failure rolls back its own transaction and never blocks or fails the login it was recording.

What is and isn't here

The audit log lives in the same Postgres as the rest of the control-plane state, on your host volume — it never leaves the box. It is distinct from the operator activity feed (the activity_log table that backs GET /activity), which records mock-library operations rather than auth events. Retention and export of the auth trail are operator-owned: it's your database, so back it up and ship it to a SIEM with your existing Postgres tooling.

Production hardening checklist

  • Keep GOSTLY_AUTH_COOKIE_SECURE=true (the default). Only set it false for local-dev HTTP on localhost — never in a deployment reachable over the network.
  • Serve the dashboard over HTTPS. SAML and OIDC redirect URLs are derived from the request scheme, and the session cookie is Secure.
  • Scope provisioning with GOSTLY_AUTH_ALLOWED_DOMAINSso only your organisation's domains can JIT-create accounts, and consider GOSTLY_AUTH_AUTO_PROVISION=false for tightly-controlled tenants.
  • Map IdP groups deliberately. Default everyone to viewer and grant member/admin by group — never widen the default role.
  • Keep more than one owner so the last-owner protection never locks you out of an owner-only action.
  • Tune GOSTLY_AUTH_SESSION_TTL_HOURS to your policy; sessions are revocable centrally (admin kick, role change, suspend, delete).

Next steps