47 Commits

Author SHA1 Message Date
Dan Milne
c85d25c4b9 Untrack SECURITY_REVIEW_TODO.md and gitignore it
Some checks are pending
CI / scan_ruby (push) Waiting to run
CI / scan_js (push) Waiting to run
CI / scan_container (push) Waiting to run
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / system-test (push) Waiting to run
Keep the findings tracker local-only; it should not be published.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 23:09:17 +10:00
Dan Milne
1b0d323572 Bump version to 0.16.3
Some checks failed
Build and publish image / prepare (push) Has been cancelled
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Build and publish image / build (amd64, linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and publish image / build (arm64, linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build and publish image / merge (push) Has been cancelled
Ships the access-check GET-form fix (782e197) as a published image.
v0.16.2 was bumped before the version-bump build workflow existed, so it
never built; this bump triggers the build via the registered push trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:29:39 +10:00
Dan Milne
d1d626c540 Rework build workflow to trigger on version bump + manual dispatch
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Port the build pipeline from the splat sibling project. Instead of
triggering on git tags, the image now builds when
config/initializers/version.rb changes on main — a version bump IS the
release — plus a workflow_dispatch button for manual builds.

Reads Clinch::VERSION, tags the image :vX.Y.Z, and moves :latest only
for non-pre-release versions. Also builds multi-arch (amd64 + arm64) on
native runners and stitches a manifest, replacing the amd64-only build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:08:04 +10:00
Dan Milne
782e197d91 Fix access check form: use GET so results render
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Build and publish image / build (push) Has been cancelled
The access check form POSTed and re-rendered :new with a 200 HTML
response, which Turbo rejects ("Form responses must redirect to
another location"), so the result panel never appeared. Since the
check is a read-only query, switch to a GET form and fold the lookup
into the new action. Results are now bookmarkable via the URL.

Bump version to 0.16.2.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:42:57 +10:00
Dan Milne
020759bfb3 Fix invalid require-trusted-types-for CSP directive
require-trusted-types-for only accepts 'script'; emitting 'none'
produced an invalid directive that browsers rejected. Omit the
directive entirely to leave Trusted Types unenforced (needed for
WebAuthn). Bump version to 0.16.1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:39:35 +10:00
Dan Milne
85f50bfc96 Add GitHub Actions workflow to build and publish image to GHCR
Builds the production Docker image and pushes it to
ghcr.io/dkam/clinch on pushes to main (edge + sha tags) and on v*
release tags (vX.Y.Z, vX.Y, latest). amd64 only, with GHA layer caching.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 14:02:29 +10:00
Dan Milne
b55139eb1c Fix Sentry config to use Sentry.init API
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
The Sentry setup used a config.sentry.* accessor that sentry-rails has
never provided, so booting with SENTRY_DSN set raised NoMethodError
during environment load (e.g. db:prepare). The code only ran once a DSN
was configured, which is why it surfaced in production now.

Rewrites config/initializers/sentry.rb to call Sentry.init, the actual
sentry-ruby API, and removes the duplicate broken block from
production.rb. Verified production boots with SENTRY_DSN set
(Sentry.initialized? == true) and that the no-DSN path still early-returns.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:57:26 +10:00
Dan Milne
8f578ed3f4 Upgrade Ruby to 4.0.5
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:28 +10:00
Dan Milne
aa5736ddab Update gems and fix lint to clear CI failures
Bumps dependencies (jwt 3.2.0, puma 8.0.2, net-imap 0.6.4.1 and others
via bundle update) to resolve bundler-audit advisories, and applies
standardrb autofixes so the lint job passes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 13:51:23 +10:00
Dan Milne
49068aa344 Add tests
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-06-15 08:22:23 +10:00
Dan Milne
07ea031b61 Remove hardcoded internal IP from production hosts allowlist
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
192.168.2.246 was redundant with the 192.168.0.0/16 regex already in the
CLINCH_ALLOW_INTERNAL_IPS block, and baked a specific lab IP into the repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:55:02 +10:00
Dan Milne
209c5496d8 Fix asset precompile boot and bump version to 0.16.0
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
The CLINCH_HOST initializer raised during `assets:precompile` in the
Docker build, where no real host is set. Skip the check when
SECRET_KEY_BASE_DUMMY is present (the build-time precompile step);
deployed boots still require CLINCH_HOST.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 23:53:09 +10:00
Dan Milne
d49e7ce4f5 Move CSP to nonces; remove unsafe-inline from script-src and style-src
unsafe-inline on script-src neutered CSP as an XSS defense on the login and OAuth
consent pages (the highest-value targets in an IdP). Switch to a per-response
nonce for both script-src and style-src and drop unsafe-inline entirely.

- Add a random per-response nonce generator and apply it to script-src/style-src.
- Remove :unsafe_inline from both directives.
- Nonce the one hand-written inline script (dark-mode detection in the layout).
- Convert the 2 static style="display:none" attributes to class="hidden" (their
  runtime toggle is done via element.style in JS, which CSP does not govern).

importmap-rails (2.2.3) already stamps the nonce onto its generated inline
importmap/module/preload tags, and Turbo (2.0.23) reads csp_meta_tag for its
injected <style>, so no other view changes were needed. Adds an integration test
asserting the enforcing header carries nonces, omits unsafe-inline, and that the
inline script's nonce matches the header.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:42:28 +10:00
Dan Milne
44892e3301 Make WebAuthn clone detection actually block, and fix false positives
Two problems with sign-count clone detection:

- suspicious_sign_count? flagged the case where both the stored and presented
  counts are 0. Most synced passkeys (Apple/Google) report 0 every time, so every
  legitimate sign-in was flagged — drowning real signals in noise. Per WebAuthn
  §6.1.1 a 0 counter means "no counter"; only flag when BOTH counts are non-zero
  and the new one does not advance.

- On a suspicious count the controller only logged a warning and then continued
  to authenticate and overwrite the stored counter. A cloned credential therefore
  worked indefinitely. webauthn_verify now rejects the sign-in (no session, no
  counter update) and emails the user via a new SecurityMailer#suspicious_passkey_used.

Tests cover the corrected classification (synced/first-use/normal vs equal/
decreasing) and the new alert email.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:28:38 +10:00
Dan Milne
24266872f9 Revoke access tokens too on refresh-token reuse detection
revoke_family! revoked only the refresh tokens in a rotation family. When reuse
of a revoked refresh token was detected (a token-theft signal), the access
tokens issued across that chain stayed valid at /userinfo until expiry — up to
the access-token TTL — so an attacker holding a stolen access token kept access.

revoke_family! now also revokes every access token referenced by the family's
refresh tokens. Adds a regression test: rotate once, reuse the revoked token,
and assert both the original and rotated-in access tokens are revoked.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:23:17 +10:00
Dan Milne
cd862c7cd7 Filter code params from logs (TOTP, backup, OAuth code, PKCE)
The TOTP and backup-code form field is named `code`, which was not covered by
the filter list, so live one-time codes landed in production logs. Adding :code
(partial match) also redacts the OAuth authorization `code` and PKCE
`code_verifier`/`code_challenge`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 20:21:41 +10:00
Dan Milne
89bd5f1432 Enforce account-active status across the auth lifecycle
active? was only checked at the password step of sign-in. A user disabled
afterwards could (a) still complete the 2FA step and mint a valid session, and
(b) keep using any existing session until natural expiry, because per-request
auth only checked session expiry, not user status.

Three enforcement points:
- Mid-flow guard: verify_totp and webauthn_verify re-check active? before
  start_new_session_for, clearing the pending session and rejecting if disabled.
- Request-time guard: find_session_by_cookie now uses Session.for_active_user,
  so a session whose user is disabled no longer authenticates (authoritative,
  catches any disable path including direct DB changes).
- Immediate cleanup: User#revoke_sessions_when_deactivated destroys a user's
  sessions when status changes away from active, so access is revoked everywhere
  at once rather than on the next request.

Tests cover the mid-flow TOTP rejection, request-time rejection of an existing
session after disable, session destruction on disable, and that unrelated
updates leave sessions intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:53:50 +10:00
Dan Milne
57d7d1f691 Anchor host-authorization regex to prevent look-alike domain bypass
The DNS-rebinding allowlist used /.*#{registrable_domain}/, which is unanchored:
for example.com it also matched evil-example.com, notexample.com,
example.computer, and example.com.attacker.com. Any of those hosts would pass
Rails' HostAuthorization middleware.

Anchor the pattern as /\A(.+\.)?DOMAIN\z/i so it matches only the registrable
domain and its subdomains (now also case-insensitively). Verified against a
real production boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:47:35 +10:00
Dan Milne
406a79d9eb Block SSRF via backchannel_logout_uri
backchannel_logout_uri was validated only for scheme/HTTPS, so an admin (or a
compromised admin account) could point it at internal infrastructure — cloud
metadata (169.254.169.254), loopback, or RFC1918 hosts — and every user logout
would fire a server-side POST there.

Add PrivateAddressCheck (app/lib) and apply it as defense-in-depth:
- Application validation rejects URIs whose host is, or is a literal, internal
  address (loopback / private / link-local / 0.0.0.0 / localhost / metadata
  hostnames). Fast, DNS-free, immediate admin feedback.
- BackchannelLogoutJob re-checks at request time WITH DNS resolution and aborts
  (no retry) if the host resolves to a non-public address — covering URIs that
  predate the validation and public hostnames pointed at internal IPs.

Tests cover the address classification, the model validation, and updates an
existing test that used a localhost logout URI.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:14:45 +10:00
Dan Milne
f38ac2ecc8 Prevent TOTP code replay within the drift window
verify_totp called ROTP without `after:`, so a captured 6-digit code stayed
valid for the full ~90s drift window and could be replayed in a separate
sign-in. Add a last_otp_at column, pass it as ROTP's `after:`, and persist the
matched timestep on success so a code (or any earlier one) cannot be reused.

Also fixes a latent bug surfaced by the new replay path: enable_totp! did
`self.backup_codes = generate_backup_codes`, reassigning backup_codes to the
plaintext return value (generate_backup_codes already stores the BCrypt hashes
internally). That stored backup codes in plaintext and broke verification.
enable_totp! is test-only today, but it is public and backup_codes is not
encrypted, so this is a real footgun. Now it just calls generate_backup_codes.

Rewrites the mislabeled "TOTP code cannot be reused" test to actually assert
that replaying an accepted code is rejected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:10:34 +10:00
Dan Milne
84ed462f40 Require CLINCH_HOST in deployed environments; drop request-host fallback
determine_base_url fell back to request.host when CLINCH_HOST was unset. Rails
resolves request.host from X-Forwarded-Host behind a trusted proxy, so a spoofed
header could make the forward-auth login redirect point at an attacker origin
(host-header phishing).

- Add config/initializers/clinch_host.rb: fail fast at boot in any non-local
  environment when CLINCH_HOST is blank. It anchors the OIDC issuer, WebAuthn
  RP ID, and login redirect, so it must be explicit, never inferred.
- determine_base_url now uses CLINCH_HOST (guaranteed in production) with a safe
  localhost default for dev/test, and never reads the request host.
- Simplify the spoofed-host regression test now that the fallback is safe.

Verified: production boot aborts with a clear message when CLINCH_HOST is blank,
and boots normally when set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:04:42 +10:00
Dan Milne
96a657e349 Validate X-Forwarded-Host before using it as a post-login redirect target
render_unauthorized built the post-login return URL directly from the
attacker-influenceable X-Forwarded-Host / X-Forwarded-Uri headers, stored it
in the session, and reflected it into the signin `rd`. After authentication
that URL is followed with allow_other_host, so a spoofed host was an open
redirect.

Now the forwarded URL is only honoured if it resolves to a known, active
forward-auth application (via validate_redirect_url); otherwise it falls back
to a validated `rd` or the IdP's base URL. Once render_unauthorized only ever
stores a validated value, the sessions_controller "supplement, don't replace"
behaviour is safe, so no change is needed there.

Two integration tests were asserting the old behaviour by reflecting
unregistered hosts (grafana.example.com, app.example.com); they now register
those domains as forward-auth apps so they exercise the real feature. Adds a
regression test that a spoofed X-Forwarded-Host is neither stored nor
reflected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 08:00:12 +10:00
Dan Milne
8a095e4939 Enforce group access on Bearer API key forward-auth at use-time
The ApiKey model only validates group access on creation (user_must_have_access
runs on create). The bearer path in /api/verify never re-checked, so a user
removed from an application's allowed groups kept access via an existing key
until it was manually revoked.

Add an app.user_allowed?(user) check to authenticate_bearer_token, matching the
session path, returning 401 when the user no longer has group access. Adds a
regression test that revokes membership after key creation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:54:48 +10:00
Dan Milne
703d24e4e4 Fix ForwardAuth fail-open and consent CSRF bypass
Two HIGH-severity findings from the security review:

- ForwardAuth: when no host header was present, /api/verify skipped the
  application lookup and group check entirely, returning 200 with identity
  headers (including all of the user's groups). This bypassed per-domain
  access control. Now fails closed with 403, and the unreachable
  DEFAULT_HEADERS fallback (the bypass path) is removed so headers are
  always scoped to a resolved, active application.

- OIDC: the consent endpoint was in the verify_authenticity_token skip
  list, so a forged cross-site POST could silently grant OAuth scopes.
  Removed :consent from the skip list (the form already embeds the token).

Adds regression tests for both: fail-closed with no identity headers when
host is absent, and 422 on a tokenless consent POST.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 07:52:56 +10:00
Dan Milne
2843790cef Apps index access column + summary + admin access checker
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
The Applications index used to render "All users" whenever an app had
no allowed_groups; under default-deny that's the opposite of the truth.
Replaced with a "No one" badge and, when groups are present, a
"N users · M groups" cell so the access reality is visible at a glance.

Added a small stats strip above the apps table: applications, users
with access, and groups granting access. Backed by preloaded counts in
the controller to avoid N+1.

Added /admin/access — a small "Access check" tool that takes a user
and an application and reports whether the user can reach it, with the
granting group(s) when allowed, and the specific reason when not
(inactive app/user, no allowed groups, or no shared group). Wired into
the admin sidebar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 18:38:56 +10:00
Dan Milne
0e9ec71013 Link the user show page from the admin users index
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
The admin users index only exposed Edit / Delete actions per row, so
the Accessible-applications panel on the user show page was unreachable
without typing the URL by hand. Adds a View action and turns the email
into a link to the show page — mirroring how the applications and
groups indexes already work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 18:26:55 +10:00
Dan Milne
fe68f6e81e Use Tailwind dark: toggles for dark-mode icons
The previous <picture media="(prefers-color-scheme: dark)"> only fires
when the OS is in dark mode. Clinch toggles dark mode with a class on
<html> via dark_mode_controller and Tailwind's @custom-variant dark
(&:where(.dark, .dark *)), so the picture source never swapped when
users clicked the in-app theme toggle. Render both <img> tags and
use Tailwind's dark:hidden / hidden dark:block so the swap follows
whatever strategy Tailwind is configured for.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 17:19:36 +10:00
Dan Milne
c5ab7dc2a5 Compact icon uploader shared between light and dark icon fields
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Extracts the icon dropzone into a reusable partial so the dark mode
icon gets the same upload / drag-and-drop / paste affordances as the
light icon. Slims the dropzone to a single-row layout (small cloud
icon plus Upload / drag-and-drop / paste hint) and a tiny format hint
below, instead of the previous tall vertically-centred block.
2026-06-07 17:13:52 +10:00
Dan Milne
bfad9c4e9d Generated monogram fallback + optional dark-mode icon per application
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
When an application has no icon attached, render a deterministic
monogram SVG instead of the generic picture-frame placeholder. Initials
are picked from capital letters in the name (ShelfLife -> SL); fall
back to the first two letters when fewer than two capitals exist
(Audiobookshelf -> AU). Background colour is hashed from the name for
stable per-app identity across visits.

Adds an optional second icon attachment, icon_dark, alongside the main
icon. When present, render a <picture> with a prefers-color-scheme:
dark source so the browser swaps automatically; when absent, the main
icon is used in both modes. The SVG sanitization, content-type fix,
and size/format validation now run over both attachments uniformly.

Bumps to 0.14.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 17:02:53 +10:00
Dan Milne
5b41db2c6a Fix FileNotFoundError when uploading an SVG icon
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
The sanitize_svg_icon before_validation callback called icon.download,
but Active Storage uploads pending blobs in before_save — so at
before_validation time the file only existed in the request tempfile,
not at the configured storage path. Read from the pending attachable
(UploadedFile / IO hash) instead. Guards against the recursive callback
that icon.attach would otherwise trigger by tracking the cleaned
attachable by object identity. Bumps to 0.13.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 16:43:24 +10:00
Dan Milne
03dfdbd83a Default-deny access control with group flags and access enumeration
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Replaces the implicit "empty allowed_groups means public" rule with
explicit default-deny across both OIDC and ForwardAuth. Adds two boolean
flags on Group — auto_assign (Keycloak-style auto-join on user create)
and admin (members can reach the admin panel) — and drops the
users.admin column entirely. Adds "Users with access" and "Accessible
applications" panels with via-group badges on the application/user show
pages.

BEHAVIOR CHANGE: a ForwardAuth app with no allowed_groups previously
bypassed authentication entirely; it now returns 403 like any other
unauthorized request. The data migration seeds an "everyone" group and
attaches it to all previously group-less apps to preserve behavior on
existing installs. An "admins" group is seeded and backfilled from any
user with the old admin column.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 15:53:27 +10:00
Dan Milne
6b58b685c4 Bump version to 0.12.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:18:35 +10:00
Dan Milne
a399907dfd Allow assigning applications to a group from the group form
Adds an "Assigned Applications" checkbox list to the group new/edit
form so admins can grant a group access to multiple apps from one
screen, instead of editing each application individually.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 21:17:43 +10:00
Dan Milne
bbfb564e1c Show Clinch, Rails and Ruby versions in sidebar footer; bump to 0.11.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 23:39:11 +10:00
Dan Milne
9663110938 Bump version to 0.10.2
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
2026-05-26 18:32:25 +10:00
Dan Milne
0bca1d2bac Allow OAuth redirect_uri host in form-action CSP on sign-in pages
Safari enforces form-action against every hop in a form submission's
redirect chain. When a user signed in (with TOTP, or through a
skip_consent OIDC app), the chain /signin or /totp-verification ->
/oauth/authorize -> external client got blocked at the cross-origin
hop because form-action was 'self'. The existing dynamic CSP widening
in OidcController#authorize only ran when the consent page rendered,
so skip_consent and pre-consented flows had no widening at all.

Add allow_oauth_redirect_in_csp on the sign-in and TOTP pages, which
pulls the OAuth redirect_uri out of session[:return_to_after_authenticating]
and appends its host to form-action for the rendered page.
2026-05-23 11:03:32 +10:00
Dan Milne
bdb10d86fb Show OIDC env vars on application show page under a toggle
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / scan_container (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled
Previously the copy-pasteable env-var block only appeared right after
creating an app or regenerating credentials. Operators had no easy way
back to it, so they had to reconstruct OIDC_DISCOVERY_URL etc. from
memory.

Adds a collapsed <details> disclosure inside the OIDC Configuration
card with the same env vars (placeholder for the secret, which can't
be re-shown). Extracts the env-line construction into an
oidc_env_lines helper so the flash panel and the persistent display
share one source of truth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:19:14 +10:00
Dan Milne
37e6e2cc19 Show copy-pasteable OIDC env vars after creating an app
Surfaces OIDC_CLIENT_ID/SECRET, discovery URL, provider name, and PKCE
flag in a single textarea on the credentials flash so the client config
can be dropped straight into a consuming app's .env file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:30:30 +10:00
Dan Milne
9648b64043 Bump version to 0.10.1
Patch release covering the Ruby 4.0.3, Rails 8.1.3, and transitive
gem updates landed since 0.10.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:27 +10:00
Dan Milne
a5eba9a5cd Update transitive gems
Routine bundle update on the Ruby 4.0.3 install. No app code changes;
test suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:09:20 +10:00
Dan Milne
afa90303c8 Bump Rails from 8.1.2 to 8.1.3
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
df5dbfc46c Bump Ruby from 4.0.1 to 4.0.3
Patch release within 4.0.x — security and bug fixes only, no source
changes required. Updated both .ruby-version and the Dockerfile ARG
they're explicitly told to keep in sync.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:06:22 +10:00
Dan Milne
2768104c1e Bump version to 0.10.0
Substantial scope since 0.9.0: API keys for forward auth, SecurityMailer
alerts on 8 account-security events, dark mode, Remember-me with proper
browser-session cookie semantics, SvgScrubber for icon XSS, OIDC
auth-code replay revocation, forward-auth caching + rate limiting, and
fixes for broken invitation / password-reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 00:02:40 +10:00
Dan Milne
2e427a0520 Add SvgScrubber to strip XSS payloads from uploaded app icons
Application#sanitize_svg_icon already runs a Loofah scrubber on every
icon upload, but the scrubber class itself was never tracked. Land it
along with tests covering the four shapes that matter:

- <script> elements stripped entirely
- on* event handlers (onload, onclick, …) removed but the carrying
  element preserved
- attribute values pointing at javascript:/data: URIs rejected
- benign icons round-trip unchanged

Writing the benign-icon test caught a real bug: the attribute allowlist
holds canonical SVG case (viewBox, preserveAspectRatio, gradientUnits,
…) but safe_attribute? downcases the incoming name before comparing,
so legitimate icons were silently losing those attributes on upload.
Fix by comparing against a precomputed lowercase lookup set; the
constant stays readable as canonical SVG case for documentation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:57:22 +10:00
Dan Milne
556656d090 Drop Remember-me cookie's Expires when the box is unchecked
Without Remember-me the session cookie was still being written via
`cookies.signed.permanent`, so it survived browser restart on shared
devices — surprising for a user who explicitly opted out of Remember-me.
Issue a browser-session cookie (no Expires) when remember_me is off;
the server-side Session#expires_at still bounds the 24h / 30d window.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:54:09 +10:00
Dan Milne
cc93f72f0a Notify users out-of-band when security settings change
Previously only TOTP-enabled triggered an email. Every other
security-relevant change — password change, TOTP disable, passkey
add/remove, API key create/revoke, email address change, backup-code
regeneration — happened silently, so an attacker on a stolen session
could quietly drop 2FA or hijack the email with no signal to the
account holder.

Add SecurityMailer with one method per event. Each email carries the
request IP, user-agent, and timestamp so the user can spot unfamiliar
activity. Email-address changes notify both the old and new addresses
with directional language; the old-address copy explicitly warns that
whoever made the change can now receive password reset emails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:52:12 +10:00
Dan Milne
09e9b32e46 Run SolidQueue supervisor inside Puma in production
Production switched the queue adapter back to :solid_queue but the Puma
plugin had been removed, so jobs (e.g. invitation resend) enqueued fine
but never ran. Clinch ships as a single container, so always start the
supervisor in production rather than gating on SOLID_QUEUE_IN_PUMA.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 23:51:37 +10:00
113 changed files with 2780 additions and 574 deletions

133
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,133 @@
name: Build and publish image
# Publishes the multi-arch image (amd64 + arm64) to GitHub Packages
# (ghcr.io/dkam/clinch) whenever config/initializers/version.rb changes on
# main — a version bump IS the release. Each arch builds natively (no QEMU); a
# merge job stitches them into one manifest tagged :vX.Y.Z (+ :latest for
# non-pre-releases).
#
# To cut a release: edit Clinch::VERSION in config/initializers/version.rb,
# commit, push. For a dev build: set a pre-release version (e.g. "1.1.0-dev") —
# it publishes :v1.1.0-dev but does not move :latest. Or run this workflow
# manually from the Actions tab.
on:
push:
branches: [ main ]
paths:
- config/initializers/version.rb
workflow_dispatch:
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
# Read the SemVer constant; decide whether this release moves :latest.
prepare:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
latest: ${{ steps.version.outputs.latest }}
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Read version from config/initializers/version.rb
id: version
run: |
V=$(ruby -e "require './config/initializers/version'; puts Clinch::VERSION")
echo "version=$V" >> "$GITHUB_OUTPUT"
# A pre-release (e.g. 1.1.0-dev) publishes its own tag but not :latest.
if [[ "$V" == *-* ]]; then latest=false; else latest=true; fi
echo "latest=$latest" >> "$GITHUB_OUTPUT"
echo "Building v$V (move :latest = $latest)"
build:
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
arch: amd64
runner: ubuntu-latest
- platform: linux/arm64
arch: arm64
runner: ubuntu-24.04-arm
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: ${{ matrix.platform }}
cache-from: type=gha,scope=${{ matrix.arch }}
cache-to: type=gha,mode=max,scope=${{ matrix.arch }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push the multi-arch manifest
working-directory: /tmp/digests
run: |
tags="-t ${{ env.IMAGE }}:v${{ needs.prepare.outputs.version }}"
if [ "${{ needs.prepare.outputs.latest }}" = "true" ]; then
tags="$tags -t ${{ env.IMAGE }}:latest"
fi
docker buildx imagetools create $tags $(printf '${{ env.IMAGE }}@sha256:%s ' *)
- name: Inspect result
run: docker buildx imagetools inspect ${{ env.IMAGE }}:latest

3
.gitignore vendored
View File

@@ -70,3 +70,6 @@ yarn-debug.log*
# Ignore bootsnap cache # Ignore bootsnap cache
/tmp/cache/bootsnap* /tmp/cache/bootsnap*
# Local-only: do not publish the security findings tracker
SECURITY_REVIEW_TODO.md

View File

@@ -1 +1 @@
4.0.1 4.0.5

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=4.0.1 ARG RUBY_VERSION=4.0.5
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
LABEL org.opencontainers.image.source=https://github.com/dkam/clinch LABEL org.opencontainers.image.source=https://github.com/dkam/clinch

View File

@@ -1,7 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.2" gem "rails", "~> 8.1.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]
gem "propshaft" gem "propshaft"
# Use sqlite3 as the database for Active Record # Use sqlite3 as the database for Active Record

View File

@@ -1,31 +1,31 @@
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
action_text-trix (2.1.16) action_text-trix (2.1.19)
railties railties
actioncable (8.1.2) actioncable (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.1.2) actionmailbox (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
actionmailer (8.1.2) actionmailer (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
mail (>= 2.8.0) mail (>= 2.8.0)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.1.2) actionpack (8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
@@ -33,36 +33,36 @@ GEM
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) useragent (~> 0.16)
actiontext (8.1.2) actiontext (8.1.3)
action_text-trix (~> 2.1.15) action_text-trix (~> 2.1.15)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.1.2) actionview (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.1.2) activejob (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.1.2) activemodel (8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
activerecord (8.1.2) activerecord (8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.1.2) activestorage (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.1.2) activesupport (8.1.3)
base64 base64
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.3.1)
@@ -75,19 +75,19 @@ GEM
securerandom (>= 0.3) securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1) uri (>= 0.13.1)
addressable (2.8.8) addressable (2.9.0)
public_suffix (>= 2.0.2, < 8.0) public_suffix (>= 2.0.2, < 8.0)
android_key_attestation (0.3.0) android_key_attestation (0.3.0)
ast (2.4.3) ast (2.4.3)
base64 (0.3.0) base64 (0.3.0)
bcrypt (3.1.21) bcrypt (3.1.22)
bcrypt_pbkdf (1.1.2) bcrypt_pbkdf (1.1.2)
bigdecimal (4.0.1) bigdecimal (4.1.2)
bindata (2.5.1) bindata (2.5.1)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.20.1) bootsnap (1.24.6)
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (7.1.2) brakeman (8.0.5)
racc racc
builder (3.3.0) builder (3.3.0)
bundler-audit (0.9.3) bundler-audit (0.9.3)
@@ -102,11 +102,11 @@ GEM
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0) regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2) xpath (~> 3.2)
cbor (0.5.10.1) cbor (0.5.10.3)
childprocess (5.1.0) childprocess (5.1.0)
logger (~> 1.5) logger (~> 1.5)
chunky_png (1.4.0) chunky_png (1.4.0)
concurrent-ruby (1.3.6) concurrent-ruby (1.3.7)
connection_pool (3.0.2) connection_pool (3.0.2)
cose (1.3.1) cose (1.3.1)
cbor (~> 0.5.9) cbor (~> 0.5.9)
@@ -120,44 +120,44 @@ GEM
dotenv (3.2.0) dotenv (3.2.0)
drb (2.2.3) drb (2.2.3)
ed25519 (1.4.0) ed25519 (1.4.0)
erb (6.0.2) erb (6.0.4)
erubi (1.13.1) erubi (1.13.1)
et-orbi (1.4.0) et-orbi (1.4.0)
tzinfo tzinfo
ffi (1.17.3-aarch64-linux-gnu) ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.3-aarch64-linux-musl) ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.3-arm-linux-gnu) ffi (1.17.4-arm-linux-gnu)
ffi (1.17.3-arm-linux-musl) ffi (1.17.4-arm-linux-musl)
ffi (1.17.3-arm64-darwin) ffi (1.17.4-arm64-darwin)
ffi (1.17.3-x86_64-linux-gnu) ffi (1.17.4-x86_64-linux-gnu)
ffi (1.17.3-x86_64-linux-musl) ffi (1.17.4-x86_64-linux-musl)
fugit (1.12.1) fugit (1.12.2)
et-orbi (~> 1.4) et-orbi (~> 1.4)
raabro (~> 1.4) raabro (~> 1.4)
globalid (1.3.0) globalid (1.3.0)
activesupport (>= 6.1) activesupport (>= 6.1)
i18n (1.14.8) i18n (1.15.2)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
image_processing (1.14.0) image_processing (1.14.0)
mini_magick (>= 4.9.5, < 6) mini_magick (>= 4.9.5, < 6)
ruby-vips (>= 2.0.17, < 3) ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.2.2) importmap-rails (2.2.3)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activesupport (>= 6.0.0) activesupport (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
io-console (0.8.2) io-console (0.8.2)
irb (1.17.0) irb (1.18.0)
pp (>= 0.6.0) pp (>= 0.6.0)
prism (>= 1.3.0) prism (>= 1.3.0)
rdoc (>= 4.0.0) rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jbuilder (2.14.1) jbuilder (2.15.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (2.19.0) json (2.19.9)
jwt (3.1.2) jwt (3.2.0)
base64 base64
kamal (2.10.1) kamal (2.12.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
bcrypt_pbkdf (~> 1.0) bcrypt_pbkdf (~> 1.0)
@@ -177,7 +177,7 @@ GEM
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
lint_roller (1.1.0) lint_roller (1.1.0)
logger (1.7.0) logger (1.7.0)
loofah (2.25.0) loofah (2.25.1)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.9.0) mail (2.9.0)
@@ -186,14 +186,14 @@ GEM
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
marcel (1.1.0) marcel (1.2.1)
matrix (0.4.3) matrix (0.4.3)
mini_magick (5.3.1) mini_magick (5.3.1)
logger logger
mini_mime (1.1.5) mini_mime (1.1.5)
minitest (5.27.0) minitest (5.27.0)
msgpack (1.8.0) msgpack (1.8.3)
net-imap (0.6.3) net-imap (0.6.4.1)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -206,68 +206,68 @@ GEM
net-ssh (>= 5.0.0, < 8.0.0) net-ssh (>= 5.0.0, < 8.0.0)
net-smtp (0.5.1) net-smtp (0.5.1)
net-protocol net-protocol
net-ssh (7.3.0) net-ssh (7.3.2)
nio4r (2.7.5) nio4r (2.7.5)
nokogiri (1.19.1-aarch64-linux-gnu) nokogiri (1.19.4-aarch64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-aarch64-linux-musl) nokogiri (1.19.4-aarch64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-gnu) nokogiri (1.19.4-arm-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm-linux-musl) nokogiri (1.19.4-arm-linux-musl)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-arm64-darwin) nokogiri (1.19.4-arm64-darwin)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-gnu) nokogiri (1.19.4-x86_64-linux-gnu)
racc (~> 1.4) racc (~> 1.4)
nokogiri (1.19.1-x86_64-linux-musl) nokogiri (1.19.4-x86_64-linux-musl)
racc (~> 1.4) racc (~> 1.4)
openssl (4.0.0) openssl (4.0.2)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
ostruct (0.6.3) ostruct (0.6.3)
parallel (1.27.0) parallel (2.1.0)
parser (3.3.10.0) parser (3.3.11.1)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.3) pp (0.6.3)
prettyprint prettyprint
prettyprint (0.2.0) prettyprint (0.2.0)
prism (1.7.0) prism (1.9.0)
propshaft (1.3.1) propshaft (1.3.2)
actionpack (>= 7.0.0) actionpack (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
rack rack
psych (5.3.1) psych (5.4.0)
date date
stringio stringio
public_suffix (7.0.0) public_suffix (7.0.5)
puma (7.1.0) puma (8.0.2)
nio4r (~> 2.0) nio4r (~> 2.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.8.1) racc (1.8.1)
rack (3.2.5) rack (3.2.6)
rack-session (2.1.1) rack-session (2.1.2)
base64 (>= 0.1.0) base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.2.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.3.1) rackup (2.3.1)
rack (>= 3) rack (>= 3)
rails (8.1.2) rails (8.1.3)
actioncable (= 8.1.2) actioncable (= 8.1.3)
actionmailbox (= 8.1.2) actionmailbox (= 8.1.3)
actionmailer (= 8.1.2) actionmailer (= 8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
actiontext (= 8.1.2) actiontext (= 8.1.3)
actionview (= 8.1.2) actionview (= 8.1.3)
activejob (= 8.1.2) activejob (= 8.1.3)
activemodel (= 8.1.2) activemodel (= 8.1.3)
activerecord (= 8.1.2) activerecord (= 8.1.3)
activestorage (= 8.1.2) activestorage (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) railties (= 8.1.3)
rails-dom-testing (2.3.0) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -275,9 +275,9 @@ GEM
rails-html-sanitizer (1.7.0) rails-html-sanitizer (1.7.0)
loofah (~> 2.25) loofah (~> 2.25)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
railties (8.1.2) railties (8.1.3)
actionpack (= 8.1.2) actionpack (= 8.1.3)
activesupport (= 8.1.2) activesupport (= 8.1.3)
irb (~> 1.13) irb (~> 1.13)
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
@@ -285,32 +285,32 @@ GEM
tsort (>= 0.2) tsort (>= 0.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.3.1) rake (13.4.2)
rdoc (7.2.0) rdoc (7.2.0)
erb erb
psych (>= 4.0.0) psych (>= 4.0.0)
tsort tsort
regexp_parser (2.11.3) regexp_parser (2.12.0)
reline (0.6.3) reline (0.6.3)
io-console (~> 0.5) io-console (~> 0.5)
rexml (3.4.4) rexml (3.4.4)
rotp (6.3.0) rotp (6.3.0)
rqrcode (3.1.1) rqrcode (3.2.0)
chunky_png (~> 1.0) chunky_png (~> 1.0)
rqrcode_core (~> 2.0) rqrcode_core (~> 2.0)
rqrcode_core (2.0.1) rqrcode_core (2.1.0)
rubocop (1.81.7) rubocop (1.87.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0) lint_roller (~> 1.1.0)
parallel (~> 1.10) parallel (>= 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.47.1, < 2.0) rubocop-ast (>= 1.49.0, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.49.0) rubocop-ast (1.49.1)
parser (>= 3.3.7.2) parser (>= 3.3.7.2)
prism (~> 1.7) prism (~> 1.7)
rubocop-performance (1.26.1) rubocop-performance (1.26.1)
@@ -321,29 +321,30 @@ GEM
ruby-vips (2.3.0) ruby-vips (2.3.0)
ffi (~> 1.12) ffi (~> 1.12)
logger logger
rubyzip (3.2.2) rubyzip (3.4.0)
safety_net_attestation (0.5.0) safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0) jwt (>= 2.0, < 4.0)
securerandom (0.4.1) securerandom (0.4.1)
selenium-webdriver (4.39.0) selenium-webdriver (4.45.0)
base64 (~> 0.2) base64 (~> 0.2)
logger (~> 1.4) logger (~> 1.4)
rexml (~> 3.2, >= 3.2.5) rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0) rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0) websocket (~> 1.0)
sentry-rails (6.2.0) sentry-rails (6.6.2)
railties (>= 5.2.0) railties (>= 5.2.0)
sentry-ruby (~> 6.2.0) sentry-ruby (~> 6.6.2)
sentry-ruby (6.2.0) sentry-ruby (6.6.2)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
logger
simplecov (0.22.0) simplecov (0.22.0)
docile (~> 1.1) docile (~> 1.1)
simplecov-html (~> 0.11) simplecov-html (~> 0.11)
simplecov_json_formatter (~> 0.1) simplecov_json_formatter (~> 0.1)
simplecov-html (0.13.2) simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4) simplecov_json_formatter (0.1.4)
solid_cable (3.0.12) solid_cable (4.0.0)
actioncable (>= 7.2) actioncable (>= 7.2)
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
@@ -352,20 +353,20 @@ GEM
activejob (>= 7.2) activejob (>= 7.2)
activerecord (>= 7.2) activerecord (>= 7.2)
railties (>= 7.2) railties (>= 7.2)
solid_queue (1.2.4) solid_queue (1.4.0)
activejob (>= 7.1) activejob (>= 7.1)
activerecord (>= 7.1) activerecord (>= 7.1)
concurrent-ruby (>= 1.3.1) concurrent-ruby (>= 1.3.1)
fugit (~> 1.11) fugit (~> 1.11)
railties (>= 7.1) railties (>= 7.1)
thor (>= 1.3.1) thor (>= 1.3.1)
sqlite3 (2.9.0-aarch64-linux-gnu) sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.0-aarch64-linux-musl) sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.0-arm-linux-gnu) sqlite3 (2.9.5-arm-linux-gnu)
sqlite3 (2.9.0-arm-linux-musl) sqlite3 (2.9.5-arm-linux-musl)
sqlite3 (2.9.0-arm64-darwin) sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.0-x86_64-linux-gnu) sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.0-x86_64-linux-musl) sqlite3 (2.9.5-x86_64-linux-musl)
sshkit (1.25.0) sshkit (1.25.0)
base64 base64
logger logger
@@ -373,10 +374,10 @@ GEM
net-sftp (>= 2.1.2) net-sftp (>= 2.1.2)
net-ssh (>= 2.8.0) net-ssh (>= 2.8.0)
ostruct ostruct
standard (1.52.0) standard (1.55.0)
language_server-protocol (~> 3.17.0.2) language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.0) lint_roller (~> 1.0)
rubocop (~> 1.81.7) rubocop (~> 1.87.0)
standard-custom (~> 1.0.0) standard-custom (~> 1.0.0)
standard-performance (~> 1.8) standard-performance (~> 1.8)
standard-custom (1.0.2) standard-custom (1.0.2)
@@ -388,27 +389,27 @@ GEM
stimulus-rails (1.3.4) stimulus-rails (1.3.4)
railties (>= 6.0.0) railties (>= 6.0.0)
stringio (3.2.0) stringio (3.2.0)
tailwindcss-rails (4.4.0) tailwindcss-rails (4.6.0)
railties (>= 7.0.0) railties (>= 7.0.0)
tailwindcss-ruby (~> 4.0) tailwindcss-ruby (~> 4.0)
tailwindcss-ruby (4.1.18) tailwindcss-ruby (4.3.1)
tailwindcss-ruby (4.1.18-aarch64-linux-gnu) tailwindcss-ruby (4.3.1-aarch64-linux-gnu)
tailwindcss-ruby (4.1.18-aarch64-linux-musl) tailwindcss-ruby (4.3.1-aarch64-linux-musl)
tailwindcss-ruby (4.1.18-arm64-darwin) tailwindcss-ruby (4.3.1-arm64-darwin)
tailwindcss-ruby (4.1.18-x86_64-linux-gnu) tailwindcss-ruby (4.3.1-x86_64-linux-gnu)
tailwindcss-ruby (4.1.18-x86_64-linux-musl) tailwindcss-ruby (4.3.1-x86_64-linux-musl)
thor (1.5.0) thor (1.5.0)
thruster (0.1.17) thruster (0.1.21)
thruster (0.1.17-aarch64-linux) thruster (0.1.21-aarch64-linux)
thruster (0.1.17-arm64-darwin) thruster (0.1.21-arm64-darwin)
thruster (0.1.17-x86_64-linux) thruster (0.1.21-x86_64-linux)
timeout (0.6.0) timeout (0.6.1)
tpm-key_attestation (0.14.1) tpm-key_attestation (0.14.1)
bindata (~> 2.4) bindata (~> 2.4)
openssl (> 2.0) openssl (> 2.0)
openssl-signature_algorithm (~> 1.0) openssl-signature_algorithm (~> 1.0)
tsort (0.2.0) tsort (0.2.0)
turbo-rails (2.0.20) turbo-rails (2.0.23)
actionpack (>= 7.1.0) actionpack (>= 7.1.0)
railties (>= 7.1.0) railties (>= 7.1.0)
tzinfo (2.0.6) tzinfo (2.0.6)
@@ -418,11 +419,10 @@ GEM
unicode-emoji (4.2.0) unicode-emoji (4.2.0)
uri (1.1.1) uri (1.1.1)
useragent (0.16.11) useragent (0.16.11)
web-console (4.2.1) web-console (4.3.0)
actionview (>= 6.0.0) actionview (>= 8.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 8.0.0)
webauthn (3.4.3) webauthn (3.4.3)
android_key_attestation (~> 0.3.0) android_key_attestation (~> 0.3.0)
bindata (~> 2.4) bindata (~> 2.4)
@@ -432,13 +432,13 @@ GEM
safety_net_attestation (~> 0.5.0) safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0) tpm-key_attestation (~> 0.14.0)
websocket (1.2.11) websocket (1.2.11)
websocket-driver (0.8.0) websocket-driver (0.8.1)
base64 base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
xpath (3.2.0) xpath (3.2.0)
nokogiri (~> 1.8) nokogiri (~> 1.8)
zeitwerk (2.7.5) zeitwerk (2.8.2)
PLATFORMS PLATFORMS
aarch64-linux aarch64-linux
@@ -469,7 +469,7 @@ DEPENDENCIES
propshaft propshaft
public_suffix (~> 7.0) public_suffix (~> 7.0)
puma (>= 5.0) puma (>= 5.0)
rails (~> 8.1.2) rails (~> 8.1.3)
rotp (~> 6.3) rotp (~> 6.3)
rqrcode (~> 3.1) rqrcode (~> 3.1)
selenium-webdriver selenium-webdriver
@@ -490,4 +490,4 @@ DEPENDENCIES
webauthn (~> 3.0) webauthn (~> 3.0)
BUNDLED WITH BUNDLED WITH
4.0.3 4.0.6

View File

@@ -0,0 +1,20 @@
module Admin
class AccessChecksController < BaseController
def new
load_options
@user = User.find_by(id: params[:user_id])
@application = Application.find_by(id: params[:application_id])
return unless @user && @application
@allowed = @application.user_allowed?(@user)
@via = @user.groups & @application.allowed_groups
end
private
def load_options
@users = User.order(:email_address)
@applications = Application.order(:name)
end
end
end

View File

@@ -3,11 +3,31 @@ module Admin
before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials] before_action :set_application, only: [:show, :edit, :update, :destroy, :regenerate_credentials]
def index def index
@applications = Application.order(created_at: :desc) @applications = Application.order(created_at: :desc).includes(:allowed_groups)
# Distinct active users that have access to each app, preloaded to avoid N+1.
@user_count_by_app = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.group("applications.id")
.distinct
.count("users.id")
# Top-of-page summary
@total_users_with_access = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.distinct
.count("users.id")
@total_groups_granting_access = Group.joins(:applications).distinct.count
end end
def show def show
@allowed_groups = @application.allowed_groups @allowed_groups = @application.allowed_groups
@users_with_access = User.where(status: User.statuses[:active])
.joins(groups: :applications)
.where(applications: {id: @application.id})
.distinct
.includes(:groups)
.order(:email_address)
end end
def new def new
@@ -104,7 +124,7 @@ module Admin
permitted = params.require(:application).permit( permitted = params.require(:application).permit(
:name, :slug, :app_type, :active, :redirect_uris, :description, :metadata, :name, :slug, :app_type, :active, :redirect_uris, :description, :metadata,
:domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl, :domain_pattern, :landing_url, :access_token_ttl, :refresh_token_ttl, :id_token_ttl,
:icon, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent :icon, :icon_dark, :backchannel_logout_uri, :is_public_client, :require_pkce, :skip_consent
) )
# Handle headers_config - it comes as a JSON string from the text area # Handle headers_config - it comes as a JSON string from the text area

View File

@@ -15,6 +15,7 @@ module Admin
def new def new
@group = Group.new @group = Group.new
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def create def create
@@ -28,6 +29,7 @@ module Admin
@group = Group.new @group = Group.new
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
return return
end end
@@ -45,15 +47,23 @@ module Admin
@group.users = User.where(id: user_ids) @group.users = User.where(id: user_ids)
end end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
end
redirect_to admin_group_path(@group), notice: "Group created successfully." redirect_to admin_group_path(@group), notice: "Group created successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def edit def edit
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
end end
def update def update
@@ -66,6 +76,7 @@ module Admin
rescue JSON::ParserError rescue JSON::ParserError
@group.errors.add(:custom_claims, "must be valid JSON") @group.errors.add(:custom_claims, "must be valid JSON")
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@@ -83,9 +94,18 @@ module Admin
@group.users = [] @group.users = []
end end
# Handle application assignments
if params[:group][:application_ids].present?
application_ids = params[:group][:application_ids].reject(&:blank?)
@group.applications = Application.where(id: application_ids)
else
@group.applications = []
end
redirect_to admin_group_path(@group), notice: "Group updated successfully." redirect_to admin_group_path(@group), notice: "Group updated successfully."
else else
@available_users = User.order(:email_address) @available_users = User.order(:email_address)
@available_applications = Application.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -102,7 +122,7 @@ module Admin
end end
def group_params def group_params
params.require(:group).permit(:name, :description, :custom_claims) params.require(:group).permit(:name, :description, :custom_claims, :auto_assign, :admin)
end end
end end
end end

View File

@@ -7,27 +7,38 @@ module Admin
end end
def show def show
@accessible_applications = Application.where(active: true)
.joins(:allowed_groups)
.where(groups: {id: @user.groups})
.distinct
.includes(:allowed_groups)
.order(:name)
end end
def new def new
@user = User.new @user = User.new
@available_groups = Group.order(:name)
end end
def create def create
@user = User.new(user_params) @user = User.new(user_params)
@user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank? @user.password = SecureRandom.alphanumeric(16) if user_params[:password].blank?
@user.status = :pending_invitation @user.status = :pending_invitation
@user.skip_auto_assign = true if params[:auto_assign] == "0"
if @user.save if @user.save
assign_groups_from_params(@user)
InvitationsMailer.invite_user(@user).deliver_later InvitationsMailer.invite_user(@user).deliver_later
redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}." redirect_to admin_users_path, notice: "User created successfully. Invitation email sent to #{@user.email_address}."
else else
@available_groups = Group.order(:name)
render :new, status: :unprocessable_entity render :new, status: :unprocessable_entity
end end
end end
def edit def edit
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
end end
def update def update
@@ -43,6 +54,7 @@ module Admin
rescue JSON::ParserError rescue JSON::ParserError
@user.errors.add(:custom_claims, "must be valid JSON") @user.errors.add(:custom_claims, "must be valid JSON")
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
return return
end end
@@ -52,9 +64,16 @@ module Admin
end end
if @user.update(update_params) if @user.update(update_params)
unless assign_groups_from_params(@user)
@applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity
return
end
redirect_to admin_users_path, notice: "User updated successfully." redirect_to admin_users_path, notice: "User updated successfully."
else else
@applications = Application.active.order(:name) @applications = Application.active.order(:name)
@available_groups = Group.order(:name)
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
end end
end end
@@ -122,14 +141,28 @@ module Admin
end end
def user_params def user_params
permitted = [:email_address, :username, :name, :password, :status, :totp_required, :custom_claims] params.require(:user).permit(:email_address, :username, :name, :password, :status, :totp_required, :custom_claims)
# Only allow modifying admin status when editing other users (prevent self-demotion)
if params[:id] != Current.session.user.id.to_s
permitted << :admin
end end
params.require(:user).permit(*permitted) # Apply group_ids from the form, with a guard preventing self-demotion when
# the user is the last member of the last admin group. Returns true on
# success, false if a guard fired (caller should re-render).
def assign_groups_from_params(user)
return true unless params[:user].key?(:group_ids)
raw_ids = Array(params[:user][:group_ids]).reject(&:blank?).map(&:to_i)
new_groups = Group.where(id: raw_ids)
if user == Current.session.user
losing_admin = user.groups.where(admin: true).any? && new_groups.none?(&:admin?)
if losing_admin
user.errors.add(:base, "you cannot remove yourself from all administrator groups")
return false
end
end
user.groups = new_groups
true
end end
end end
end end

View File

@@ -64,26 +64,16 @@ module Api
return render_forbidden("No authentication rule configured for this domain") return render_forbidden("No authentication rule configured for this domain")
end end
else else
Rails.logger.info "ForwardAuth: User #{user.email_address} authenticated (no domain specified)" # Fail closed: with no host we cannot resolve an application or evaluate its
end # group policy. Emitting identity headers here would bypass all per-domain
# access control, so reject instead.
headers = if app Rails.logger.info "ForwardAuth: Access denied - no host header present"
app.headers_for_user(user) return render_forbidden("No host header present")
else
Application::DEFAULT_HEADERS.map { |key, header_name|
case key
when :user, :email, :name
[header_name, user.email_address]
when :username
[header_name, user.username] if user.username.present?
when :groups
user.groups.any? ? [header_name, user.groups.map(&:name).join(",")] : nil
when :admin
[header_name, user.admin? ? "true" : "false"]
end
}.compact.to_h
end end
# Reaching here implies a matching, active application was resolved above
# (every other path returns forbidden), so headers are always scoped to it.
headers = app.headers_for_user(user)
headers.each { |key, value| response.headers[key] = value } headers.each { |key, value| response.headers[key] = value }
Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any? Rails.logger.debug "ForwardAuth: Headers sent: #{headers.keys.join(", ")}" if headers.any?
@@ -148,6 +138,14 @@ module Api
return render_bearer_error("Application is inactive") return render_bearer_error("Application is inactive")
end end
# Re-check group membership at use-time. The ApiKey model only validates
# access on creation, so a user removed from the app's allowed groups
# afterwards must not keep access via an existing key.
unless app.user_allowed?(user)
Rails.logger.info "ForwardAuth: API key '#{api_key.name}' denied - user #{user.email_address} lacks group access to #{app.domain_pattern}"
return render_bearer_error("Access denied: insufficient group membership")
end
api_key.touch_last_used! api_key.touch_last_used!
headers = app.headers_for_user(user) headers = app.headers_for_user(user)
@@ -158,7 +156,7 @@ module Api
end end
def render_bearer_error(message) def render_bearer_error(message)
render json: { error: message }, status: :unauthorized render json: {error: message}, status: :unauthorized
end end
def check_forward_auth_token def check_forward_auth_token
@@ -198,15 +196,18 @@ module Api
original_host = request.headers["X-Forwarded-Host"] original_host = request.headers["X-Forwarded-Host"]
original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/" original_uri = request.headers["X-Forwarded-Uri"] || request.headers["X-Forwarded-Path"] || "/"
original_url = if original_host # X-Forwarded-Host is attacker-influenceable, so only honour the forwarded
"https://#{original_host}#{original_uri}" # URL as a post-login redirect target if it resolves to a known, active
else # forward-auth application. Otherwise this is an open redirect: a spoofed
redirect_url || base_url # host would be stored and reflected into the signin `rd`, then followed
end # (with allow_other_host) after the user authenticates. Fall back to a
# validated `rd` or, failing that, the IdP's own base URL.
forwarded_url = "https://#{original_host}#{original_uri}" if original_host.present?
original_url = validate_redirect_url(forwarded_url) || redirect_url || base_url
session[:return_to_after_authenticating] = original_url session[:return_to_after_authenticating] = original_url
login_params = { rd: original_url, rm: request.method } login_params = {rd: original_url, rm: request.method}
login_url = "#{base_url}/signin?#{login_params.to_query}" login_url = "#{base_url}/signin?#{login_params.to_query}"
redirect_to login_url, allow_other_host: true, status: :found redirect_to login_url, allow_other_host: true, status: :found
@@ -242,18 +243,13 @@ module Api
def determine_base_url(redirect_url) def determine_base_url(redirect_url)
return redirect_url if redirect_url.present? return redirect_url if redirect_url.present?
if ENV["CLINCH_HOST"].present? # CLINCH_HOST is the IdP's canonical origin and is mandatory in deployed
host = ENV["CLINCH_HOST"] # environments (enforced at boot in config/initializers/clinch_host.rb).
host.match?(/^https?:\/\//) ? host : "https://#{host}" # We never fall back to the request host: a spoofed X-Forwarded-Host would
else # otherwise redirect the login flow to an attacker-controlled origin. The
request_host = request.host || request.headers["X-Forwarded-Host"] # localhost default only applies to local dev/test.
if request_host.present? host = ENV["CLINCH_HOST"].presence || "http://localhost:3000"
Rails.logger.warn "ForwardAuth: CLINCH_HOST not set, using request host: #{request_host}" host.match?(%r{\Ahttps?://}) ? host : "https://#{host}"
"https://#{request_host}"
else
raise StandardError, "ForwardAuth: CLINCH_HOST environment variable not set and no request host available."
end
end
end end
end end
end end

View File

@@ -14,6 +14,7 @@ class ApiKeysController < ApplicationController
@api_key = Current.session.user.api_keys.build(api_key_params) @api_key = Current.session.user.api_keys.build(api_key_params)
if @api_key.save if @api_key.save
SecurityMailer.api_key_created(Current.session.user, name: @api_key.name, **security_event_context).deliver_later
flash[:api_key_token] = @api_key.plaintext_token flash[:api_key_token] = @api_key.plaintext_token
redirect_to api_key_path(@api_key) redirect_to api_key_path(@api_key)
else else
@@ -31,6 +32,7 @@ class ApiKeysController < ApplicationController
def destroy def destroy
@api_key.revoke! @api_key.revoke!
SecurityMailer.api_key_revoked(@api_key.user, name: @api_key.name, **security_event_context).deliver_later
redirect_to api_keys_path, notice: "API key revoked." redirect_to api_keys_path, notice: "API key revoked."
end end

View File

@@ -14,6 +14,10 @@ class ApplicationController < ActionController::Base
private private
def security_event_context
{ip: request.remote_ip, user_agent: request.user_agent, occurred_at: Time.current}
end
# Remove a query parameter from a URL using proper URI parsing # Remove a query parameter from a URL using proper URI parsing
# More robust than regex - handles URL encoding, edge cases, etc. # More robust than regex - handles URL encoding, edge cases, etc.
# #

View File

@@ -31,7 +31,7 @@ module Authentication
end end
def find_session_by_cookie def find_session_by_cookie
Session.active.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id] Session.active.for_active_user.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end end
def request_authentication def request_authentication
@@ -43,6 +43,37 @@ module Authentication
session.delete(:return_to_after_authenticating) || root_url session.delete(:return_to_after_authenticating) || root_url
end end
# When a sign-in form will eventually redirect through /oauth/authorize to an
# external client, Safari enforces CSP form-action against every hop in the
# redirect chain. With the default form-action 'self', the final cross-origin
# hop to the OAuth client's redirect_uri gets blocked. Add the redirect_uri
# host to form-action so the chain completes.
def allow_oauth_redirect_in_csp
stored = session[:return_to_after_authenticating]
return if stored.blank?
uri = URI.parse(stored)
return unless uri.path&.start_with?("/oauth/")
redirect_uri = Rack::Utils.parse_query(uri.query.to_s)["redirect_uri"]
return if redirect_uri.blank?
redirect_host = URI.parse(redirect_uri).host
return if redirect_host.blank?
csp = request.content_security_policy
return unless csp
# NOTE: `csp.form_action` (no args) is destructive — it deletes the directive
# and returns its old value, so reading it twice yields nil. Mutate the
# underlying `directives` hash (a public reader of the real values) instead.
form_action = (csp.directives["form-action"] ||= ["'self'"])
host = "https://#{redirect_host}"
form_action << host unless form_action.include?(host)
rescue URI::InvalidURIError
nil
end
def start_new_session_for(user, acr: "1", remember_me: false) def start_new_session_for(user, acr: "1", remember_me: false)
user.update!(last_sign_in_at: Time.current) user.update!(last_sign_in_at: Time.current)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session| user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip, acr: acr, remember_me: remember_me).tap do |session|
@@ -73,7 +104,15 @@ module Authentication
# Set domain for cross-subdomain authentication if we can extract it # Set domain for cross-subdomain authentication if we can extract it
cookie_options[:domain] = domain if domain.present? cookie_options[:domain] = domain if domain.present?
# When "Remember me" is off, issue a browser-session cookie (no Expires)
# so closing the browser signs the user out — especially important on
# shared devices. The server Session#expires_at still enforces the
# 24h / 30d window regardless.
if remember_me
cookies.signed.permanent[:session_id] = cookie_options cookies.signed.permanent[:session_id] = cookie_options
else
cookies.signed[:session_id] = cookie_options
end
# Create a one-time token for immediate forward auth after authentication # Create a one-time token for immediate forward auth after authentication
# This solves the race condition where browser hasn't processed cookie yet # This solves the race condition where browser hasn't processed cookie yet
@@ -152,7 +191,7 @@ module Authentication
token = SecureRandom.urlsafe_base64(32) token = SecureRandom.urlsafe_base64(32)
Rails.cache.write( Rails.cache.write(
"forward_auth_token:#{token}", "forward_auth_token:#{token}",
{ session_id: session_obj.id, host: bound_host }, {session_id: session_obj.id, host: bound_host},
expires_in: 60.seconds expires_in: 60.seconds
) )

View File

@@ -4,7 +4,11 @@ class OidcController < ApplicationController
# Discovery and JWKS endpoints are public # Discovery and JWKS endpoints are public
# authorize is also unauthenticated to handle prompt=none and prompt=login specially # authorize is also unauthenticated to handle prompt=none and prompt=login specially
allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize] allow_unauthenticated_access only: [:discovery, :jwks, :token, :revoke, :userinfo, :logout, :authorize]
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize, :consent] # Machine-to-machine endpoints (token/revoke/userinfo) and pure redirect handlers
# (logout/authorize) legitimately skip CSRF. The consent endpoint is browser-facing
# and state-changing (it grants OAuth scopes), so it MUST keep CSRF protection — the
# consent form already embeds the token via form_with.
skip_before_action :verify_authenticity_token, only: [:token, :revoke, :userinfo, :logout, :authorize]
# RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any # RFC 6749 §4.1.2.1: client_id and redirect_uri must be validated *before* any
# other error can be reported via redirect. Failures here render a plain page. # other error can be reported via redirect. Failures here render a plain page.

View File

@@ -20,6 +20,7 @@ class PasswordsController < ApplicationController
def update def update
if @user.update(params.permit(:password, :password_confirmation)) if @user.update(params.permit(:password, :password_confirmation))
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
@user.sessions.destroy_all @user.sessions.destroy_all
redirect_to signin_path, notice: "Password has been reset." redirect_to signin_path, notice: "Password has been reset."
else else

View File

@@ -15,6 +15,7 @@ class ProfilesController < ApplicationController
end end
if @user.update(password_params) if @user.update(password_params)
SecurityMailer.password_changed(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Password updated successfully." redirect_to profile_path, notice: "Password updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity
@@ -27,7 +28,15 @@ class ProfilesController < ApplicationController
return return
end end
old_email = @user.email_address
if @user.update(email_params) if @user.update(email_params)
new_email = @user.email_address
if old_email != new_email
context = security_event_context
[old_email, new_email].uniq.each do |recipient|
SecurityMailer.email_address_changed(@user, recipient: recipient, old_email: old_email, new_email: new_email, **context).deliver_later
end
end
redirect_to profile_path, notice: "Email updated successfully." redirect_to profile_path, notice: "Email updated successfully."
else else
render :show, status: :unprocessable_entity render :show, status: :unprocessable_entity

View File

@@ -28,6 +28,8 @@ class SessionsController < ApplicationController
end end
end end
allow_oauth_redirect_in_csp
respond_to do |format| respond_to do |format|
format.html # render HTML login page format.html # render HTML login page
format.json { render json: {error: "Authentication required"}, status: :unauthorized } format.json { render json: {error: "Authentication required"}, status: :unauthorized }
@@ -119,6 +121,16 @@ class SessionsController < ApplicationController
return return
end end
# Re-check account status: active? was verified at the password step, but an
# admin may have disabled the account while the user sat on this 2FA screen.
# Without this, a disabled account could still mint a valid session here.
unless user.active?
session.delete(:pending_totp_user_id)
session.delete(:pending_remember_me)
redirect_to signin_path, alert: "Your account is not active. Please contact an administrator."
return
end
remember_me = session.delete(:pending_remember_me) || false remember_me = session.delete(:pending_remember_me) || false
# Try TOTP verification first (password + TOTP = 2FA) # Try TOTP verification first (password + TOTP = 2FA)
@@ -154,6 +166,8 @@ class SessionsController < ApplicationController
@user_has_webauthn = user&.can_authenticate_with_webauthn? @user_has_webauthn = user&.can_authenticate_with_webauthn?
@pending_email = user&.email_address @pending_email = user&.email_address
allow_oauth_redirect_in_csp
# Just render the form # Just render the form
end end
@@ -237,6 +251,14 @@ class SessionsController < ApplicationController
return return
end end
# Re-check account status: an admin may have disabled the account between the
# password step and this passkey verification. Reject before creating a session.
unless user.active?
session.delete(:pending_webauthn_user_id)
render json: {error: "Your account is not active."}, status: :unauthorized
return
end
# Get the credential and assertion from params # Get the credential and assertion from params
credential_data = params[:credential] credential_data = params[:credential]
if credential_data.blank? if credential_data.blank?
@@ -273,10 +295,14 @@ class SessionsController < ApplicationController
sign_count: stored_credential.sign_count sign_count: stored_credential.sign_count
) )
# Check for suspicious sign count (possible clone) # Clone detection: a non-advancing signature counter signals the credential
# may have been copied. Reject the sign-in (do NOT create a session or update
# the stored counter) and alert the user, per WebAuthn §6.1.1.
if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count) if stored_credential.suspicious_sign_count?(webauthn_credential.sign_count)
Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id}" Rails.logger.warn "Suspicious WebAuthn sign count for user #{user.id}, credential #{stored_credential.id} (stored=#{stored_credential.sign_count}, presented=#{webauthn_credential.sign_count})"
# You might want to notify admins or temporarily disable the credential SecurityMailer.suspicious_passkey_used(user, nickname: stored_credential.display_name, **security_event_context).deliver_later
render json: {error: "Passkey authentication could not be completed. Please contact support."}, status: :unprocessable_entity
return
end end
# Update credential usage # Update credential usage

View File

@@ -103,6 +103,7 @@ class TotpController < ApplicationController
# Generate new backup codes and store BCrypt hashes # Generate new backup codes and store BCrypt hashes
plain_codes = @user.send(:generate_backup_codes) plain_codes = @user.send(:generate_backup_codes)
@user.save! @user.save!
SecurityMailer.backup_codes_regenerated(@user, **security_event_context).deliver_later
# Store plain codes temporarily in session for display # Store plain codes temporarily in session for display
session[:temp_backup_codes] = plain_codes session[:temp_backup_codes] = plain_codes
@@ -136,6 +137,7 @@ class TotpController < ApplicationController
end end
@user.disable_totp! @user.disable_totp!
SecurityMailer.totp_disabled(@user, **security_event_context).deliver_later
redirect_to profile_path, notice: "Two-factor authentication has been disabled." redirect_to profile_path, notice: "Two-factor authentication has been disabled."
end end

View File

@@ -8,12 +8,16 @@ class UsersController < ApplicationController
def create def create
@user = User.new(user_params) @user = User.new(user_params)
# First user becomes admin automatically
@user.admin = true if User.count.zero?
@user.status = "active" @user.status = "active"
first_user = User.count.zero?
if @user.save if @user.save
# First user automatically becomes a member of every admin group, so they
# can reach the admin panel without an existing admin to grant access.
if first_user
Group.where(admin: true).each { |g| @user.groups << g }
end
start_new_session_for @user start_new_session_for @user
redirect_to root_path, notice: "Welcome to Clinch! Your account has been created." redirect_to root_path, notice: "Welcome to Clinch! Your account has been created."
else else

View File

@@ -91,6 +91,8 @@ class WebauthnController < ApplicationController
backup_state: backup_state backup_state: backup_state
) )
SecurityMailer.passkey_added(user, nickname: @webauthn_credential.nickname, **security_event_context).deliver_later
render json: { render json: {
success: true, success: true,
message: "Passkey '#{nickname}' registered successfully", message: "Passkey '#{nickname}' registered successfully",
@@ -109,8 +111,11 @@ class WebauthnController < ApplicationController
# Remove a passkey # Remove a passkey
def destroy def destroy
nickname = @webauthn_credential.nickname nickname = @webauthn_credential.nickname
user = @webauthn_credential.user
@webauthn_credential.destroy @webauthn_credential.destroy
SecurityMailer.passkey_removed(user, nickname: nickname, **security_event_context).deliver_later
respond_to do |format| respond_to do |format|
format.html { format.html {
redirect_to profile_path, redirect_to profile_path,

View File

@@ -20,6 +20,21 @@ module ApplicationHelper
end end
end end
def oidc_env_lines(application, client_secret: nil)
lines = ["OIDC_CLIENT_ID=#{application.client_id}"]
lines << if client_secret
"OIDC_CLIENT_SECRET=#{client_secret}"
elsif application.public_client?
"OIDC_CLIENT_SECRET="
else
"OIDC_CLIENT_SECRET=<your-client-secret>"
end
lines << "OIDC_DISCOVERY_URL=#{OidcJwtService.issuer_url}"
lines << "OIDC_PROVIDER_NAME='Clinch'"
lines << "OIDC_REQUIRE_PKCE=#{application.requires_pkce? ? "true" : "false"}"
lines
end
def border_class_for(type) def border_class_for(type)
case type.to_s case type.to_s
when "notice" then "border-green-200 dark:border-green-700" when "notice" then "border-green-200 dark:border-green-700"
@@ -29,4 +44,49 @@ module ApplicationHelper
else "border-gray-200 dark:border-gray-700" else "border-gray-200 dark:border-gray-700"
end end
end end
# Picks 1-2 character initials for a monogram fallback when an Application
# has no icon. Prefers capital letters (ShelfLife -> SL); falls back to the
# first two letters of the name (Audiobookshelf -> AU).
MONOGRAM_PALETTE = %w[
#4f46e5 #0891b2 #16a34a #ca8a04
#db2777 #9333ea #ea580c #475569
].freeze
def monogram_initials(name)
return "?" if name.blank?
caps = name.scan(/[A-Z]/)
initials = if caps.size >= 2
caps.first(2).join
else
name.upcase.gsub(/[^A-Z0-9]/, "").first(2)
end
initials.presence || "?"
end
def monogram_color(name)
return MONOGRAM_PALETTE.first if name.blank?
index = Digest::MD5.hexdigest(name).to_i(16) % MONOGRAM_PALETTE.size
MONOGRAM_PALETTE[index]
end
# Renders an application icon with optional dark-mode variant. If
# `icon_dark` is attached, we render both <img> tags and Tailwind's class-
# based `dark:` modifier hides the inactive one — so it follows the in-app
# theme toggle (.dark on <html>), not the OS preference. If only `icon` is
# attached, the same image is used in both modes. Caller must ensure at
# least app.icon is attached; the monogram fallback handles no-icon.
def app_icon_picture(app, class:, alt: nil)
img_class = binding.local_variable_get(:class)
alt ||= "#{app.name} icon"
if app.icon_dark.attached?
safe_join([
image_tag(app.icon, class: "#{img_class} dark:hidden", alt: alt),
image_tag(app.icon_dark, class: "#{img_class} hidden dark:block", alt: alt)
])
else
image_tag(app.icon, class: img_class, alt: alt)
end
end
end end

View File

@@ -28,6 +28,14 @@ class BackchannelLogoutJob < ApplicationJob
# Send HTTP POST to the application's backchannel logout URI # Send HTTP POST to the application's backchannel logout URI
uri = URI.parse(application.backchannel_logout_uri) uri = URI.parse(application.backchannel_logout_uri)
# SSRF guard: re-check at request time (with DNS resolution) in case the URI
# predates the validation, or a public hostname now resolves to an internal
# address. Abort without retrying — retries would not change the outcome.
if PrivateAddressCheck.internal_host?(uri.host) || PrivateAddressCheck.resolves_to_internal?(uri.host)
Rails.logger.error "BackchannelLogout: Refusing to send logout to #{application.name} - #{uri.host} is or resolves to a non-public address (SSRF guard)"
return
end
begin begin
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http| response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 5, read_timeout: 5) do |http|
request = Net::HTTP::Post.new(uri.path.presence || "/") request = Net::HTTP::Post.new(uri.path.presence || "/")

View File

@@ -0,0 +1,57 @@
require "ipaddr"
require "resolv"
# SSRF guard for outbound requests to admin-configured URLs (currently the OIDC
# backchannel logout endpoint). Blocks hosts that are, or resolve to, private,
# loopback, link-local (incl. the cloud metadata address 169.254.169.254) or
# otherwise non-public address space.
module PrivateAddressCheck
module_function
# Hostnames that are internal by definition and must never be dialled.
BLOCKED_HOSTNAMES = %w[localhost metadata.google.internal].freeze
# Fast, DNS-free check: catches IP literals and well-known internal hostnames.
# Suitable for model validation (deterministic, immediate admin feedback).
def internal_host?(host)
host = host.to_s.downcase
return true if host.blank?
return true if BLOCKED_HOSTNAMES.include?(host)
return true if host.end_with?(".localhost")
ip = parse_ip(host)
ip ? internal_ip?(ip) : false
end
# Authoritative check: resolves the hostname and blocks if ANY address is
# internal. Suitable for request time — also defeats a public hostname that
# has been pointed at an internal IP (DNS rebinding to internal space).
def resolves_to_internal?(host)
addresses(host).any? { |ip| internal_ip?(ip) }
end
def addresses(host)
ip = parse_ip(host)
return [ip] if ip
Resolv.getaddresses(host.to_s).filter_map { |a| parse_ip(a) }
rescue
# Resolution failure: surface no addresses. Callers treat "can't resolve" as
# not-provably-internal; the dial itself will then fail safely.
[]
end
def internal_ip?(ip)
ip.loopback? || ip.private? || ip.link_local? || unspecified?(ip)
end
def parse_ip(str)
IPAddr.new(str.to_s)
rescue IPAddr::Error
nil
end
def unspecified?(ip)
ip == IPAddr.new("0.0.0.0") || ip == IPAddr.new("::")
end
end

View File

@@ -0,0 +1,65 @@
class SecurityMailer < ApplicationMailer
SUBJECT_PREFIX = "[Clinch security alert] ".freeze
def password_changed(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Your password was changed", to: user.email_address
end
def totp_disabled(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor authentication was disabled", to: user.email_address
end
def backup_codes_regenerated(user, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
mail subject: "#{SUBJECT_PREFIX}Two-factor backup codes were regenerated", to: user.email_address
end
def passkey_added(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was added to your account", to: user.email_address
end
def passkey_removed(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey was removed from your account", to: user.email_address
end
def api_key_created(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
mail subject: "#{SUBJECT_PREFIX}An API key was created on your account", to: user.email_address
end
def api_key_revoked(user, name:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@api_key_name = name
mail subject: "#{SUBJECT_PREFIX}An API key was revoked on your account", to: user.email_address
end
def suspicious_passkey_used(user, nickname:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@nickname = nickname
mail subject: "#{SUBJECT_PREFIX}A passkey sign-in was blocked", to: user.email_address
end
def email_address_changed(user, recipient:, old_email:, new_email:, ip:, user_agent:, occurred_at:)
assign_context(user, ip, user_agent, occurred_at)
@recipient = recipient
@old_email = old_email
@new_email = new_email
mail subject: "#{SUBJECT_PREFIX}Your account email address was changed", to: recipient
end
private
def assign_context(user, ip, user_agent, occurred_at)
@user = user
@ip = ip
@user_agent = user_agent
@occurred_at = occurred_at
end
end

View File

@@ -25,9 +25,12 @@ class Application < ApplicationRecord
after_commit :bust_forward_auth_cache, if: :forward_auth? after_commit :bust_forward_auth_cache, if: :forward_auth?
has_one_attached :icon has_one_attached :icon
has_one_attached :icon_dark
before_validation :sanitize_svg_icon, if: -> { attachment_changes["icon"].present? } ICON_ATTACHMENTS = %i[icon icon_dark].freeze
after_save :fix_icon_content_type, if: -> { icon.attached? && saved_change_to_attribute?(:id) == false }
before_validation :sanitize_svg_icons
after_save :fix_icon_content_types
has_many :application_groups, dependent: :destroy has_many :application_groups, dependent: :destroy
has_many :allowed_groups, through: :application_groups, source: :group has_many :allowed_groups, through: :application_groups, source: :group
@@ -53,9 +56,10 @@ class Application < ApplicationRecord
message: "must be a valid HTTP or HTTPS URL" message: "must be a valid HTTP or HTTPS URL"
} }
validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? } validate :backchannel_logout_uri_must_be_https_in_production, if: -> { backchannel_logout_uri.present? }
validate :backchannel_logout_uri_not_internal, if: -> { backchannel_logout_uri.present? }
# Icon validation using ActiveStorage validators # Icon validation using ActiveStorage validators
validate :icon_validation, if: -> { icon.attached? } validate :icon_validation
# Token TTL validations (for OIDC apps) # Token TTL validations (for OIDC apps)
validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours validates :access_token_ttl, numericality: {greater_than_or_equal_to: 300, less_than_or_equal_to: 86400}, if: :oidc? # 5 min - 24 hours
@@ -118,14 +122,12 @@ class Application < ApplicationRecord
end end
# Access control # Access control
# Default-deny: an empty allowed_groups list means no one gets in.
# To make an app accessible to "everyone", attach the seeded auto-assign
# group (or any group every user is in).
def user_allowed?(user) def user_allowed?(user)
return false unless active? return false unless active?
return false unless user.active? return false unless user.active?
# If no groups are specified, allow all active users
return true if allowed_groups.empty?
# Otherwise, user must be in at least one of the allowed groups
(user.groups & allowed_groups).any? (user.groups & allowed_groups).any?
end end
@@ -168,10 +170,6 @@ class Application < ApplicationRecord
return "deny" unless active? return "deny" unless active?
return "deny" unless user.active? return "deny" unless user.active?
# If no groups specified, bypass authentication
return "bypass" if allowed_groups.empty?
# If user is in allowed groups, determine auth level
if user_allowed?(user) if user_allowed?(user)
# Require 2FA if user has TOTP configured, otherwise one factor # Require 2FA if user has TOTP configured, otherwise one factor
user.totp_enabled? ? "two_factor" : "one_factor" user.totp_enabled? ? "two_factor" : "one_factor"
@@ -274,42 +272,84 @@ class Application < ApplicationRecord
Rails.application.config.forward_auth_cache&.delete("fa_apps") Rails.application.config.forward_auth_cache&.delete("fa_apps")
end end
def fix_icon_content_type def fix_icon_content_types
return unless icon.attached? ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
# Fix SVG content type if it was detected incorrectly # Fix SVG content type if it was detected incorrectly
if icon.filename.extension == "svg" && icon.content_type == "application/octet-stream" if attachment.filename.extension == "svg" && attachment.content_type == "application/octet-stream"
icon.blob.update(content_type: "image/svg+xml") attachment.blob.update(content_type: "image/svg+xml")
end
end end
end end
def sanitize_svg_icon def sanitize_svg_icons
return unless icon.content_type == "image/svg+xml" # Runs in before_validation. The blob has NOT yet been uploaded to disk at
# this point (Active Storage uploads in before_save), so we cannot call
# download — we must read from the pending attachable.
#
# attach below re-sets attachment_changes and would re-fire this callback;
# we skip if the pending attachable is the cleaned hash we just installed
# (tracked by object identity, per-attribute).
@svg_sanitized_attachables ||= {}
ICON_ATTACHMENTS.each do |attr|
change = attachment_changes[attr.to_s]
next unless change
attachable = change.attachable
next if attachable.equal?(@svg_sanitized_attachables[attr])
raw_svg, filename, content_type = read_pending_icon(attachable)
next unless raw_svg
next unless content_type == "image/svg+xml" || filename.to_s.downcase.end_with?(".svg")
raw_svg = icon.download
doc = Loofah.xml_document(raw_svg) doc = Loofah.xml_document(raw_svg)
doc.scrub!(SvgScrubber.new) doc.scrub!(SvgScrubber.new)
clean_svg = doc.to_xml clean_svg = doc.to_xml
icon.attach( sanitized = {
io: StringIO.new(clean_svg), io: StringIO.new(clean_svg),
filename: icon.filename.to_s, filename: filename,
content_type: "image/svg+xml" content_type: "image/svg+xml"
) }
@svg_sanitized_attachables[attr] = sanitized
public_send(attr).attach(sanitized)
end
end
def read_pending_icon(attachable)
case attachable
when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
content = attachable.read
attachable.rewind
[content, attachable.original_filename, attachable.content_type]
when Hash
io = attachable[:io] || attachable["io"]
return [nil, nil, nil] unless io
content = io.read
io.rewind if io.respond_to?(:rewind)
[content,
attachable[:filename] || attachable["filename"],
attachable[:content_type] || attachable["content_type"]]
else
[nil, nil, nil]
end
end end
def icon_validation def icon_validation
return unless icon.attached?
# Check content type
allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"] allowed_types = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"]
unless allowed_types.include?(icon.content_type)
errors.add(:icon, "must be a PNG, JPG, GIF, or SVG image") ICON_ATTACHMENTS.each do |attr|
attachment = public_send(attr)
next unless attachment.attached?
unless allowed_types.include?(attachment.content_type)
errors.add(attr, "must be a PNG, JPG, GIF, or SVG image")
end end
# Check file size (2MB limit) if attachment.blob.byte_size > 2.megabytes
if icon.blob.byte_size > 2.megabytes errors.add(attr, "must be less than 2MB")
errors.add(:icon, "must be less than 2MB") end
end end
end end
@@ -351,4 +391,17 @@ class Application < ApplicationRecord
# Let the format validator handle invalid URIs # Let the format validator handle invalid URIs
end end
end end
# SSRF guard: the backchannel logout URI is dialled server-side on every user
# logout, so it must not target internal infrastructure (loopback, private
# ranges, or the link-local cloud metadata endpoint). This is the fast,
# config-time check; BackchannelLogoutJob re-checks with DNS resolution.
def backchannel_logout_uri_not_internal
uri = URI.parse(backchannel_logout_uri)
if uri.host.present? && PrivateAddressCheck.internal_host?(uri.host)
errors.add(:backchannel_logout_uri, "must not point to a private, loopback, or link-local address")
end
rescue URI::InvalidURIError
# Let the format validator handle invalid URIs
end
end end

View File

@@ -15,6 +15,11 @@ class Group < ApplicationRecord
normalizes :name, with: ->(name) { name.strip.downcase } normalizes :name, with: ->(name) { name.strip.downcase }
validate :no_reserved_claim_names validate :no_reserved_claim_names
scope :auto_assign, -> { where(auto_assign: true) }
scope :admin, -> { where(admin: true) }
before_destroy :ensure_other_admin_group_exists
# Parse custom_claims JSON field # Parse custom_claims JSON field
def parsed_custom_claims def parsed_custom_claims
return {} if custom_claims.blank? return {} if custom_claims.blank?
@@ -23,6 +28,13 @@ class Group < ApplicationRecord
private private
def ensure_other_admin_group_exists
return unless admin?
return if Group.where(admin: true).where.not(id: id).exists?
errors.add(:base, "cannot delete the last administrators group")
throw :abort
end
def no_reserved_claim_names def no_reserved_claim_names
return if custom_claims.blank? return if custom_claims.blank?

View File

@@ -49,11 +49,21 @@ class OidcRefreshToken < ApplicationRecord
update!(revoked_at: Time.current) update!(revoked_at: Time.current)
end end
# Revoke all refresh tokens in the same family (token rotation security) # Revoke all refresh tokens in the same family (token rotation security).
# Also revoke every access token issued within the family: on a detected reuse
# attack the stolen chain's access tokens must not remain usable at /userinfo
# until they expire.
def revoke_family! def revoke_family!
return unless token_family_id.present? return unless token_family_id.present?
OidcRefreshToken.in_family(token_family_id).update_all(revoked_at: Time.current) now = Time.current
family = OidcRefreshToken.in_family(token_family_id)
access_token_ids = family.pluck(:oidc_access_token_id).compact.uniq
family.update_all(revoked_at: now)
if access_token_ids.any?
OidcAccessToken.where(id: access_token_ids, revoked_at: nil).update_all(revoked_at: now)
end
end end
private private

View File

@@ -7,6 +7,9 @@ class Session < ApplicationRecord
# Scopes # Scopes
scope :active, -> { where("expires_at > ?", Time.current) } scope :active, -> { where("expires_at > ?", Time.current) }
scope :expired, -> { where("expires_at <= ?", Time.current) } scope :expired, -> { where("expires_at <= ?", Time.current) }
# Sessions whose owning user is currently active. Used at request time so a
# disabled account cannot continue to authenticate with an existing session.
scope :for_active_user, -> { joins(:user).where(users: {status: User.statuses[:active]}) }
def expired? def expired?
expires_at.present? && expires_at <= Time.current expires_at.present? && expires_at <= Time.current

View File

@@ -0,0 +1,73 @@
# Loofah scrubber that strips dangerous content from SVG files
# while preserving safe SVG elements and attributes for icon display.
class SvgScrubber < Loofah::Scrubber
ALLOWED_ELEMENTS = %w[
svg g defs use symbol
circle ellipse line path polygon polyline rect
text tspan textPath
clipPath mask pattern
linearGradient radialGradient stop
filter feBlend feColorMatrix feComponentTransfer feComposite
feConvolveMatrix feDiffuseLighting feDisplacementMap feFlood
feGaussianBlur feImage feMerge feMergeNode feMorphology
feOffset feSpecularLighting feTile feTurbulence
title desc metadata
].freeze
ALLOWED_ATTRIBUTES = %w[
id class style
x y x1 y1 x2 y2 cx cy r rx ry
width height viewBox preserveAspectRatio
d points
fill stroke stroke-width stroke-linecap stroke-linejoin stroke-dasharray
opacity fill-opacity stroke-opacity
transform translate rotate scale
font-family font-size font-weight text-anchor
clip-path mask filter
gradientUnits gradientTransform spreadMethod
offset stop-color stop-opacity
dx dy textLength lengthAdjust
xmlns xmlns:xlink
color display visibility overflow
fill-rule clip-rule
marker-start marker-mid marker-end
].freeze
# Loofah hands attribute names back in their source case (e.g. "viewBox").
# Compare against a downcased copy so SVG-spec camelCase attributes aren't
# stripped from legitimate icons.
ALLOWED_ATTRIBUTES_LOOKUP = ALLOWED_ATTRIBUTES.map(&:downcase).to_set.freeze
# Event handler attributes that must always be removed
EVENT_HANDLER_PATTERN = /\Aon/i
def initialize
@direction = :top_down
end
def scrub(node)
return CONTINUE if node.text? || node.cdata?
if node.element?
if ALLOWED_ELEMENTS.include?(node.name)
# Remove disallowed and event handler attributes
node.attribute_nodes.each do |attr|
attr.remove unless safe_attribute?(attr)
end
return CONTINUE
end
end
node.remove
STOP
end
private
def safe_attribute?(attr)
name = attr.name.downcase
return false if name.match?(EVENT_HANDLER_PATTERN)
return false if attr.value&.match?(/javascript:|data:/i)
ALLOWED_ATTRIBUTES_LOOKUP.include?(name)
end
end

View File

@@ -41,8 +41,24 @@ class User < ApplicationRecord
# Enum - automatically creates scopes (User.active, User.disabled, etc.) # Enum - automatically creates scopes (User.active, User.disabled, etc.)
enum :status, {active: 0, disabled: 1, pending_invitation: 2} enum :status, {active: 0, disabled: 1, pending_invitation: 2}
# When an account stops being active (e.g. an admin disables it), immediately
# terminate its sessions so access is revoked everywhere, not just on expiry.
# Defence-in-depth: session lookup also filters by active status at request time.
after_update_commit :revoke_sessions_when_deactivated
# Scopes # Scopes
scope :admins, -> { where(admin: true) } scope :admins, -> { joins(:groups).where(groups: {admin: true}).distinct }
# Set true on a user (or on the user_params) to skip the auto-assign callback
# for that record. Used by the admin invite form (opt-out checkbox) and by
# tests that want a clean slate.
attr_accessor :skip_auto_assign
after_create :add_to_auto_assign_groups, unless: :skip_auto_assign
def admin?
groups.any?(&:admin?)
end
# TOTP methods # TOTP methods
def totp_enabled? def totp_enabled?
@@ -52,7 +68,10 @@ class User < ApplicationRecord
def enable_totp! def enable_totp!
require "rotp" require "rotp"
self.totp_secret = ROTP::Base32.random self.totp_secret = ROTP::Base32.random
self.backup_codes = generate_backup_codes # generate_backup_codes assigns the BCrypt hashes to self.backup_codes and
# returns the plaintext codes for display. Do NOT reassign backup_codes to the
# return value — that would store the plaintext codes and break verification.
generate_backup_codes
save! save!
end end
@@ -75,7 +94,13 @@ class User < ApplicationRecord
require "rotp" require "rotp"
totp = ROTP::TOTP.new(totp_secret) totp = ROTP::TOTP.new(totp_secret)
totp.verify(code, drift_behind: 30, drift_ahead: 30) # Pass `after:` so a code can only be accepted once: ROTP rejects any timestep
# at or before the last accepted one, closing the ~90s drift-window replay.
verified_at = totp.verify(code, drift_behind: 30, drift_ahead: 30, after: last_otp_at)
return false unless verified_at
update_column(:last_otp_at, verified_at)
true
end end
# Console/debug helper: get current TOTP code # Console/debug helper: get current TOTP code
@@ -222,6 +247,17 @@ class User < ApplicationRecord
private private
def add_to_auto_assign_groups
Group.auto_assign.each { |g| groups << g }
end
def revoke_sessions_when_deactivated
return unless saved_change_to_status?
return if active?
sessions.destroy_all
end
def no_reserved_claim_names def no_reserved_claim_names
return if custom_claims.blank? return if custom_claims.blank?

View File

@@ -52,13 +52,17 @@ class WebauthnCredential < ApplicationRecord
end end
end end
# Check if sign count is suspicious (clone detection) # Check if sign count is suspicious (clone detection).
#
# Per WebAuthn §6.1.1, a signature counter of 0 means the authenticator does
# not implement a counter (true of most synced passkeys — Apple/Google report
# 0 every time), so it cannot be used for clone detection. Only when BOTH the
# stored and presented counts are non-zero does a non-increasing value signal
# a possible clone.
def suspicious_sign_count?(new_sign_count) def suspicious_sign_count?(new_sign_count)
return false if sign_count.zero? && new_sign_count > 0 # First use return false if sign_count.zero? || new_sign_count.zero?
return false if new_sign_count > sign_count # Normal increment
# Sign count didn't increase - possible clone new_sign_count <= sign_count
true
end end
# Format for display in UI # Format for display in UI

View File

@@ -0,0 +1,77 @@
<div class="mb-6">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Access check</h1>
<p class="mt-2 text-sm text-gray-700 dark:text-gray-300">Pick a user and an application to see whether the user can access it and, if so, which group(s) grant that access.</p>
</div>
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<%= form_with url: admin_access_path, method: :get, class: "space-y-4" do |form| %>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<%= form.label :user_id, "User", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :user_id,
@users.map { |u| [u.email_address, u.id] },
{ include_blank: "Select a user…", selected: @user&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
<div>
<%= form.label :application_id, "Application", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.select :application_id,
@applications.map { |a| [a.name, a.id] },
{ include_blank: "Select an application…", selected: @application&.id },
class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div>
</div>
<div>
<%= form.submit "Check access", class: "rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600" %>
</div>
<% end %>
<% if @user && @application %>
<div class="mt-6 rounded-md border <%= @allowed ? "border-green-200 dark:border-green-700 bg-green-50 dark:bg-green-900/30" : "border-red-200 dark:border-red-700 bg-red-50 dark:bg-red-900/30" %> p-4">
<div class="flex items-start gap-3">
<% if @allowed %>
<svg class="h-6 w-6 text-green-600 dark:text-green-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
</svg>
<% else %>
<svg class="h-6 w-6 text-red-600 dark:text-red-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
<% end %>
<div class="flex-1">
<p class="text-sm font-medium <%= @allowed ? "text-green-800 dark:text-green-200" : "text-red-800 dark:text-red-200" %>">
<%= @user.email_address %> <%= @allowed ? "can access" : "cannot access" %> <%= @application.name %>.
</p>
<% if @allowed %>
<p class="mt-1 text-xs text-green-700 dark:text-green-300">
Granted via:
<% @via.each_with_index do |g, i| %>
<%= link_to g.name, admin_group_path(g), class: "underline" %><%= "," unless i == @via.size - 1 %>
<% end %>
</p>
<% else %>
<p class="mt-1 text-xs text-red-700 dark:text-red-300">
<% reasons = [] %>
<% reasons << "the application is inactive" unless @application.active? %>
<% reasons << "the user is #{@user.status.humanize.downcase}" unless @user.active? %>
<% if @application.active? && @user.active? %>
<% if @application.allowed_groups.empty? %>
<% reasons << "the application has no allowed groups (default deny)" %>
<% else %>
<% reasons << "the user shares no group with the application's allowed groups" %>
<% end %>
<% end %>
Reason: <%= reasons.join("; ") %>.
</p>
<% end %>
<p class="mt-2 text-xs text-gray-600 dark:text-gray-400">
<%= link_to "View user", admin_user_path(@user), class: "underline" %> ·
<%= link_to "View application", admin_application_path(@application), class: "underline" %>
</p>
</div>
</div>
</div>
<% end %>
</div>
</div>

View File

@@ -36,9 +36,9 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %> <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this application" %>
</div> </div>
<div> <div class="space-y-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between -mb-2">
<%= form.label :icon, "Application Icon", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <span class="block text-sm font-medium text-gray-700 dark:text-gray-300">Application Icons</span>
<a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1"> <a href="https://dashboardicons.com" target="_blank" rel="noopener noreferrer" class="text-xs text-blue-600 hover:text-blue-800 flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path>
@@ -46,75 +46,22 @@
Browse icons at dashboardicons.com Browse icons at dashboardicons.com
</a> </a>
</div> </div>
<% if application.icon.attached? && application.persisted? %>
<% begin %>
<%# Only show icon if we can successfully get its URL (blob is persisted) %>
<% if application.icon.blob&.persisted? && application.icon.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-4">
<%= image_tag application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700", alt: "Current icon" %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Current icon</p>
<p class="text-xs"><%= number_to_human_size(application.icon.blob.byte_size) %></p>
</div>
</div>
<% end %>
<% rescue ArgumentError => e %>
<%# Handle case where icon attachment exists but can't generate signed_id %>
<% if e.message.include?("Cannot get a signed_id for a new record") %>
<div class="mt-2 mb-3 text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium">Icon uploaded</p>
<p class="text-xs">File will be processed shortly</p>
</div>
<% else %>
<%# Re-raise if it's a different error %>
<% raise e %>
<% end %>
<% end %>
<% end %>
<div class="mt-2" data-controller="file-drop image-paste"> <%= render "icon_uploader",
<div class="flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-blue-400 transition-colors" form: form,
data-file-drop-target="dropzone" field: :icon,
data-image-paste-target="dropzone" label: "Icon",
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste" current_attached: (application.persisted? ? application.icon : nil),
tabindex="0"> current_label: "Current icon" %>
<div class="space-y-1 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500" stroke="currentColor" fill="none" viewBox="0 0 48 48"> <%= render "icon_uploader",
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /> form: form,
</svg> field: :icon_dark,
<div class="flex text-sm text-gray-600 dark:text-gray-400"> label: "Dark mode icon (optional)",
<label for="<%= form.field_id(:icon) %>" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 dark:focus-within:ring-offset-gray-900 focus-within:ring-blue-500"> help: "Used in place of the main icon when the user's theme is dark. If omitted, the main icon is used in both modes.",
<span>Upload a file</span> current_attached: (application.persisted? ? application.icon_dark : nil),
<%= form.file_field :icon, current_label: "Current dark-mode icon",
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml", preview_extra_class: "bg-gray-900" %>
class: "sr-only",
data: {
file_drop_target: "input",
image_paste_target: "input",
action: "change->file-drop#handleFiles"
} %>
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF, or SVG up to 2MB</p>
<p class="text-xs text-blue-600 font-medium mt-2">💡 Tip: Click here and press Ctrl+V (or Cmd+V) to paste an image from your clipboard</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-3 hidden">
<div class="flex items-center gap-3 p-3 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
<img data-file-drop-target="previewImage" class="h-12 w-12 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div> </div>
<div> <div>

View File

@@ -0,0 +1,66 @@
<%# Compact icon uploader. Locals:
form - the form builder
field - symbol for the file field (:icon or :icon_dark)
label - heading text
help - small helper paragraph (optional)
current_attached - the attachment to show as "current" preview
current_label - text for the preview row (e.g. "Current icon")
preview_extra_class - extra css for the preview img (e.g. "bg-gray-900")
%>
<div>
<%= form.label field, label, class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<% if local_assigns[:help].present? %>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400"><%= help %></p>
<% end %>
<% if current_attached&.attached? && current_attached.blob&.persisted? && current_attached.blob.key.present? %>
<div class="mt-2 mb-3 flex items-center gap-3">
<%= image_tag current_attached, class: "h-12 w-12 rounded-md object-cover border border-gray-200 dark:border-gray-700 #{local_assigns[:preview_extra_class]}", alt: current_label %>
<div class="text-sm text-gray-600 dark:text-gray-400">
<p class="font-medium"><%= current_label %></p>
<p class="text-xs"><%= number_to_human_size(current_attached.blob.byte_size) %></p>
</div>
</div>
<% end %>
<div class="mt-2" data-controller="file-drop image-paste">
<div class="flex items-center gap-3 px-3 py-2 border border-dashed border-gray-300 dark:border-gray-600 rounded-md hover:border-blue-400 focus-within:border-blue-500 focus-within:ring-1 focus-within:ring-blue-500 transition-colors"
data-file-drop-target="dropzone"
data-image-paste-target="dropzone"
data-action="dragover->file-drop#dragover dragleave->file-drop#dragleave drop->file-drop#drop paste->image-paste#handlePaste"
tabindex="0">
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500 shrink-0" stroke="currentColor" fill="none" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8" d="M12 4v12m0-12l-4 4m4-4l4 4M4 17v2a2 2 0 002 2h12a2 2 0 002-2v-2"/>
</svg>
<div class="flex-1 text-sm">
<label for="<%= form.field_id(field) %>" class="cursor-pointer font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
<span>Upload</span>
<%= form.file_field field,
accept: "image/png,image/jpg,image/jpeg,image/gif,image/svg+xml",
class: "sr-only",
data: {
file_drop_target: "input",
image_paste_target: "input",
action: "change->file-drop#handleFiles"
} %>
</label>
<span class="text-gray-600 dark:text-gray-400"> · drag and drop · or click and paste (⌘V)</span>
<p class="text-xs text-gray-500 dark:text-gray-400">PNG, JPG, GIF or SVG · max 2MB</p>
</div>
</div>
<div data-file-drop-target="preview" class="mt-2 hidden">
<div class="flex items-center gap-3 p-2 bg-blue-50 dark:bg-blue-900/30 rounded-md border border-blue-200 dark:border-blue-700">
<img data-file-drop-target="previewImage" class="h-10 w-10 rounded object-cover" alt="Preview">
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100" data-file-drop-target="filename"></p>
<p class="text-xs text-gray-500 dark:text-gray-400" data-file-drop-target="filesize"></p>
</div>
<button type="button" data-action="click->file-drop#clear" class="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
</div>
</div>

View File

@@ -8,6 +8,21 @@
</div> </div>
</div> </div>
<dl class="mt-4 grid grid-cols-3 gap-4">
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Applications</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @applications.size %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Users with access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_users_with_access %></dd>
</div>
<div class="rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 px-4 py-3">
<dt class="text-xs text-gray-500 dark:text-gray-400">Groups granting access</dt>
<dd class="mt-1 text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @total_groups_granting_access %></dd>
</div>
</dl>
<div class="mt-8 flow-root"> <div class="mt-8 flow-root">
<div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
@@ -18,7 +33,7 @@
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Slug</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Type</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Status</th>
<th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Groups</th> <th scope="col" class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900 dark:text-gray-100">Access</th>
<th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0"> <th scope="col" class="relative py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">Actions</span> <span class="sr-only">Actions</span>
</th> </th>
@@ -30,13 +45,9 @@
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0"> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<% if application.icon.attached? %> <% if application.icon.attached? %>
<%= image_tag application.icon, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0", alt: "#{application.name} icon" %> <%= app_icon_picture application, class: "h-10 w-10 rounded-lg object-cover border border-gray-200 dark:border-gray-700 flex-shrink-0" %>
<% else %> <% else %>
<div class="h-10 w-10 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center flex-shrink-0"> <%= render "shared/app_monogram", name: application.name, class: "h-10 w-10 rounded-lg flex-shrink-0" %>
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %> <% end %>
<%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %> <%= link_to application.name, admin_application_path(application), class: "text-blue-600 hover:text-blue-900" %>
</div> </div>
@@ -62,10 +73,13 @@
<% end %> <% end %>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if application.allowed_groups.empty? %> <% groups_count = application.allowed_groups.size %>
<span class="text-gray-400 dark:text-gray-500">All users</span> <% users_count = @user_count_by_app[application.id] || 0 %>
<% if groups_count.zero? %>
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/40 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">No one</span>
<% else %> <% else %>
<%= application.allowed_groups.count %> <span class="text-gray-700 dark:text-gray-200"><%= pluralize(users_count, "user") %></span>
<span class="text-gray-400 dark:text-gray-500"> · <%= pluralize(groups_count, "group") %></span>
<% end %> <% end %>
</td> </td>
<td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0"> <td class="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">

View File

@@ -25,6 +25,23 @@
Public clients do not have a client secret. PKCE is required. Public clients do not have a client secret. PKCE is required.
</div> </div>
<% end %> <% end %>
<% env_lines = oidc_env_lines(@application, client_secret: flash[:client_secret]) %>
<div class="mt-4" data-controller="clipboard">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-medium text-yellow-700 dark:text-yellow-300">Environment variables (copy &amp; paste):</span>
<button type="button"
data-action="clipboard#copy"
class="text-xs font-medium text-yellow-700 dark:text-yellow-300 hover:text-yellow-900 dark:hover:text-yellow-100 underline">
<span data-clipboard-target="label">Copy</span>
</button>
</div>
<textarea data-clipboard-target="source"
readonly
rows="<%= env_lines.length %>"
class="block w-full bg-yellow-100 dark:bg-yellow-900/50 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-yellow-500"><%= env_lines.join("\n") %></textarea>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>
@@ -32,13 +49,9 @@
<div class="sm:flex sm:items-start sm:justify-between"> <div class="sm:flex sm:items-start sm:justify-between">
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
<% if @application.icon.attached? %> <% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{@application.name} icon" %> <%= app_icon_picture @application, class: "h-16 w-16 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
<% else %> <% else %>
<div class="h-16 w-16 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 flex items-center justify-center shrink-0"> <%= render "shared/app_monogram", name: @application.name, class: "h-16 w-16 rounded-lg shrink-0" %>
<svg class="h-8 w-8 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %> <% end %>
<div> <div>
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @application.name %></h1>
@@ -157,6 +170,30 @@
</dd> </dd>
</div> </div>
<% end %> <% end %>
<div>
<details class="border border-gray-200 dark:border-gray-700 rounded-lg">
<summary class="cursor-pointer bg-gray-50 dark:bg-gray-700 px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300">
Environment variables
</summary>
<div class="px-4 py-3" data-controller="clipboard">
<% env_lines = oidc_env_lines(@application) %>
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-500 dark:text-gray-400">
<%= @application.confidential_client? ? "Replace <your-client-secret> with your saved secret." : "Public client — no secret required." %>
</span>
<button type="button"
data-action="clipboard#copy"
class="text-xs font-medium text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 underline">
<span data-clipboard-target="label">Copy</span>
</button>
</div>
<textarea data-clipboard-target="source"
readonly
rows="<%= env_lines.length %>"
class="block w-full bg-gray-100 dark:bg-gray-700 px-3 py-2 rounded font-mono text-xs text-gray-900 dark:text-gray-100 resize-none focus:outline-none focus:ring-1 focus:ring-gray-500"><%= env_lines.join("\n") %></textarea>
</div>
</details>
</div>
<% end %> <% end %>
<div> <div>
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Redirect URIs</dt>
@@ -233,11 +270,11 @@
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt> <dt class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Allowed Groups</dt>
<dd class="mt-1 text-sm text-gray-900 dark:text-gray-100"> <dd class="mt-1 text-sm text-gray-900 dark:text-gray-100">
<% if @allowed_groups.empty? %> <% if @allowed_groups.empty? %>
<div class="rounded-md bg-blue-50 dark:bg-blue-900/30 p-4"> <div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
<div class="flex"> <div class="flex">
<div class="ml-3"> <div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-300"> <p class="text-sm text-amber-700 dark:text-amber-300">
No groups assigned - all active users can access this application. No groups assigned — no one can access this application. Attach a group to grant access.
</p> </p>
</div> </div>
</div> </div>
@@ -258,4 +295,35 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Users with access -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Users with access (<%= @users_with_access.count %>)
</h3>
<% if @users_with_access.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @users_with_access.each do |user| %>
<% via = user.groups & @application.allowed_groups %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= user.email_address %></p>
<div class="flex flex-wrap gap-1 mt-1">
<% via.each do |g| %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
<% end %>
</div>
</div>
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No users currently have access. Attach a group to grant access.</p>
</div>
<% end %>
</div>
</div>
</div> </div>

View File

@@ -12,6 +12,22 @@
<%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %> <%= form.text_area :description, rows: 3, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm", placeholder: "Optional description of this group" %>
</div> </div>
<div>
<div class="flex items-center">
<%= form.check_box :auto_assign, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :auto_assign, "Auto Assign", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">New users will be automatically added to this group when invited. You can mark multiple groups as auto-assigned.</p>
</div>
<div>
<div class="flex items-center">
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :admin, "Administrators", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Members of this group can access the admin panel. Does not grant automatic access to applications.</p>
</div>
<div> <div>
<%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= form.label :user_ids, "Group Members", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3"> <div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
@@ -32,6 +48,29 @@
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which users should be members of this group.</p>
</div> </div>
<div>
<%= form.label :application_ids, "Assigned Applications", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-48 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_applications.any? %>
<% @available_applications.each do |application| %>
<div class="flex items-center">
<%= check_box_tag "group[application_ids][]", application.id, group.applications.include?(application), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "group_application_ids_#{application.id}", application.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% case application.app_type %>
<% when "oidc" %>
<span class="ml-2 inline-flex items-center rounded-full bg-purple-100 dark:bg-purple-900/50 px-2 py-0.5 text-xs font-medium text-purple-700 dark:text-purple-300">OIDC</span>
<% when "trusted_header" %>
<span class="ml-2 inline-flex items-center rounded-full bg-indigo-100 dark:bg-indigo-900/50 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:text-indigo-300">ForwardAuth</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">No applications available.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Select which applications this group grants access to.</p>
</div>
<div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600"> <div data-controller="json-validator" data-json-validator-valid-class="border-green-500 focus:border-green-500 focus:ring-green-500" data-json-validator-invalid-class="border-red-500 focus:border-red-500 focus:ring-red-500" data-json-validator-valid-status-class="text-green-600" data-json-validator-invalid-status-class="text-red-600">
<%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %> <%= form.label :custom_claims, "Custom Claims (JSON)", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8, <%= form.text_area :custom_claims, value: (group.custom_claims.present? ? JSON.pretty_generate(group.custom_claims) : ""), rows: 8,

View File

@@ -1,7 +1,15 @@
<div class="mb-6"> <div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between"> <div class="sm:flex sm:items-center sm:justify-between">
<div> <div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1> <h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @group.name %></h1>
<% if @group.auto_assign? %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
<% if @group.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Administrators</span>
<% end %>
</div>
<% if @group.description.present? %> <% if @group.description.present? %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p> <p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @group.description %></p>
<% end %> <% end %>

View File

@@ -33,11 +33,36 @@
<%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %> <%= form.select :status, User.statuses.keys.map { |s| [s.titleize, s] }, {}, class: "mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm" %>
</div> </div>
<div>
<%= form.label :group_ids, "Group Memberships", class: "block text-sm font-medium text-gray-700 dark:text-gray-300" %>
<div class="mt-2 space-y-2 max-h-64 overflow-y-auto border border-gray-200 dark:border-gray-700 rounded-md p-3">
<% if @available_groups.any? %>
<% @available_groups.each do |group| %>
<div class="flex items-center"> <div class="flex items-center">
<%= form.check_box :admin, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500", disabled: (user == Current.session.user) %> <%= check_box_tag "user[group_ids][]", group.id, user.groups.include?(group), class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= form.label :admin, "Administrator", class: "ml-2 block text-sm text-gray-900 dark:text-gray-100" %> <%= label_tag "user_group_ids_#{group.id}", group.name, class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
<% if user == Current.session.user %> <% if group.admin? %>
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">(Cannot change your own admin status)</span> <span class="ml-2 inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if group.auto_assign? %>
<span class="ml-2 inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
</div>
<% end %>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">No groups available. Create a group first.</p>
<% end %>
</div>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Administrators are members of any group with the Admin flag set. You cannot remove yourself from your last administrator group.</p>
<% unless user.persisted? %>
<% auto_names = Group.where(auto_assign: true).pluck(:name) %>
<% if auto_names.any? %>
<div class="mt-2 flex items-center">
<%= check_box_tag "auto_assign", "1", true, class: "h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500" %>
<%= label_tag "auto_assign", "Auto-assign to default groups (#{auto_names.join(", ")})", class: "ml-2 text-sm text-gray-900 dark:text-gray-100" %>
</div>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">Uncheck to invite this user without auto-assigning the default group(s) — useful for restricted accounts.</p>
<% end %>
<% end %> <% end %>
</div> </div>

View File

@@ -61,7 +61,7 @@
<% @users.each do |user| %> <% @users.each do |user| %>
<tr> <tr>
<td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0"> <td class="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 dark:text-gray-100 sm:pl-0">
<%= user.email_address %> <%= link_to user.email_address, admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
</td> </td>
<td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400"> <td class="whitespace-nowrap px-3 py-4 text-sm text-gray-500 dark:text-gray-400">
<% if user.status.present? %> <% if user.status.present? %>
@@ -110,6 +110,7 @@
data: { turbo_method: :post }, data: { turbo_method: :post },
class: "text-yellow-600 hover:text-yellow-900" %> class: "text-yellow-600 hover:text-yellow-900" %>
<% end %> <% end %>
<%= link_to "View", admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
<%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %> <%= link_to "Edit", edit_admin_user_path(user), class: "text-blue-600 hover:text-blue-900" %>
<%= link_to "Delete", admin_user_path(user), <%= link_to "Delete", admin_user_path(user),
data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" }, data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this user?" },

View File

@@ -0,0 +1,95 @@
<div class="mb-6">
<div class="sm:flex sm:items-center sm:justify-between">
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100"><%= @user.email_address %></h1>
<% if @user.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% case @user.status %>
<% when "active" %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Active</span>
<% when "disabled" %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">Disabled</span>
<% when "pending_invitation" %>
<span class="inline-flex items-center rounded-full bg-amber-100 dark:bg-amber-900/50 px-2 py-0.5 text-xs font-medium text-amber-700 dark:text-amber-300">Pending Invitation</span>
<% end %>
</div>
<% if @user.name.present? %>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400"><%= @user.name %></p>
<% end %>
</div>
<div class="mt-4 sm:mt-0 flex gap-3">
<%= link_to "Edit", edit_admin_user_path(@user), class: "rounded-md bg-white dark:bg-gray-700 px-3 py-2 text-sm font-semibold text-gray-900 dark:text-gray-200 shadow-sm ring-1 ring-inset ring-gray-300 dark:ring-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600" %>
</div>
</div>
</div>
<div class="space-y-6">
<!-- Group memberships -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Group memberships (<%= @user.groups.count %>)
</h3>
<% if @user.groups.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @user.groups.order(:name).each do |group| %>
<li class="px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= group.name %></p>
<% if group.admin? %>
<span class="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/50 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">Admin</span>
<% end %>
<% if group.auto_assign? %>
<span class="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/50 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">Auto Assign</span>
<% end %>
</div>
<%= link_to "View", admin_group_path(group), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<p class="text-sm text-gray-500 dark:text-gray-400">This user is in no groups.</p>
<% end %>
</div>
</div>
<!-- Accessible applications -->
<div class="bg-white dark:bg-gray-800 shadow sm:rounded-lg">
<div class="px-4 py-5 sm:p-6">
<h3 class="text-base font-semibold leading-6 text-gray-900 dark:text-gray-100 mb-4">
Accessible applications (<%= @accessible_applications.count %>)
</h3>
<% unless @user.active? %>
<div class="rounded-md bg-amber-50 dark:bg-amber-900/30 p-4">
<p class="text-sm text-amber-700 dark:text-amber-300">
User is <%= @user.status.humanize.downcase %> — access is denied regardless of group memberships.
</p>
</div>
<% end %>
<% if @accessible_applications.any? %>
<ul class="divide-y divide-gray-200 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-md">
<% @accessible_applications.each do |app| %>
<% via = app.allowed_groups & @user.groups %>
<li class="px-4 py-3 flex items-center justify-between">
<div>
<p class="text-sm font-medium text-gray-900 dark:text-gray-100"><%= app.name %></p>
<div class="flex flex-wrap gap-1 mt-1">
<% via.each do |g| %>
<span class="inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700 px-2 py-0.5 text-xs font-medium text-gray-700 dark:text-gray-300">via <%= g.name %></span>
<% end %>
</div>
</div>
<%= link_to "View", admin_application_path(app), class: "text-blue-600 hover:text-blue-900 text-sm" %>
</li>
<% end %>
</ul>
<% else %>
<div class="rounded-md bg-gray-50 dark:bg-gray-700 p-4">
<p class="text-sm text-gray-500 dark:text-gray-400">No accessible applications. Add the user to a group that's attached to one or more applications.</p>
</div>
<% end %>
</div>
</div>
</div>

View File

@@ -130,13 +130,9 @@
<div class="p-6"> <div class="p-6">
<div class="flex items-start gap-3 mb-4"> <div class="flex items-start gap-3 mb-4">
<% if app.icon.attached? %> <% if app.icon.attached? %>
<%= image_tag app.icon, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0", alt: "#{app.name} icon" %> <%= app_icon_picture app, class: "h-12 w-12 rounded-lg object-cover border border-gray-200 dark:border-gray-700 shrink-0" %>
<% else %> <% else %>
<div class="h-12 w-12 rounded-lg bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-700 flex items-center justify-center shrink-0"> <%= render "shared/app_monogram", name: app.name, class: "h-12 w-12 rounded-lg shrink-0" %>
<svg class="h-6 w-6 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<% end %> <% end %>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">

View File

@@ -9,7 +9,7 @@
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %> <%= csp_meta_tag %>
<script> <script nonce="<%= content_security_policy_nonce %>">
(function() { (function() {
var theme = localStorage.getItem('theme'); var theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) { if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {

View File

@@ -2,12 +2,12 @@
<div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10"> <div class="bg-white dark:bg-gray-800 py-8 px-6 shadow rounded-lg sm:px-10">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<% if @application.icon.attached? %> <% if @application.icon.attached? %>
<%= image_tag @application.icon, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm mb-4", alt: "#{@application.name} icon" %> <div class="mx-auto h-20 w-20 mb-4">
<%= app_icon_picture @application, class: "mx-auto h-20 w-20 rounded-xl object-cover border-2 border-gray-200 dark:border-gray-700 shadow-sm" %>
</div>
<% else %> <% else %>
<div class="mx-auto h-20 w-20 rounded-xl bg-gray-100 dark:bg-gray-700 border-2 border-gray-200 dark:border-gray-700 flex items-center justify-center mb-4"> <div class="mx-auto mb-4">
<svg class="h-10 w-10 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <%= render "shared/app_monogram", name: @application.name, class: "h-20 w-20 rounded-xl shadow-sm" %>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div> </div>
<% end %> <% end %>
<h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2> <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-100">Authorize Application</h2>

View File

@@ -0,0 +1,11 @@
<hr>
<p>
This action was recorded at <strong><%= @occurred_at.to_fs(:long) %></strong>
from IP <strong><%= @ip %></strong>
using <strong><%= @user_agent.presence || "an unknown client" %></strong>.
</p>
<p>
If you did <strong>not</strong> perform this action, reset your password
immediately and contact your administrator.
</p>

View File

@@ -0,0 +1,7 @@
---
This action was recorded at <%= @occurred_at.to_fs(:long) %>
from IP <%= @ip %>
using <%= @user_agent.presence || "an unknown client" %>.
If you did not perform this action, reset your password immediately
and contact your administrator.

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new API key (<strong><%= @api_key_name %></strong>) was just created
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new API key ("<%= @api_key_name %>") was just created on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The API key <strong><%= @api_key_name %></strong> was just revoked
on your Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
The API key "<%= @api_key_name %>" was just revoked on your Clinch
account (<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,9 @@
<p>Hello,</p>
<p>
A new set of two-factor backup codes was generated on your Clinch
account (<strong><%= @user.email_address %></strong>).
Any previous backup codes are now invalid.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new set of two-factor backup codes was generated on your Clinch account
(<%= @user.email_address %>). Any previous backup codes are now invalid.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,22 @@
<p>Hello,</p>
<% if @recipient == @new_email %>
<p>
The email address on your Clinch account is now
<strong><%= @new_email %></strong>.
It was previously <strong><%= @old_email %></strong>.
</p>
<% else %>
<p>
The email address on your Clinch account was changed away from this
address (<strong><%= @old_email %></strong>) to
<strong><%= @new_email %></strong>.
</p>
<p>
If this was <strong>not</strong> you, contact your administrator
immediately — whoever made the change can now receive password
reset emails for the account.
</p>
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,14 @@
Hello,
<% if @recipient == @new_email %>
The email address on your Clinch account is now <%= @new_email %>.
It was previously <%= @old_email %>.
<% else %>
The email address on your Clinch account was changed away from this
address (<%= @old_email %>) to <%= @new_email %>.
If this was not you, contact your administrator immediately — whoever
made the change can now receive password reset emails for the account.
<% end %>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A new passkey (<strong><%= @nickname %></strong>) was just added to your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A new passkey ("<%= @nickname %>") was just added to your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
A passkey (<strong><%= @nickname %></strong>) was just removed from your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
A passkey ("<%= @nickname %>") was just removed from your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
The password on your Clinch account
(<strong><%= @user.email_address %></strong>) was just changed.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,5 @@
Hello,
The password on your Clinch account (<%= @user.email_address %>) was just changed.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,16 @@
<p>Hello,</p>
<p>
A sign-in to your Clinch account (<strong><%= @user.email_address %></strong>)
using your passkey (<strong><%= @nickname %></strong>) was <strong>blocked</strong>
because its security counter did not advance as expected. This can indicate the
passkey has been copied (cloned).
</p>
<p>
If this was you and you are unable to sign in, remove this passkey and register
a new one. If you do not recognise this activity, treat it as a compromise:
remove the passkey and review your account security.
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,11 @@
Hello,
A sign-in to your Clinch account (<%= @user.email_address %>) using your passkey
("<%= @nickname %>") was BLOCKED because its security counter did not advance as
expected. This can indicate the passkey has been copied (cloned).
If this was you and you are unable to sign in, remove this passkey and register a
new one. If you do not recognise this activity, treat it as a compromise: remove
the passkey and review your account security.
<%= render "event_metadata" %>

View File

@@ -0,0 +1,8 @@
<p>Hello,</p>
<p>
Two-factor authentication was just <strong>disabled</strong> on your
Clinch account (<strong><%= @user.email_address %></strong>).
</p>
<%= render "event_metadata" %>

View File

@@ -0,0 +1,6 @@
Hello,
Two-factor authentication was just disabled on your Clinch account
(<%= @user.email_address %>).
<%= render "event_metadata" %>

View File

@@ -38,7 +38,7 @@
</svg> </svg>
Continue with Passkey Continue with Passkey
</button> </button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div> <div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div> </div>
<!-- Password section - shown by default, hidden if WebAuthn is required --> <!-- Password section - shown by default, hidden if WebAuthn is required -->

View File

@@ -54,7 +54,7 @@
</svg> </svg>
Use Passkey Instead Use Passkey Instead
</button> </button>
<div data-webauthn-target="error" class="mt-2 text-sm text-red-600" style="display: none;"></div> <div data-webauthn-target="error" class="mt-2 text-sm text-red-600 hidden"></div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -0,0 +1,18 @@
<%# Renders a deterministic monogram SVG for an Application that has no icon.
Locals:
name - the application name (required)
class - css classes for the <svg> element (e.g. "h-12 w-12 rounded-lg")
%>
<% initials = monogram_initials(name) %>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"
class="<%= local_assigns[:class] || "h-12 w-12 rounded-lg" %>"
role="img" aria-label="<%= name %> icon">
<rect width="40" height="40" fill="<%= monogram_color(name) %>" />
<text x="50%" y="52%" dominant-baseline="middle" text-anchor="middle"
font-family="ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif"
font-weight="600" fill="#ffffff"
font-size="<%= initials.length == 1 ? 22 : 17 %>"
letter-spacing="-0.5">
<%= initials %>
</text>
</svg>

View File

@@ -66,6 +66,16 @@
Groups Groups
<% end %> <% end %>
</li> </li>
<!-- Admin: Access check -->
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }" do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %> <% end %>
<!-- Profile --> <!-- Profile -->
@@ -115,6 +125,10 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="mt-auto pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</li>
</ul> </ul>
</nav> </nav>
</div> </div>
@@ -192,6 +206,14 @@
Groups Groups
<% end %> <% end %>
</li> </li>
<li>
<%= link_to admin_access_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path.start_with?('/admin/access') ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
<svg class="h-6 w-6 shrink-0" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Access check
<% end %>
</li>
<% end %> <% end %>
<li> <li>
<%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %> <%= link_to profile_path, class: "group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6 #{ current_path == '/profile' ? 'bg-gray-50 dark:bg-gray-800 text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 hover:bg-gray-50 dark:hover:bg-gray-800' }", data: { action: "click->mobile-sidebar#closeSidebar" } do %>
@@ -233,6 +255,10 @@
<% end %> <% end %>
</li> </li>
</ul> </ul>
<div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
<%= render "shared/version_info" %>
</div>
</nav> </nav>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,4 @@
<div class="px-2 text-xs text-gray-500 dark:text-gray-500 space-y-0.5">
<div>Clinch <%= Clinch::VERSION %></div>
<div>Rails <%= Rails.version %> &middot; Ruby <%= RUBY_VERSION %></div>
</div>

View File

@@ -38,10 +38,6 @@ env:
secret: secret:
- RAILS_MASTER_KEY - RAILS_MASTER_KEY
clear: clear:
# Run the Solid Queue Supervisor inside the web server's Puma process to do jobs.
# When you start using multiple servers, you should split out job processing to a dedicated machine.
SOLID_QUEUE_IN_PUMA: true
# Set number of processes dedicated to Solid Queue (default: 1) # Set number of processes dedicated to Solid Queue (default: 1)
# JOB_CONCURRENCY: 3 # JOB_CONCURRENCY: 3

View File

@@ -118,14 +118,17 @@ Rails.application.configure do
registrable_domain = domain.domain # Gets "example.com" from "auth.example.com" registrable_domain = domain.domain # Gets "example.com" from "auth.example.com"
if registrable_domain.present? if registrable_domain.present?
# Create regex to allow any subdomain of the registrable domain # Allow the registrable domain and any subdomain of it. The pattern is
allowed_hosts << /.*#{Regexp.escape(registrable_domain)}/ # anchored (\A...\z) with a mandatory dot before the domain so that
# look-alikes such as "evil-example.com" or "example.com.attacker.com"
# do NOT match — an unanchored /.*example\.com/ would allow both.
allowed_hosts << /\A(.+\.)?#{Regexp.escape(registrable_domain)}\z/i
end end
rescue PublicSuffix::DomainInvalid rescue PublicSuffix::DomainInvalid
# Fallback to simple domain extraction if PublicSuffix fails # Fallback to simple domain extraction if PublicSuffix fails
Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback" Rails.logger.warn "Could not parse domain '#{host_domain}' with PublicSuffix, using fallback"
base_domain = host_domain.split(".").last(2).join(".") base_domain = host_domain.split(".").last(2).join(".")
allowed_hosts << /.*#{Regexp.escape(base_domain)}/ allowed_hosts << /\A(.+\.)?#{Regexp.escape(base_domain)}\z/i
end end
end end
@@ -136,9 +139,6 @@ Rails.application.configure do
# Allow internal IP access for cross-compose or host networking # Allow internal IP access for cross-compose or host networking
if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true" if ENV["CLINCH_ALLOW_INTERNAL_IPS"] == "true"
# Specific host IP
allowed_hosts << "192.168.2.246"
# Private IP ranges for internal network access # Private IP ranges for internal network access
allowed_hosts += [ allowed_hosts += [
/192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network /192\.168\.\d+\.\d+/, # 192.168.0.0/16 private network
@@ -157,17 +157,5 @@ Rails.application.configure do
# Skip DNS rebinding protection for the default health check endpoint. # Skip DNS rebinding protection for the default health check endpoint.
config.host_authorization = {exclude: ->(request) { request.path == "/up" }} config.host_authorization = {exclude: ->(request) { request.path == "/up" }}
# Sentry configuration for production # Sentry is configured in config/initializers/sentry.rb, gated on SENTRY_DSN.
# Only enabled if SENTRY_DSN environment variable is set
if ENV["SENTRY_DSN"].present?
config.sentry.enabled = true
# Performance monitoring: sample 20% of transactions for traces
# Adjust based on your traffic volume and Sentry plan limits
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.2).to_f
# Continuous profiling: disabled by default in production due to cost
# Enable temporarily for performance investigations if needed
config.sentry.profiles_sample_rate = ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
end
end end

View File

@@ -0,0 +1,16 @@
# CLINCH_HOST is this IdP's canonical external origin, e.g. https://auth.example.com.
# It anchors the OIDC issuer, the WebAuthn RP ID, and the forward-auth login
# redirect. In deployed (non-local) environments it MUST be set explicitly and
# never inferred from request headers — X-Forwarded-Host is attacker-influenceable,
# so inferring the origin from it would allow host-header phishing and open
# redirects. Fail fast at boot rather than start in an unsafe configuration.
#
# Skipped during asset precompilation (e.g. the Docker build step, which sets
# SECRET_KEY_BASE_DUMMY): no real CLINCH_HOST exists yet and assets don't need it.
unless Rails.env.local? || ENV["SECRET_KEY_BASE_DUMMY"].present?
if ENV["CLINCH_HOST"].blank?
raise "CLINCH_HOST must be set (e.g. https://auth.example.com). It is the " \
"canonical origin of this Clinch instance and must not be inferred " \
"from request headers."
end
end

View File

@@ -9,13 +9,15 @@ Rails.application.configure do
# Default to self for everything, plus blob: for file downloads # Default to self for everything, plus blob: for file downloads
policy.default_src :self, "blob:" policy.default_src :self, "blob:"
# Scripts: Allow self, importmaps, unsafe-inline for Turbo/StimulusJS, and blob: for downloads # Scripts: self + per-response nonce (see nonce config below) + blob: for
# Note: unsafe_inline is needed for Stimulus controllers and Turbo navigation # downloads. No unsafe-inline — importmap/Turbo/Stimulus inline tags carry the
policy.script_src :self, :unsafe_inline, "blob:" # nonce automatically, and the one hand-written inline script is nonced.
policy.script_src :self, "blob:"
# Styles: Allow self and unsafe_inline for TailwindCSS dynamic classes # Styles: self + per-response nonce. No unsafe-inline Tailwind ships as an
# and Stimulus controller style manipulations # external stylesheet, Turbo's injected <style> carries the nonce, and Stimulus
policy.style_src :self, :unsafe_inline # sets styles via the CSSOM (not governed by CSP).
policy.style_src :self
# Images: Allow self, data URLs, and https for external images # Images: Allow self, data URLs, and https for external images
policy.img_src :self, :data, :https policy.img_src :self, :data, :https
@@ -51,14 +53,22 @@ Rails.application.configure do
# Child sources: Allow self for any future iframes # Child sources: Allow self for any future iframes
policy.child_src :self policy.child_src :self
# Additional security headers for WebAuthn # Do not enforce Trusted Types. The only valid value for
# Required for WebAuthn to work properly # require-trusted-types-for is 'script'; there is no 'none' token, so
policy.require_trusted_types_for :none # emitting it produces an invalid directive that browsers reject. To leave
# Trusted Types unenforced (needed for WebAuthn), omit the directive entirely.
# CSP reporting using report_uri (supported method) # CSP reporting using report_uri (supported method)
policy.report_uri "/api/csp-violation-report" policy.report_uri "/api/csp-violation-report"
end end
# Per-response random nonce applied to script-src and style-src. The app does
# not page-cache HTML, so a fresh random nonce per response is the most secure
# choice (no reuse across responses). csp_meta_tag (in the layout) and
# importmap-rails read this nonce automatically.
config.content_security_policy_nonce_generator = ->(_request) { SecureRandom.base64(16) }
config.content_security_policy_nonce_directives = %w[script-src style-src]
# Start with CSP in report-only mode for testing # Start with CSP in report-only mode for testing
# Set to false after verifying everything works in production # Set to false after verifying everything works in production
config.content_security_policy_report_only = Rails.env.development? config.content_security_policy_report_only = Rails.env.development?

View File

@@ -4,5 +4,8 @@
# Use this to limit dissemination of sensitive information. # Use this to limit dissemination of sensitive information.
# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. # See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors.
Rails.application.config.filter_parameters += [ Rails.application.config.filter_parameters += [
:passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :cvv, :cvc, :backup,
# :code partially matches the TOTP/backup `code` param, the OAuth authorization
# `code`, and the PKCE `code_verifier`/`code_challenge` — all sensitive in logs.
:code
] ]

View File

@@ -1,62 +1,44 @@
# Sentry configuration for error tracking and performance monitoring # Sentry configuration for error tracking and performance monitoring.
# Only initializes if SENTRY_DSN environment variable is set # Only initializes if the SENTRY_DSN environment variable is set.
return unless ENV["SENTRY_DSN"].present? return unless ENV["SENTRY_DSN"].present?
Rails.application.configure do Sentry.init do |config|
config.sentry.dsn = ENV["SENTRY_DSN"] config.dsn = ENV["SENTRY_DSN"]
# Set environment (defaults to Rails.env) # Environment label (defaults to Rails.env)
config.sentry.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env
# Set release version from Git or environment variable # Release version from an env var or the current Git SHA
config.sentry.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence || nil config.release = ENV["SENTRY_RELEASE"] || `git rev-parse HEAD 2>/dev/null`.strip.presence
# Sample rate for performance monitoring (0.0 to 1.0) # Only report from production unless explicitly enabled elsewhere.
config.sentry.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f config.enabled_environments =
if ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
%w[production development]
else
%w[production]
end
# Enable profiling in development/staging, disable in production unless explicitly enabled # Don't send cookies, request bodies, or user IPs by default.
config.sentry.profiles_sample_rate = if Rails.env.production? config.send_default_pii = false
# Breadcrumbs for debugging
config.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Performance monitoring sample rate (0.0 to 1.0)
config.traces_sample_rate = ENV.fetch("SENTRY_TRACES_SAMPLE_RATE", 0.1).to_f
# Profiling: disabled in production by default due to cost.
config.profiles_sample_rate =
if Rails.env.production?
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.0).to_f
else else
ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f ENV.fetch("SENTRY_PROFILES_SAMPLE_RATE", 0.5).to_f
end end
# Include additional context
config.sentry.before_send = lambda do |event, hint|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
event
end
# Include breadcrumbs for debugging
config.sentry.breadcrumbs_logger = [:active_support_logger, :http_logger]
# Send session data for user context
config.sentry.user_context = lambda do
if Current.user.present?
{
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
}
end
end
# Ignore common non-critical exceptions # Ignore common non-critical exceptions
config.sentry.excluded_exceptions += [ config.excluded_exceptions += [
"ActionController::RoutingError", "ActionController::RoutingError",
"ActionController::InvalidAuthenticityToken", "ActionController::InvalidAuthenticityToken",
"ActionController::UnknownFormat", "ActionController::UnknownFormat",
@@ -66,75 +48,38 @@ Rails.application.configure do
"ActiveRecord::RecordNotFound" "ActiveRecord::RecordNotFound"
] ]
# Add CSP-specific tags for security events # Attach application/user context and scrub anything sensitive before sending.
config.sentry.tags = lambda do config.before_send = lambda do |event, _hint|
{ event.tags = (event.tags || {}).merge(
# Add application context
app_name: "clinch", app_name: "clinch",
app_environment: Rails.env, app_environment: Rails.env
# Add CSP policy status )
csp_enabled: defined?(Rails.application.config.content_security_policy) &&
Rails.application.config.content_security_policy.present? if defined?(Current) && Current.respond_to?(:user) && Current.user
} event.user = (event.user || {}).merge(
id: Current.user.id,
email: Current.user.email_address,
admin: Current.user.admin?
)
end end
# Enhance before_send to handle CSP events properly if event.extra.is_a?(Hash)
config.sentry.before_send = lambda do |event, hint| event.extra.reject! do |key, value|
# Filter out sensitive information
if event.context[:extra]
event.context[:extra].reject! { |key, value|
key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i) key.to_s.match?(/password|secret|token|key/i) || value.to_s.match?(/password|secret/i)
}
end end
# Filter sensitive parameters
if event.context[:request]
event.context[:request].reject! { |key, value|
key.to_s.match?(/password|secret|token|key|authorization/i)
}
end
# Special handling for CSP violations
if event.tags&.dig(:csp_violation)
# Ensure CSP violations have proper security context
event.context[:server] = event.context[:server] || {}
event.context[:server][:name] = "clinch-auth-service"
event.context[:server][:environment] = Rails.env
# Add additional security context
event.context[:extra] ||= {}
event.context[:extra][:security_context] = {
csp_reporting: true,
user_authenticated: event.context[:user].present?,
request_origin: event.context[:request]&.dig(:headers, "Origin"),
request_referer: event.context[:request]&.dig(:headers, "Referer")
}
end end
event event
end end
# Add CSP-specific breadcrumbs for security events # Scrub sensitive data out of breadcrumbs.
config.sentry.before_breadcrumb = lambda do |breadcrumb, hint| config.before_breadcrumb = lambda do |breadcrumb, _hint|
# Filter out sensitive breadcrumb data if breadcrumb.data.is_a?(Hash)
if breadcrumb[:data] breadcrumb.data.reject! do |key, value|
breadcrumb[:data].reject! { |key, value| key.to_s.match?(/password|secret|token|key|authorization/i) || value.to_s.match?(/password|secret/i)
key.to_s.match?(/password|secret|token|key|authorization/i) ||
value.to_s.match?(/password|secret/i)
}
end end
# Mark CSP-related events
if breadcrumb[:message]&.include?("CSP Violation") ||
breadcrumb[:category]&.include?("csp")
breadcrumb[:data] ||= {}
breadcrumb[:data][:security_event] = true
breadcrumb[:data][:csp_violation] = true
end end
breadcrumb breadcrumb
end end
# Only send errors in production unless explicitly enabled
config.sentry.enabled = Rails.env.production? || ENV["SENTRY_ENABLED_IN_DEVELOPMENT"] == "true"
end end

View File

@@ -1,5 +1,5 @@
# frozen_string_literal: true # frozen_string_literal: true
module Clinch module Clinch
VERSION = "0.9.0" VERSION = "0.16.3"
end end

View File

@@ -34,7 +34,9 @@ port ENV.fetch("PORT", 3000)
# Allow puma to be restarted by `bin/rails restart` command. # Allow puma to be restarted by `bin/rails restart` command.
plugin :tmp_restart plugin :tmp_restart
# Solid Queue plugin removed - now using async processor # Run the Solid Queue supervisor inside Puma. Clinch ships as a single
# container, so the web process is always the worker too.
plugin :solid_queue if ENV.fetch("RAILS_ENV", "development") == "production"
# Specify the PID file. Defaults to tmp/pids/server.pid in development. # Specify the PID file. Defaults to tmp/pids/server.pid in development.
# In other environments, only set the PID file if requested. # In other environments, only set the PID file if requested.

View File

@@ -95,6 +95,7 @@ Rails.application.routes.draw do
end end
end end
resources :groups resources :groups
get "access", to: "access_checks#new"
end end
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb) # Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)

View File

@@ -0,0 +1,8 @@
class AddAutoAssignAndAdminToGroups < ActiveRecord::Migration[8.1]
def change
add_column :groups, :auto_assign, :boolean, default: false, null: false
add_column :groups, :admin, :boolean, default: false, null: false
add_index :groups, :auto_assign, where: "auto_assign"
add_index :groups, :admin, where: "admin"
end
end

View File

@@ -0,0 +1,44 @@
class SeedDefaultGroupsAndMigrateAdmins < ActiveRecord::Migration[8.1]
# Data migration: seed "everyone" (auto_assign) and "admins" (admin) groups,
# backfill memberships from existing data, attach "everyone" to previously
# group-less applications. Idempotent.
#
# Must run before RemoveAdminFromUsers, because it reads the legacy
# users.admin column.
def up
unless Group.exists?(auto_assign: true)
everyone = Group.create!(
name: "everyone",
description: "Auto-assigned to new users. Safe to rename or remove.",
auto_assign: true
)
User.where(status: 0).find_each do |u|
UserGroup.find_or_create_by!(user_id: u.id, group_id: everyone.id)
end
Application.left_joins(:application_groups)
.where(application_groups: {id: nil})
.find_each do |app|
ApplicationGroup.find_or_create_by!(application_id: app.id, group_id: everyone.id)
end
end
unless Group.exists?(admin: true)
admins = Group.create!(
name: "admins",
description: "Members can access the admin panel.",
admin: true
)
User.where(admin: true).find_each do |u|
UserGroup.find_or_create_by!(user_id: u.id, group_id: admins.id)
end
end
end
def down
Group.where(name: ["everyone", "admins"]).destroy_all
end
end

View File

@@ -0,0 +1,5 @@
class RemoveAdminFromUsers < ActiveRecord::Migration[8.1]
def change
remove_column :users, :admin, :boolean, default: false, null: false
end
end

View File

@@ -0,0 +1,7 @@
class AddLastOtpAtToUsers < ActiveRecord::Migration[8.1]
def change
# Unix timestamp of the most recently accepted TOTP timestep, used to reject
# replay of a code within its drift window (passed to ROTP's `after:`).
add_column :users, :last_otp_at, :integer
end
end

8
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do ActiveRecord::Schema[8.1].define(version: 2026_06_11_000001) do
create_table "active_storage_attachments", force: :cascade do |t| create_table "active_storage_attachments", force: :cascade do |t|
t.bigint "blob_id", null: false t.bigint "blob_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
@@ -106,11 +106,15 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
end end
create_table "groups", force: :cascade do |t| create_table "groups", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.boolean "auto_assign", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false t.json "custom_claims", default: {}, null: false
t.text "description" t.text "description"
t.string "name", null: false t.string "name", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["admin"], name: "index_groups_on_admin", where: "admin"
t.index ["auto_assign"], name: "index_groups_on_auto_assign", where: "auto_assign"
t.index ["name"], name: "index_groups_on_name", unique: true t.index ["name"], name: "index_groups_on_name", unique: true
end end
@@ -225,11 +229,11 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_20_080000) do
end end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.boolean "admin", default: false, null: false
t.json "backup_codes" t.json "backup_codes"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.json "custom_claims", default: {}, null: false t.json "custom_claims", default: {}, null: false
t.string "email_address", null: false t.string "email_address", null: false
t.integer "last_otp_at"
t.datetime "last_sign_in_at" t.datetime "last_sign_in_at"
t.string "name" t.string "name"
t.string "password_digest", null: false t.string "password_digest", null: false

View File

@@ -0,0 +1,47 @@
require "test_helper"
module Admin
class AccessChecksControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@kavita = applications(:kavita_app)
end
test "new renders the form with users and applications" do
get admin_access_path
assert_response :success
assert_match @kavita.name, response.body
assert_match "alice@example.com", response.body
end
test "returns 'can access' with via group when user is in an allowed group" do
get admin_access_path, params: {
user_id: users(:alice).id,
application_id: @kavita.id
}
assert_response :success
assert_match "can access", response.body
assert_match "Administrators", response.body # alice is in admin_group; kavita has admin_group
end
test "returns 'cannot access' with reason when user shares no group with the app" do
lonely = User.create!(email_address: "lonely@example.com", password: "password123", skip_auto_assign: true)
get admin_access_path, params: {
user_id: lonely.id,
application_id: @kavita.id
}
assert_response :success
assert_match "cannot access", response.body
assert_match "shares no group", response.body
end
test "renders form unchanged when ids are missing" do
get admin_access_path, params: {user_id: "", application_id: ""}
assert_response :success
# No result panel should render. The panel-only phrases:
refute_match "Granted via", response.body
refute_match "Reason:", response.body
end
end
end

View File

@@ -0,0 +1,70 @@
require "test_helper"
module Admin
class GroupsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two)
sign_in_as(@admin)
@group = groups(:one)
end
test "update assigns applications from application_ids" do
app_a = applications(:kavita_app)
app_b = applications(:another_app)
patch admin_group_path(@group), params: {
group: {
name: @group.name,
application_ids: [app_a.id, app_b.id]
}
}
assert_redirected_to admin_group_path(@group)
assert_equal [app_a, app_b].sort, @group.reload.applications.sort
end
test "update with no application_ids clears assigned applications" do
@group.applications = [applications(:kavita_app)]
patch admin_group_path(@group), params: {
group: {name: @group.name}
}
assert_redirected_to admin_group_path(@group)
assert_empty @group.reload.applications
end
test "create assigns applications from application_ids" do
app = applications(:audiobookshelf_app)
assert_difference -> { Group.count }, 1 do
post admin_groups_path, params: {
group: {
name: "New Group",
application_ids: [app.id]
}
}
end
assert_equal [app], Group.find_by(name: "new group").applications
end
test "can mark a group as auto_assign and admin" do
patch admin_group_path(@group), params: {
group: {name: @group.name, auto_assign: "1", admin: "1"}
}
@group.reload
assert @group.auto_assign?
assert @group.admin?
end
test "cannot delete the last admin group" do
admins = groups(:admin_group)
delete admin_group_path(admins)
# Destroy was aborted by the before_destroy guard
assert Group.exists?(admins.id), "admin group should not have been deleted"
end
end
end

View File

@@ -0,0 +1,69 @@
require "test_helper"
module Admin
class UsersControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = users(:two) # in admin_group via fixtures
sign_in_as(@admin)
end
test "show loads accessible applications via the user's groups" do
kavita = applications(:kavita_app)
# alice is in admin_group via fixtures; kavita is attached to admin_group via app_groups
get admin_user_path(users(:alice))
assert_response :success
assert_match kavita.name, response.body
# The "via" badge mentions the granting group name
assert_match groups(:admin_group).name, response.body
end
test "update assigns group memberships from group_ids" do
target = users(:bob)
editors = groups(:editor_group)
one = groups(:one)
patch admin_user_path(target), params: {
user: {email_address: target.email_address, group_ids: [editors.id, one.id]}
}
assert_redirected_to admin_users_path
assert_equal [editors, one].sort, target.reload.groups.sort
end
test "cannot remove yourself from the last admin group" do
# @admin (users:two) is in admin_group. Removing them via the user form
# while no other admin exists is blocked.
sole_admin = users(:two)
# Strip alice (the other admin) so @admin is the last one.
users(:alice).groups.delete(groups(:admin_group))
patch admin_user_path(sole_admin), params: {
user: {email_address: sole_admin.email_address, group_ids: []}
}
assert_response :unprocessable_entity
assert sole_admin.reload.admin?, "should still be admin"
end
test "create with auto_assign=0 skips the auto-assign callback" do
post admin_users_path, params: {
user: {email_address: "restricted@example.com"},
auto_assign: "0"
}
assert_response :redirect
created = User.find_by(email_address: "restricted@example.com")
assert_not_includes created.groups, groups(:everyone)
end
test "create without auto_assign param auto-joins everyone" do
post admin_users_path, params: {
user: {email_address: "newbie@example.com"}
}
assert_response :redirect
created = User.find_by(email_address: "newbie@example.com")
assert_includes created.groups, groups(:everyone)
end
end
end

View File

@@ -11,6 +11,7 @@ module Api
domain_pattern: "webdav.example.com", domain_pattern: "webdav.example.com",
active: true active: true
) )
grant_everyone_access(@app)
@api_key = @user.api_keys.create!(name: "Test Key", application: @app) @api_key = @user.api_keys.create!(name: "Test Key", application: @app)
@token = @api_key.plaintext_token @token = @api_key.plaintext_token
end end
@@ -112,6 +113,42 @@ module Api
assert_equal "Application is inactive", json["error"] assert_equal "Application is inactive", json["error"]
end end
test "bearer token returns 401 once user is removed from allowed groups" do
# App restricted to a specific group; user is a member when the key is made.
group = Group.create!(name: "webdav-users")
restricted_app = Application.create!(
name: "Restricted WebDAV",
slug: "restricted-webdav",
app_type: "forward_auth",
domain_pattern: "restricted.example.com",
active: true
)
restricted_app.allowed_groups << group
@user.groups << group
key = @user.api_keys.create!(name: "Restricted Key", application: restricted_app)
token = key.plaintext_token
# Sanity: access works while membership stands.
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :ok
# Revoke group membership; the existing key must stop working.
@user.groups.destroy(group)
get "/api/verify", headers: {
"Authorization" => "Bearer #{token}",
"X-Forwarded-Host" => "restricted.example.com"
}
assert_response :unauthorized
json = JSON.parse(response.body)
assert_equal "Access denied: insufficient group membership", json["error"]
end
test "no bearer token falls through to cookie auth" do test "no bearer token falls through to cookie auth" do
# No auth header, no session -> should redirect (cookie flow) # No auth header, no session -> should redirect (cookie flow)
get "/api/verify", headers: { get "/api/verify", headers: {

View File

@@ -7,8 +7,8 @@ module Api
@admin_user = users(:alice) @admin_user = users(:alice)
@inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled) @inactive_user = User.create!(email_address: "inactive@example.com", password: "password", status: :disabled)
@group = groups(:admin_group) @group = groups(:admin_group)
@rule = Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true) @rule = grant_everyone_access(Application.create!(name: "Test App", slug: "test-app", app_type: "forward_auth", domain_pattern: "test.example.com", active: true))
@inactive_rule = Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false) @inactive_rule = grant_everyone_access(Application.create!(name: "Inactive App", slug: "inactive-app", app_type: "forward_auth", domain_pattern: "inactive.example.com", active: false))
end end
# Authentication Tests # Authentication Tests
@@ -65,7 +65,7 @@ module Api
end end
test "should return 403 when rule exists but user not in allowed groups" do test "should return 403 when rule exists but user not in allowed groups" do
@rule.allowed_groups << @group @rule.allowed_groups = [@group]
sign_in_as(@user) # User not in group sign_in_as(@user) # User not in group
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"}
@@ -75,7 +75,7 @@ module Api
end end
test "should return 200 when user is in allowed groups" do test "should return 200 when user is in allowed groups" do
@rule.allowed_groups << @group @rule.allowed_groups = [@group]
@user.groups << @group @user.groups << @group
sign_in_as(@user) sign_in_as(@user)
@@ -86,7 +86,7 @@ module Api
# Domain Pattern Tests # Domain Pattern Tests
test "should match wildcard domains correctly" do test "should match wildcard domains correctly" do
Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true) grant_everyone_access Application.create!(name: "Wildcard App", slug: "wildcard-app", app_type: "forward_auth", domain_pattern: "*.example.com", active: true)
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "app.example.com"}
@@ -101,7 +101,7 @@ module Api
end end
test "should match exact domains correctly" do test "should match exact domains correctly" do
Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true) grant_everyone_access Application.create!(name: "Exact App", slug: "exact-app", app_type: "forward_auth", domain_pattern: "api.example.com", active: true)
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "api.example.com"}
@@ -126,7 +126,7 @@ module Api
end end
test "should return custom headers when configured" do test "should return custom headers when configured" do
Application.create!( grant_everyone_access Application.create!(
name: "Custom App", name: "Custom App",
slug: "custom-app", slug: "custom-app",
app_type: "forward_auth", app_type: "forward_auth",
@@ -151,7 +151,7 @@ module Api
end end
test "should return no headers when all headers disabled" do test "should return no headers when all headers disabled" do
Application.create!( grant_everyone_access Application.create!(
name: "No Headers App", name: "No Headers App",
slug: "no-headers-app", slug: "no-headers-app",
app_type: "forward_auth", app_type: "forward_auth",
@@ -182,11 +182,19 @@ module Api
assert_includes groups_header, "Editors" assert_includes groups_header, "Editors"
end end
test "should not include groups header when user has no groups" do test "should not include groups header when user has no groups beyond the granting one and groups header empty" do
@user.groups.clear # Remove fixture groups # Under default-deny the user must be in at least one group to access the app.
# This rewritten test verifies that when an app's headers_config disables the
# groups header, no x-remote-groups is sent regardless of memberships.
grant_everyone_access Application.create!(
name: "Headers Hidden", slug: "headers-hidden", app_type: "forward_auth",
domain_pattern: "hidden.example.com",
active: true,
headers_config: {groups: ""}
)
sign_in_as(@user) sign_in_as(@user)
get "/api/verify", headers: {"X-Forwarded-Host" => "test.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "hidden.example.com"}
assert_response 200 assert_response 200
assert_nil response.headers["x-remote-groups"] assert_nil response.headers["x-remote-groups"]
@@ -234,6 +242,20 @@ module Api
assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"] assert_equal "No authentication rule configured for this domain", response.headers["x-auth-reason"]
end end
# Fail closed when no host can be determined: emitting identity headers without
# an application would bypass all per-domain group access control.
test "should fail closed and emit no identity headers when host is absent" do
sign_in_as(@user)
# Blank both host sources so forwarded_host is not present.
get "/api/verify", headers: {"X-Forwarded-Host" => "", "Host" => ""}
assert_response 403
assert_equal "No host header present", response.headers["x-auth-reason"]
assert_nil response.headers["X-Remote-User"]
assert_nil response.headers["X-Remote-Groups"]
end
# Security Tests # Security Tests
test "should handle very long domain names" do test "should handle very long domain names" do
long_domain = "a" * 250 + ".example.com" long_domain = "a" * 250 + ".example.com"
@@ -537,7 +559,7 @@ module Api
end end
test "should track failed attempts and eventually rate limit" do test "should track failed attempts and eventually rate limit" do
cache = Rails.application.config.forward_auth_cache Rails.application.config.forward_auth_cache
# Make 50 failed requests (no session = unauthorized) # Make 50 failed requests (no session = unauthorized)
50.times do 50.times do
@@ -705,7 +727,7 @@ module Api
class FaTokenHostBindingTest < ActionDispatch::IntegrationTest class FaTokenHostBindingTest < ActionDispatch::IntegrationTest
setup do setup do
@user = users(:bob) @user = users(:bob)
Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) grant_everyone_access Application.create!(name: "Bound App", slug: "bound-app", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
@original_cache = Rails.cache @original_cache = Rails.cache
Rails.cache = ActiveSupport::Cache::MemoryStore.new Rails.cache = ActiveSupport::Cache::MemoryStore.new

View File

@@ -17,6 +17,7 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
@application.generate_new_client_secret! @application.generate_new_client_secret!
@plain_client_secret = @application.client_secret @plain_client_secret = @application.client_secret
@application.save! @application.save!
grant_everyone_access(@application)
end end
def teardown def teardown
@@ -33,6 +34,25 @@ class OidcAuthorizationCodeSecurityTest < ActionDispatch::IntegrationTest
# CRITICAL SECURITY TESTS # CRITICAL SECURITY TESTS
# ==================== # ====================
test "consent endpoint rejects cross-site POST without a CSRF token" do
sign_in_as(@user)
# Forgery protection is disabled in the test env by default; enable it so the
# before_action actually runs, mirroring production behaviour.
original = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
begin
# No authenticity_token param: a forged cross-site submission. Because
# :consent is NOT in the verify_authenticity_token skip list, this must be
# rejected before the action can grant any OAuth scopes.
post "/oauth/authorize/consent", params: {approve: "true"}
assert_response :unprocessable_entity
ensure
ActionController::Base.allow_forgery_protection = original
end
end
test "prevents authorization code reuse - sequential attempts" do test "prevents authorization code reuse - sequential attempts" do
# Create consent # Create consent
OidcUserConsent.create!( OidcUserConsent.create!(

View File

@@ -10,6 +10,7 @@ class OidcPkceControllerTest < ActionDispatch::IntegrationTest
redirect_uris: ["http://localhost:4000/callback"].to_json, redirect_uris: ["http://localhost:4000/callback"].to_json,
active: true active: true
) )
grant_everyone_access(@application)
# Sign in the user using the test helper # Sign in the user using the test helper
sign_in_as(@user) sign_in_as(@user)

View File

@@ -213,6 +213,50 @@ class OidcRefreshTokenControllerTest < ActionDispatch::IntegrationTest
assert_equal family_id, new_refresh_token.token_family_id assert_equal family_id, new_refresh_token.token_family_id
end end
test "reusing a revoked refresh token revokes every access token in the family" do
access_token = OidcAccessToken.create!(
application: @application,
user: @user,
scope: "openid profile email"
)
refresh_token = OidcRefreshToken.create!(
application: @application,
user: @user,
oidc_access_token: access_token,
scope: "openid profile email"
)
family_id = refresh_token.token_family_id
old_plaintext = refresh_token.token
# Rotate once: the old refresh token is revoked; a new access + refresh token
# are issued into the same family.
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: old_plaintext,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :success
new_refresh = OidcRefreshToken.in_family(family_id).where.not(id: refresh_token.id).first
new_access_token = new_refresh.oidc_access_token
refute new_access_token.reload.revoked?, "rotated-in access token should start active"
# Reuse the OLD (now revoked) refresh token -> rotation-attack detection.
post "/oauth/token", params: {
grant_type: "refresh_token",
refresh_token: old_plaintext,
client_id: @application.client_id,
client_secret: @client_secret
}
assert_response :bad_request
# Both the original and the rotated-in access token must now be revoked, so a
# stolen access token from anywhere in the chain stops working at /userinfo.
assert access_token.reload.revoked?, "original access token should be revoked"
assert new_access_token.reload.revoked?, "rotated-in access token should be revoked"
end
test "userinfo endpoint works with hashed access token" do test "userinfo endpoint works with hashed access token" do
access_token = OidcAccessToken.create!( access_token = OidcAccessToken.create!(
application: @application, application: @application,

View File

@@ -30,4 +30,22 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
assert_redirected_to signin_path assert_redirected_to signin_path
assert_empty cookies[:session_id] assert_empty cookies[:session_id]
end end
test "session cookie has no Expires attribute when remember_me is off" do
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "0"}
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
assert set_cookie, "session_id cookie should be set"
refute_match(/expires=/i, set_cookie,
"without Remember me, the session cookie must be a browser-session cookie (no Expires)")
end
test "session cookie has long-lived Expires attribute when remember_me is on" do
post session_path, params: {email_address: @user.email_address, password: "password", remember_me: "1"}
set_cookie = Array(response.headers["Set-Cookie"]).find { |c| c.start_with?("session_id=") }
assert set_cookie, "session_id cookie should be set"
assert_match(/expires=/i, set_cookie,
"with Remember me, the cookie should have an Expires attribute")
end
end end

View File

@@ -19,16 +19,21 @@ class TotpSecurityTest < ActionDispatch::IntegrationTest
# First use of the code should succeed # First use of the code should succeed
post totp_verification_path, params: {code: valid_code} post totp_verification_path, params: {code: valid_code}
assert_response :redirect
assert_redirected_to root_path assert_redirected_to root_path
# Sign out # Sign out
delete session_path delete session_path
assert_response :redirect assert_response :redirect
# Note: In the current implementation, TOTP codes CAN be reused within the 60-second time window # Replay the SAME code in a fresh sign-in attempt. Because verify_totp records
# This is standard TOTP behavior. For enhanced security, you could implement used code tracking. # the accepted timestep (ROTP `after:`), the code is now rejected even though
# This test documents the current behavior - codes work within their time window # it is still within its drift window — so we stay on the verification step.
post signin_path, params: {email_address: "totp_replay_test@example.com", password: "password123"}
assert_redirected_to totp_verification_path
post totp_verification_path, params: {code: valid_code}
assert_redirected_to totp_verification_path
assert_equal "Invalid verification code. Please try again.", flash[:alert]
user.sessions.delete_all user.sessions.delete_all
user.destroy user.destroy

View File

@@ -11,7 +11,13 @@ two:
admin_group: admin_group:
name: Administrators name: Administrators
description: System administrators with full access description: System administrators with full access
admin: true
editor_group: editor_group:
name: Editors name: Editors
description: Content editors with limited access description: Content editors with limited access
everyone:
name: everyone
description: Auto-assigned to new users.
auto_assign: true

View File

@@ -1,9 +1,28 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# All users belong to "everyone" so existing tests that create group-less apps
# can be made compatible by attaching that group.
one_everyone:
user: one
group: everyone
two_everyone:
user: two
group: everyone
alice_everyone:
user: alice
group: everyone
bob_everyone:
user: bob
group: everyone
alice_admin_group: alice_admin_group:
user: alice user: alice
group: admin_group group: admin_group
two_admin_group:
user: two
group: admin_group
bob_editor_group: bob_editor_group:
user: bob user: bob
group: editor_group group: editor_group

View File

@@ -3,23 +3,19 @@
one: one:
email_address: one@example.com email_address: one@example.com
password_digest: <%= password_digest %> password_digest: <%= password_digest %>
admin: false
status: 0 # active status: 0 # active
two: two:
email_address: two@example.com email_address: two@example.com
password_digest: <%= password_digest %> password_digest: <%= password_digest %>
admin: true
status: 0 # active status: 0 # active
alice: alice:
email_address: alice@example.com email_address: alice@example.com
password_digest: <%= password_digest %> password_digest: <%= password_digest %>
admin: true
status: 0 # active status: 0 # active
bob: bob:
email_address: bob@example.com email_address: bob@example.com
password_digest: <%= password_digest %> password_digest: <%= password_digest %>
admin: false
status: 0 # active status: 0 # active

View File

@@ -0,0 +1,53 @@
require "test_helper"
class ApplicationHelperTest < ActionView::TestCase
test "monogram_initials picks capitals from camelCase" do
assert_equal "SL", monogram_initials("ShelfLife")
assert_equal "KR", monogram_initials("KavitaReader")
assert_equal "AB", monogram_initials("AudioBookShelf") # first two of 4 capitals
end
test "monogram_initials falls back to first two letters when fewer than two capitals" do
assert_equal "AU", monogram_initials("Audiobookshelf")
assert_equal "ME", monogram_initials("metube")
assert_equal "GI", monogram_initials("git")
end
test "monogram_initials handles single-character and unusual names" do
assert_equal "X", monogram_initials("X")
assert_equal "X1", monogram_initials("X1")
assert_equal "?", monogram_initials("")
assert_equal "?", monogram_initials(nil)
end
test "monogram_color is deterministic for the same name" do
a = monogram_color("ShelfLife")
b = monogram_color("ShelfLife")
assert_equal a, b
assert_match(/\A#[0-9a-f]{6}\z/i, a)
end
test "monogram_color differs for different names" do
# not a guarantee for all pairs, but should hold for at least one pair
assert_not_equal monogram_color("Kavita"), monogram_color("Navidrome")
end
test "app_icon_picture renders both icons with Tailwind dark: toggles when icon_dark is attached" do
app = applications(:kavita_app)
app.icon.attach(io: StringIO.new("light"), filename: "light.png", content_type: "image/png")
app.icon_dark.attach(io: StringIO.new("dark"), filename: "dark.png", content_type: "image/png")
html = app_icon_picture(app, class: "h-10 w-10 rounded-lg")
assert_match(/dark:hidden/, html)
assert_match(/hidden dark:block/, html)
end
test "app_icon_picture renders one img when no icon_dark is attached" do
app = applications(:kavita_app)
app.icon.attach(io: StringIO.new("light"), filename: "light.png", content_type: "image/png")
html = app_icon_picture(app, class: "h-10 w-10")
refute_match(/dark:hidden/, html)
refute_match(/hidden dark:block/, html)
end
end

View File

@@ -0,0 +1,76 @@
require "test_helper"
class CspTest < ActionDispatch::IntegrationTest
# In the test env content_security_policy_report_only is false, so the enforcing
# Content-Security-Policy header is emitted.
test "signin page sends a nonce-based CSP with no unsafe-inline" do
get signin_path
assert_response :success
csp = response.headers["Content-Security-Policy"]
assert csp.present?, "expected a Content-Security-Policy header"
script_src = directive(csp, "script-src")
style_src = directive(csp, "style-src")
assert_includes script_src, "'nonce-", "script-src must carry a nonce"
assert_includes style_src, "'nonce-", "style-src must carry a nonce"
refute_includes script_src, "'unsafe-inline'", "script-src must not allow unsafe-inline"
refute_includes style_src, "'unsafe-inline'", "style-src must not allow unsafe-inline"
end
test "the inline theme script carries the script-src nonce" do
get signin_path
assert_response :success
header_nonce = response.headers["Content-Security-Policy"][/script-src[^;]*'nonce-([^']+)'/, 1]
assert header_nonce.present?, "expected a nonce in the CSP header"
# The hand-written dark-mode <script> in the layout must use the same nonce,
# otherwise it would be blocked under the enforcing policy.
assert_match(/<script nonce="#{Regexp.escape(header_nonce)}">/, response.body,
"inline theme script must carry the matching CSP nonce")
end
test "signin page adds the OAuth redirect_uri host to form-action without 500ing" do
# A user must exist, otherwise /signin redirects to signup before the CSP
# branch runs.
User.create!(email_address: "csp_oauth@example.com", password: "password123")
app = Application.create!(
name: "CSP OAuth App",
slug: "csp-oauth-app",
app_type: "oidc",
redirect_uris: ["https://app.example.com/callback"].to_json,
active: true,
require_pkce: false
)
# An unauthenticated authorize request stores the full /oauth/authorize URL
# in the session and redirects to signin (oidc_controller.rb:202).
get "/oauth/authorize", params: {
client_id: app.client_id,
redirect_uri: app.parsed_redirect_uris.first,
response_type: "code",
scope: "openid"
}
assert_redirected_to signin_path
# Following to signin must reach allow_oauth_redirect_in_csp without raising.
# Regression: csp.form_action is a destructive getter, so reading it twice
# returned nil and `nil << host` raised NoMethodError -> 500.
follow_redirect!
assert_response :success
form_action = directive(response.headers["Content-Security-Policy"], "form-action")
assert_includes form_action, "'self'", "form-action must keep its default 'self'"
assert_includes form_action, "https://app.example.com",
"form-action must include the OAuth client's redirect_uri host"
end
private
def directive(csp, name)
csp.split(";").map(&:strip).find { |d| d.start_with?("#{name} ") } || ""
end
end

View File

@@ -12,7 +12,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# End-to-End Authentication Flow Tests # End-to-End Authentication Flow Tests
test "complete forward auth flow with default headers" do test "complete forward auth flow with default headers" do
# Create an application with default headers # Create an application with default headers
Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) grant_everyone_access Application.create!(name: "App", slug: "app-system-test", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
# Step 1: Unauthenticated request to protected resource # Step 1: Unauthenticated request to protected resource
get "/api/verify", headers: { get "/api/verify", headers: {
@@ -48,14 +48,14 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
test "multiple domain access with single session" do test "multiple domain access with single session" do
# Create applications for different domains # Create applications for different domains
Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true) grant_everyone_access Application.create!(name: "App Domain", slug: "app-domain", app_type: "forward_auth", domain_pattern: "app.example.com", active: true)
Application.create!( grant_everyone_access Application.create!(
name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth", name: "Grafana", slug: "grafana-system-test", app_type: "forward_auth",
domain_pattern: "grafana.example.com", domain_pattern: "grafana.example.com",
active: true, active: true,
headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"} headers_config: {user: "X-WEBAUTH-USER", email: "X-WEBAUTH-EMAIL"}
) )
Application.create!( grant_everyone_access Application.create!(
name: "Metube", slug: "metube-system-test", app_type: "forward_auth", name: "Metube", slug: "metube-system-test", app_type: "forward_auth",
domain_pattern: "metube.example.com", domain_pattern: "metube.example.com",
active: true, active: true,
@@ -106,7 +106,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Should have access (in allowed group) # Should have access (in allowed group)
get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"} get "/api/verify", headers: {"X-Forwarded-Host" => "admin.example.com"}
assert_response 200 assert_response 200
assert_equal @group.name, response.headers["x-remote-groups"] assert_includes response.headers["x-remote-groups"], @group.name
# Add user to second group # Add user to second group
@user.groups << @group2 @user.groups << @group2
@@ -126,31 +126,27 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
assert_response 403 assert_response 403
end end
test "bypass mode when no groups assigned to rule" do test "default deny when no groups assigned to rule" do
# Create bypass application (no groups) # An app with no allowed_groups now denies all users (was: bypass mode).
Application.create!( Application.create!(
name: "Public", slug: "public-system-test", app_type: "forward_auth", name: "No Groups", slug: "nogroups-system-test", app_type: "forward_auth",
domain_pattern: "public.example.com", domain_pattern: "nogroups.example.com",
active: true active: true
) )
# Create user with no groups
@user.groups.clear @user.groups.clear
# Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}
assert_response 303 assert_response 303
# Should have access (bypass mode) get "/api/verify", headers: {"X-Forwarded-Host" => "nogroups.example.com"}
get "/api/verify", headers: {"X-Forwarded-Host" => "public.example.com"} assert_response 403
assert_response 200
assert_equal @user.email_address, response.headers["x-remote-user"]
end end
# Security System Tests # Security System Tests
test "session expiration and cleanup" do test "session expiration and cleanup" do
# Create test application # Create test application
Application.create!( grant_everyone_access Application.create!(
name: "Test", slug: "test-system-test", app_type: "forward_auth", name: "Test", slug: "test-system-test", app_type: "forward_auth",
domain_pattern: "test.example.com", domain_pattern: "test.example.com",
active: true active: true
@@ -179,7 +175,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
test "concurrent access with rate limiting considerations" do test "concurrent access with rate limiting considerations" do
# Create wildcard application # Create wildcard application
Application.create!( grant_everyone_access Application.create!(
name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth", name: "Wildcard", slug: "wildcard-test", app_type: "forward_auth",
domain_pattern: "*.example.com", domain_pattern: "*.example.com",
active: true active: true
@@ -246,7 +242,11 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
active: true, active: true,
headers_config: app[:headers_config] headers_config: app[:headers_config]
) )
if app[:groups].any?
app[:groups].each { |group| rule.allowed_groups << group } app[:groups].each { |group| rule.allowed_groups << group }
else
grant_everyone_access(rule)
end
rule rule
end end
@@ -286,7 +286,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
] ]
patterns.each_with_index do |pattern_config, idx| patterns.each_with_index do |pattern_config, idx|
Application.create!( grant_everyone_access Application.create!(
name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth", name: "Pattern Test #{idx}", slug: "pattern-test-#{idx}", app_type: "forward_auth",
domain_pattern: pattern_config[:pattern], domain_pattern: pattern_config[:pattern],
active: true active: true
@@ -310,7 +310,7 @@ class ForwardAuthAdvancedTest < ActionDispatch::IntegrationTest
# Performance System Tests # Performance System Tests
test "system performance under load" do test "system performance under load" do
# Create test application with wildcard pattern # Create test application with wildcard pattern
Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true) grant_everyone_access Application.create!(name: "Load Test", slug: "loadtest", app_type: "forward_auth", domain_pattern: "*.loadtest.example.com", active: true)
# Sign in # Sign in
post "/signin", params: {email_address: @user.email_address, password: "password"} post "/signin", params: {email_address: @user.email_address, password: "password"}

Some files were not shown because too many files have changed in this diff Show More