feat: add app-level auth with RBAC and per-user tenancy
CI / mix precommit (push) Failing after 22s
CI / Build and push to Harbor (push) Has been skipped
CI / All checks passed (push) Failing after 2s

Add password-only phx.gen.auth (argon2) with closed, admin-managed
registration: admins create accounts with a temporary password and users
must change it on first login. Strip email/magic-link flows and remove the
unused Swoosh mailer.

Add two roles (admin/user) enforced via on_mount guards, and per-user data
tenancy: scans, assets, and findings carry a user_id; vulnerabilities stay
global with visibility derived through findings via a correlated EXISTS.
Cross-tenant detail URLs 404; admins see all rows.

Merge the account password page into /settings. Add an admin user-management
dashboard, a seeded bootstrap admin (fixed dev creds via seeds.exs, random
password via the new seed-admin release task), and cross-tenant isolation
tests. Bundle the root layout noindex/theme-color SEO change.
This commit is contained in:
Christopher Fahlin
2026-05-30 20:30:46 -07:00
parent 3c7f28cc67
commit 6aa878a8ab
57 changed files with 3460 additions and 257 deletions
+61
View File
@@ -44,6 +44,67 @@ custom classes must fully style the input
- Focus on **delightful details** like hover effects, loading states, and smooth page transitions
<!-- phoenix-gen-auth-start -->
## Authentication
- **Always** handle authentication flow at the router level with proper redirects
- **Always** be mindful of where to place routes. `phx.gen.auth` creates multiple router plugs and `live_session` scopes:
- A plug `:fetch_current_scope_for_user` that is included in the default browser pipeline
- A plug `:require_authenticated_user` that redirects to the log in page when the user is not authenticated
- A `live_session :current_user` scope - for routes that need the current user but don't require authentication, similar to `:fetch_current_scope_for_user`
- A `live_session :require_authenticated_user` scope - for routes that require authentication, similar to the plug with the same name
- In both cases, a `@current_scope` is assigned to the Plug connection and LiveView socket
- A plug `redirect_if_user_is_authenticated` that redirects to a default path in case the user is authenticated - useful for a registration page that should only be shown to unauthenticated users
- **Always let the user know in which router scopes, `live_session`, and pipeline you are placing the route, AND SAY WHY**
- `phx.gen.auth` assigns the `current_scope` assign - it **does not assign a `current_user` assign**
- Always pass the assign `current_scope` to context modules as first argument. When performing queries, use `current_scope.user` to filter the query results
- To derive/access `current_user` in templates, **always use the `@current_scope.user`**, never use **`@current_user`** in templates or LiveViews
- **Never** duplicate `live_session` names. A `live_session :current_user` can only be defined __once__ in the router, so all routes for the `live_session :current_user` must be grouped in a single block
- Anytime you hit `current_scope` errors or the logged in session isn't displaying the right content, **always double check the router and ensure you are using the correct plug and `live_session` as described below**
### Routes that require authentication
LiveViews that require login should **always be placed inside the __existing__ `live_session :require_authenticated_user` block**:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{BulwarkWeb.UserAuth, :require_authenticated}] do
# phx.gen.auth generated routes
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
# our own routes that require logged in user
live "/", MyLiveThatRequiresAuth, :index
end
end
Controller routes must be placed in a scope that sets the `:require_authenticated_user` plug:
scope "/", AppWeb do
pipe_through [:browser, :require_authenticated_user]
get "/", MyControllerThatRequiresAuth, :index
end
### Routes that work with or without authentication
LiveViews that can work with or without authentication, **always use the __existing__ `:current_user` scope**, ie:
scope "/", MyAppWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{BulwarkWeb.UserAuth, :mount_current_scope}] do
# our own routes that work with or without authentication
live "/", PublicLive
end
end
Controllers automatically have the `current_scope` available if they use the `:browser` pipeline.
<!-- phoenix-gen-auth-end -->
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
+13 -9
View File
@@ -7,6 +7,19 @@
# General application configuration
import Config
config :bulwark, :scopes,
user: [
default: true,
module: Bulwark.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Bulwark.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
config :bulwark,
ecto_repos: [Bulwark.Repo],
generators: [timestamp_type: :utc_datetime]
@@ -22,15 +35,6 @@ config :bulwark, BulwarkWeb.Endpoint,
pubsub_server: Bulwark.PubSub,
live_view: [signing_salt: "4uP78Fmk"]
# Configures the mailer
#
# By default it uses the "Local" adapter which stores the emails
# locally. You can see the emails in your browser, at "/dev/mailbox".
#
# For production it's recommended to configure a different adapter
# at the `config/runtime.exs`.
config :bulwark, Bulwark.Mailer, adapter: Swoosh.Adapters.Local
# Configure esbuild (the version is required)
config :esbuild,
version: "0.25.4",
+1 -4
View File
@@ -85,7 +85,7 @@ config :bulwark, BulwarkWeb.Endpoint,
]
]
# Enable dev routes for dashboard and mailbox
# Enable dev routes for the LiveDashboard
config :bulwark, dev_routes: true
# Do not include metadata nor timestamps in development logs
@@ -105,6 +105,3 @@ config :phoenix_live_view,
debug_attributes: true,
# Enable helpful, but potentially expensive runtime checks
enable_expensive_runtime_checks: true
# Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false
-6
View File
@@ -7,12 +7,6 @@ import Config
# before starting your production server.
config :bulwark, BulwarkWeb.Endpoint, cache_static_manifest: "priv/static/cache_manifest.json"
# Configures Swoosh API Client
config :swoosh, api_client: Swoosh.ApiClient.Req
# Disable Swoosh Local Memory Storage
config :swoosh, local: false
# Do not print debug messages in production
config :logger, level: :info
-18
View File
@@ -100,22 +100,4 @@ if config_env() == :prod do
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
# ## Configuring the mailer
#
# In production you need to configure the mailer to use a different adapter.
# Here is an example configuration for Mailgun:
#
# config :bulwark, Bulwark.Mailer,
# adapter: Swoosh.Adapters.Mailgun,
# api_key: System.get_env("MAILGUN_API_KEY"),
# domain: System.get_env("MAILGUN_DOMAIN")
#
# Most non-SMTP adapters require an API client. Swoosh supports Req, Hackney,
# and Finch out-of-the-box. This configuration is typically done at
# compile-time in your config/prod.exs:
#
# config :swoosh, :api_client, Swoosh.ApiClient.Req
#
# See https://hexdocs.pm/swoosh/Swoosh.html#module-installation for details.
end
+3 -6
View File
@@ -1,5 +1,8 @@
import Config
# Only in tests, remove the complexity from the password hashing algorithm
config :argon2_elixir, t_cost: 1, m_cost: 8
# Configure your database.
#
# The MIX_TEST_PARTITION environment variable can be used to provide built-in
@@ -49,12 +52,6 @@ config :bulwark, BulwarkWeb.Endpoint,
secret_key_base: "eY3kwD7MW/KzmXoW8XsEU//xv9WLQT3hBSAp9NGXJNyiq1LgS/cDfSfzAqn4B4jQ",
server: false
# In test we don't send emails
config :bulwark, Bulwark.Mailer, adapter: Swoosh.Adapters.Test
# Disable swoosh api client as it is only required for production adapters
config :swoosh, :api_client, false
# Print only warnings and errors during test
config :logger, level: :warning
+199
View File
@@ -0,0 +1,199 @@
defmodule Bulwark.Accounts do
@moduledoc """
The Accounts context.
"""
import Ecto.Query, warn: false
alias Bulwark.Repo
alias Bulwark.Accounts.{User, UserToken}
## Database getters
@doc """
Gets a user by email.
## Examples
iex> get_user_by_email("foo@example.com")
%User{}
iex> get_user_by_email("unknown@example.com")
nil
"""
def get_user_by_email(email) when is_binary(email) do
Repo.get_by(User, email: email)
end
@doc """
Gets a user by email and password.
## Examples
iex> get_user_by_email_and_password("foo@example.com", "correct_password")
%User{}
iex> get_user_by_email_and_password("foo@example.com", "invalid_password")
nil
"""
def get_user_by_email_and_password(email, password)
when is_binary(email) and is_binary(password) do
user = Repo.get_by(User, email: email)
if User.valid_password?(user, password), do: user
end
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
## Examples
iex> get_user!(123)
%User{}
iex> get_user!(456)
** (Ecto.NoResultsError)
"""
def get_user!(id), do: Repo.get!(User, id)
@doc """
Lists all users, ordered by email. Admin user-management view.
"""
def list_users do
Repo.all(from u in User, order_by: [asc: u.email])
end
## Admin user management
@doc """
Returns a changeset for an admin creating a user (email, role, temp password).
"""
def change_admin_user(user \\ %User{}, attrs \\ %{}) do
User.admin_create_changeset(user, attrs, hash_password: false)
end
@doc """
Creates a user as an admin: sets a temporary password and forces a change on
first login. Returns `{:ok, user}` or `{:error, changeset}`.
"""
def create_user_as_admin(attrs) do
%User{}
|> User.admin_create_changeset(attrs)
|> Repo.insert()
end
@doc """
Resets a user's password as an admin, forcing a change on next login and
expiring all of that user's existing sessions.
"""
def admin_reset_user_password(%User{} = user, attrs) do
user
|> User.admin_reset_password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
@doc "Updates a user's role."
def update_user_role(%User{} = user, attrs) do
user
|> User.role_changeset(attrs)
|> Repo.update()
end
## Settings
@doc """
Checks whether the user is in sudo mode.
The user is in sudo mode when the last authentication was done no further
than 20 minutes ago. The limit can be given as second argument in minutes.
"""
def sudo_mode?(user, minutes \\ -20)
def sudo_mode?(%User{authenticated_at: ts}, minutes) when is_struct(ts, DateTime) do
DateTime.after?(ts, DateTime.utc_now() |> DateTime.add(minutes, :minute))
end
def sudo_mode?(_user, _minutes), do: false
@doc """
Returns an `%Ecto.Changeset{}` for changing the user password.
See `Bulwark.Accounts.User.password_changeset/3` for a list of supported options.
## Examples
iex> change_user_password(user)
%Ecto.Changeset{data: %User{}}
"""
def change_user_password(user, attrs \\ %{}, opts \\ []) do
User.password_changeset(user, attrs, opts)
end
@doc """
Updates the user password.
Returns a tuple with the updated user, as well as a list of expired tokens.
## Examples
iex> update_user_password(user, %{password: ...})
{:ok, {%User{}, [...]}}
iex> update_user_password(user, %{password: "too short"})
{:error, %Ecto.Changeset{}}
"""
def update_user_password(user, attrs) do
user
|> User.password_changeset(attrs)
|> update_user_and_delete_all_tokens()
end
## Session
@doc """
Generates a session token.
"""
def generate_user_session_token(user) do
{token, user_token} = UserToken.build_session_token(user)
Repo.insert!(user_token)
token
end
@doc """
Gets the user with the given signed token.
If the token is valid `{user, token_inserted_at}` is returned, otherwise `nil` is returned.
"""
def get_user_by_session_token(token) do
{:ok, query} = UserToken.verify_session_token_query(token)
Repo.one(query)
end
@doc """
Deletes the signed token with the given context.
"""
def delete_user_session_token(token) do
Repo.delete_all(from(UserToken, where: [token: ^token, context: "session"]))
:ok
end
## Token helper
defp update_user_and_delete_all_tokens(changeset) do
Repo.transact(fn ->
with {:ok, user} <- Repo.update(changeset) do
tokens_to_expire = Repo.all_by(UserToken, user_id: user.id)
Repo.delete_all(from(t in UserToken, where: t.id in ^Enum.map(tokens_to_expire, & &1.id)))
{:ok, {user, tokens_to_expire}}
end
end)
end
end
+41
View File
@@ -0,0 +1,41 @@
defmodule Bulwark.Accounts.Scope do
@moduledoc """
Defines the scope of the caller to be used throughout the app.
The `Bulwark.Accounts.Scope` allows public interfaces to receive
information about the caller, such as if the call is initiated from an
end-user, and if so, which user. Additionally, such a scope can carry fields
such as "super user" or other privileges for use as authorization, or to
ensure specific code paths can only be access for a given scope.
It is useful for logging as well as for scoping pubsub subscriptions and
broadcasts when a caller subscribes to an interface or performs a particular
action.
Feel free to extend the fields on this struct to fit the needs of
growing application requirements.
"""
alias Bulwark.Accounts.User
defstruct user: nil
@doc """
Creates a scope for the given user.
Returns nil if no user is given.
"""
def for_user(%User{} = user) do
%__MODULE__{user: user}
end
def for_user(nil), do: nil
@doc "Returns true if the scope's user is an admin."
def admin?(%__MODULE__{user: %User{role: "admin"}}), do: true
def admin?(_), do: false
@doc "Returns the scope's user id, or nil."
def user_id(%__MODULE__{user: %User{id: id}}), do: id
def user_id(_), do: nil
end
+168
View File
@@ -0,0 +1,168 @@
defmodule Bulwark.Accounts.User do
use Ecto.Schema
import Ecto.Changeset
@roles ~w(admin user)
schema "users" do
field :email, :string
field :password, :string, virtual: true, redact: true
field :hashed_password, :string, redact: true
field :role, :string, default: "user"
field :must_change_password, :boolean, default: false
field :confirmed_at, :utc_datetime
field :authenticated_at, :utc_datetime, virtual: true
timestamps(type: :utc_datetime)
end
@doc "Valid role values."
def roles, do: @roles
@doc """
Changeset for an admin creating a user: sets email, role, an initial
(temporary) password, and forces a password change on first login.
"""
def admin_create_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email, :role, :password])
|> validate_email(opts)
|> validate_role()
|> validate_password(opts)
|> put_change(:must_change_password, true)
|> put_change(:confirmed_at, DateTime.utc_now(:second))
end
@doc "Changeset for changing a user's role."
def role_changeset(user, attrs) do
user
|> cast(attrs, [:role])
|> validate_role()
end
defp validate_role(changeset) do
changeset
|> validate_required([:role])
|> validate_inclusion(:role, @roles)
end
@doc """
A user changeset for registering or changing the email.
It requires the email to change otherwise an error is added.
## Options
* `:validate_unique` - Set to false if you don't want to validate the
uniqueness of the email, useful when displaying live validations.
Defaults to `true`.
"""
def email_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:email])
|> validate_email(opts)
end
defp validate_email(changeset, opts) do
changeset =
changeset
|> validate_required([:email])
|> validate_format(:email, ~r/^[^@,;\s]+@[^@,;\s]+$/,
message: "must have the @ sign and no spaces"
)
|> validate_length(:email, max: 160)
if Keyword.get(opts, :validate_unique, true) do
changeset
|> unsafe_validate_unique(:email, Bulwark.Repo)
|> unique_constraint(:email)
|> validate_email_changed()
else
changeset
end
end
defp validate_email_changed(changeset) do
if get_field(changeset, :email) && get_change(changeset, :email) == nil do
add_error(changeset, :email, "did not change")
else
changeset
end
end
@doc """
A user changeset for changing the password.
It is important to validate the length of the password, as long passwords may
be very expensive to hash for certain algorithms.
## Options
* `:hash_password` - Hashes the password so it can be stored securely
in the database and ensures the password field is cleared to prevent
leaks in the logs. If password hashing is not needed and clearing the
password field is not desired (like when using this changeset for
validations on a LiveView form), this option can be set to `false`.
Defaults to `true`.
"""
def password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_confirmation(:password, message: "does not match password")
|> validate_password(opts)
|> put_change(:must_change_password, false)
end
@doc """
Changeset for an admin resetting another user's password to a new temporary
value. Forces the target to change it on next login.
"""
def admin_reset_password_changeset(user, attrs, opts \\ []) do
user
|> cast(attrs, [:password])
|> validate_password(opts)
|> put_change(:must_change_password, true)
end
defp validate_password(changeset, opts) do
changeset
|> validate_required([:password])
|> validate_length(:password, min: 12, max: 72)
# Examples of additional password validation:
# |> validate_format(:password, ~r/[a-z]/, message: "at least one lower case character")
# |> validate_format(:password, ~r/[A-Z]/, message: "at least one upper case character")
# |> validate_format(:password, ~r/[!?@#$%^&*_0-9]/, message: "at least one digit or punctuation character")
|> maybe_hash_password(opts)
end
defp maybe_hash_password(changeset, opts) do
hash_password? = Keyword.get(opts, :hash_password, true)
password = get_change(changeset, :password)
if hash_password? && password && changeset.valid? do
changeset
# Hashing could be done with `Ecto.Changeset.prepare_changes/2`, but that
# would keep the database transaction open longer and hurt performance.
|> put_change(:hashed_password, Argon2.hash_pwd_salt(password))
|> delete_change(:password)
else
changeset
end
end
@doc """
Verifies the password.
If there is no user or the user doesn't have a password, we call
`Argon2.no_user_verify/0` to avoid timing attacks.
"""
def valid_password?(%Bulwark.Accounts.User{hashed_password: hashed_password}, password)
when is_binary(hashed_password) and byte_size(password) > 0 do
Argon2.verify_pass(password, hashed_password)
end
def valid_password?(_, _) do
Argon2.no_user_verify()
false
end
end
+66
View File
@@ -0,0 +1,66 @@
defmodule Bulwark.Accounts.UserToken do
use Ecto.Schema
import Ecto.Query
alias Bulwark.Accounts.UserToken
@rand_size 32
@session_validity_in_days 14
schema "users_tokens" do
field :token, :binary
field :context, :string
field :sent_to, :string
field :authenticated_at, :utc_datetime
belongs_to :user, Bulwark.Accounts.User
timestamps(type: :utc_datetime, updated_at: false)
end
@doc """
Generates a token that will be stored in a signed place,
such as session or cookie. As they are signed, those
tokens do not need to be hashed.
The reason why we store session tokens in the database, even
though Phoenix already provides a session cookie, is because
Phoenix's default session cookies are not persisted, they are
simply signed and potentially encrypted. This means they are
valid indefinitely, unless you change the signing/encryption
salt.
Therefore, storing them allows individual user
sessions to be expired. The token system can also be extended
to store additional data, such as the device used for logging in.
You could then use this information to display all valid sessions
and devices in the UI and allow users to explicitly expire any
session they deem invalid.
"""
def build_session_token(user) do
token = :crypto.strong_rand_bytes(@rand_size)
dt = user.authenticated_at || DateTime.utc_now(:second)
{token, %UserToken{token: token, context: "session", user_id: user.id, authenticated_at: dt}}
end
@doc """
Checks if the token is valid and returns its underlying lookup query.
The query returns the user found by the token, if any, along with the token's creation time.
The token is valid if it matches the value in the database and it has
not expired (after @session_validity_in_days).
"""
def verify_session_token_query(token) do
query =
from token in by_token_and_context_query(token, "session"),
join: user in assoc(token, :user),
where: token.inserted_at > ago(@session_validity_in_days, "day"),
select: {%{user | authenticated_at: token.authenticated_at}, token.inserted_at}
{:ok, query}
end
defp by_token_and_context_query(token, context) do
from UserToken, where: [token: ^token, context: ^context]
end
end
+5 -8
View File
@@ -12,13 +12,11 @@ defmodule Bulwark.Ingestion.ParseJob do
alias Bulwark.{Repo, Security}
alias Bulwark.Ingestion.{Detector, Parsers}
@pubsub Bulwark.PubSub
@impl Oban.Worker
require Logger
def perform(%Oban.Job{args: %{"scan_id" => scan_id}}) do
scan = Security.get_scan!(scan_id)
scan = Security.get_scan_for_job!(scan_id)
if scan.status == "cancelled" do
:ok
@@ -106,10 +104,10 @@ defmodule Bulwark.Ingestion.ParseJob do
vuln_attrs = Map.update!(f.vulnerability, :external_id, &stable_external_id(&1, f))
Repo.transaction(fn ->
asset = Security.upsert_asset!(f.asset)
asset = Security.upsert_asset!(scan.user_id, f.asset)
vuln = Security.upsert_vulnerability!(vuln_attrs)
Security.create_finding!(%{
Security.create_finding!(scan.user_id, %{
scan_id: scan.id,
vulnerability_id: vuln.id,
asset_id: asset.id,
@@ -169,8 +167,7 @@ defmodule Bulwark.Ingestion.ParseJob do
end
defp broadcast(scan) do
Security.invalidate_dashboard_cache()
Phoenix.PubSub.broadcast(@pubsub, "scans", {:scan_updated, scan})
Phoenix.PubSub.broadcast(@pubsub, "scan:#{scan.id}", {:scan_updated, scan})
Security.invalidate_dashboard_cache(scan.user_id)
Security.broadcast_scan(scan)
end
end
-3
View File
@@ -1,3 +0,0 @@
defmodule Bulwark.Mailer do
use Swoosh.Mailer, otp_app: :bulwark
end
+47
View File
@@ -20,6 +20,53 @@ defmodule Bulwark.Release do
:ok
end
@doc """
Bootstraps the sole admin account if none exists. Idempotent.
Registration is closed (admin-managed), so a bootstrap admin must exist
before anyone can log in. The email comes from `ADMIN_EMAIL` (default
`admin@bulwark.local`); a random temporary password is generated and printed
ONCE to stdout. Capture it from the pod logs — the admin must change it on
first login (`must_change_password` is set). No file is written, since a
release container's filesystem is ephemeral.
"""
@spec seed_admin() :: :ok
def seed_admin do
load_app()
{:ok, _} = Application.ensure_all_started(@app)
import Ecto.Query
alias Bulwark.Accounts
alias Bulwark.Accounts.User
alias Bulwark.Repo
admin_email = System.get_env("ADMIN_EMAIL", "admin@bulwark.local")
if Repo.exists?(from u in User, where: u.role == "admin") do
IO.puts("[seed_admin] Admin already exists — skipping.")
else
password = :crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)
case Accounts.create_user_as_admin(%{
email: admin_email,
role: "admin",
password: password
}) do
{:ok, user} ->
IO.puts("[seed_admin] Created admin #{user.email}.")
IO.puts("[seed_admin] TEMPORARY PASSWORD (shown once): #{password}")
IO.puts("[seed_admin] Log in and change it immediately.")
{:error, changeset} ->
IO.puts("[seed_admin] Failed to create admin:")
IO.inspect(changeset.errors)
exit({:shutdown, 1})
end
end
:ok
end
@doc "Rolls `repo` back to migration version `version`."
@spec rollback(module(), integer()) :: {:ok, term(), term()}
def rollback(repo, version) do
+238 -119
View File
@@ -5,47 +5,97 @@ defmodule Bulwark.Security do
This is the primary interface for the security domain. It provides CRUD
operations, Flop-powered listing with filtering/sorting/pagination, upsert
semantics for assets and vulnerabilities, and dashboard statistics.
## Tenancy
Every public function takes a `Bulwark.Accounts.Scope` as its first argument
and only returns data the scope is allowed to see:
* Scans, assets, and findings carry a `user_id` and are filtered to the
scope's user.
* Vulnerabilities are a GLOBAL CVE catalog (no `user_id`). A user only sees
a vulnerability if they own a finding referencing it — derived through a
correlated `EXISTS` on findings, never by exposing the whole catalog.
* An admin scope bypasses the per-user filter and sees everything.
`get_*!` apply the same scope filter, so requesting another tenant's record by
id raises `Ecto.NoResultsError` (a 404) rather than leaking its existence.
"""
import Ecto.Query
alias Bulwark.Cache
alias Bulwark.Repo
alias Bulwark.Accounts.Scope
alias Bulwark.Security.{Scan, Asset, Vulnerability, Finding}
# Dashboard aggregates change only on ingestion/triage events, which already
# broadcast over PubSub. We cache them in Valkey and invalidate on those same
# events, so the hot dashboard path avoids three aggregate queries per render.
@dashboard_cache_keys ~w(
bulwark:dashboard:severity_counts
bulwark:dashboard:scan_counts
bulwark:dashboard:open_findings
)
@dashboard_ttl 300
# --- Scope helpers ---
# Restricts a query on a schema that has a :user_id column to the scope's
# user. Admins see all rows.
defp scope_owned(query, %Scope{} = scope) do
if Scope.admin?(scope) do
query
else
uid = Scope.user_id(scope)
from(q in query, where: q.user_id == ^uid)
end
end
# Restricts a vulnerability query to vulnerabilities the scope can see: those
# with at least one finding owned by the scope's user. Admins see all.
defp scope_vulnerabilities(query, %Scope{} = scope) do
if Scope.admin?(scope) do
query
else
uid = Scope.user_id(scope)
from(v in query,
as: :vuln,
where:
exists(
from(f in Finding,
where: f.vulnerability_id == parent_as(:vuln).id and f.user_id == ^uid
)
)
)
end
end
# --- Scans ---
@doc """
Creates a scan record from the given attributes.
## Examples
iex> create_scan(%{source_tool: "trivy", source_format: "json"})
{:ok, %Scan{}}
Creates a scan record from the given attributes, stamping it with the scope's
user.
"""
@spec create_scan(map()) :: {:ok, Scan.t()} | {:error, Ecto.Changeset.t()}
def create_scan(attrs) do
@spec create_scan(Scope.t(), map()) :: {:ok, Scan.t()} | {:error, Ecto.Changeset.t()}
def create_scan(%Scope{} = scope, attrs) do
%Scan{}
|> Scan.changeset(attrs)
|> Scan.changeset(Map.put(attrs, :user_id, Scope.user_id(scope)))
|> Repo.insert()
end
@doc """
Fetches a scan by ID, raising `Ecto.NoResultsError` if not found.
Fetches a scan owned by the scope, raising `Ecto.NoResultsError` if not found
or owned by another user.
"""
@spec get_scan!(integer()) :: Scan.t()
def get_scan!(id), do: Repo.get!(Scan, id)
@spec get_scan!(Scope.t(), integer()) :: Scan.t()
def get_scan!(%Scope{} = scope, id) do
Scan
|> scope_owned(scope)
|> Repo.get!(id)
end
@doc """
Fetches a scan by id for trusted system callers (the Oban ParseJob), bypassing
user scoping. The job only has a scan id and runs as infrastructure, not on
behalf of a logged-in user; tenancy is enforced downstream by stamping the
scan's own `user_id` onto the assets/findings it produces.
"""
@spec get_scan_for_job!(integer()) :: Scan.t()
def get_scan_for_job!(id), do: Repo.get!(Scan, id)
@doc """
Transitions a scan to the given `status`, merging optional metadata attributes
@@ -60,19 +110,23 @@ defmodule Bulwark.Security do
end
@doc """
Lists scans with Flop filtering, sorting, and pagination.
Lists the scope's scans with Flop filtering, sorting, and pagination.
"""
@spec list_scans(map()) :: {:ok, {[Scan.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_scans(params \\ %{}) do
Flop.validate_and_run(Scan, params, for: Scan)
@spec list_scans(Scope.t(), map()) ::
{:ok, {[Scan.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_scans(%Scope{} = scope, params \\ %{}) do
Scan
|> scope_owned(scope)
|> Flop.validate_and_run(params, for: Scan)
end
@doc """
Returns the most recent scans, ordered by insertion time descending.
Returns the scope's most recent scans, ordered by insertion time descending.
"""
@spec recent_scans(non_neg_integer()) :: [Scan.t()]
def recent_scans(limit \\ 10) do
@spec recent_scans(Scope.t(), non_neg_integer()) :: [Scan.t()]
def recent_scans(%Scope{} = scope, limit \\ 10) do
Scan
|> scope_owned(scope)
|> order_by(desc: :inserted_at)
|> limit(^limit)
|> Repo.all()
@@ -87,9 +141,8 @@ defmodule Bulwark.Security do
@spec cancel_scan(Scan.t()) :: {:ok, Scan.t()} | {:error, :not_cancellable}
def cancel_scan(%Scan{status: status} = scan) when status in ~w(pending processing) do
{:ok, scan} = update_scan_status(scan, "cancelled", %{completed_at: DateTime.utc_now()})
invalidate_dashboard_cache()
Phoenix.PubSub.broadcast(Bulwark.PubSub, "scans", {:scan_updated, scan})
Phoenix.PubSub.broadcast(Bulwark.PubSub, "scan:#{scan.id}", {:scan_updated, scan})
invalidate_dashboard_cache(scan.user_id)
broadcast_scan(scan)
{:ok, scan}
end
@@ -98,46 +151,51 @@ defmodule Bulwark.Security do
# --- Assets ---
@doc """
Upserts an asset, inserting if new or updating `metadata` on conflict.
Conflict is determined by the compound `[:type, :identifier]` unique index.
Raises on validation failure.
Upserts an asset for the given user, inserting if new or updating `metadata`
on conflict. Conflict is determined by the `[:user_id, :type, :identifier]`
unique index. Raises on validation failure.
"""
@spec upsert_asset!(map()) :: Asset.t()
def upsert_asset!(attrs) do
@spec upsert_asset!(integer(), map()) :: Asset.t()
def upsert_asset!(user_id, attrs) do
%Asset{}
|> Asset.changeset(attrs)
|> Asset.changeset(Map.put(attrs, :user_id, user_id))
|> Repo.insert!(
on_conflict: {:replace, [:metadata, :updated_at]},
conflict_target: [:type, :identifier],
conflict_target: [:user_id, :type, :identifier],
returning: true
)
end
@doc """
Fetches an asset by ID, raising `Ecto.NoResultsError` if not found.
Fetches an asset owned by the scope, raising `Ecto.NoResultsError` if not
found or owned by another user.
"""
@spec get_asset!(integer()) :: Asset.t()
def get_asset!(id), do: Repo.get!(Asset, id)
@spec get_asset!(Scope.t(), integer()) :: Asset.t()
def get_asset!(%Scope{} = scope, id) do
Asset
|> scope_owned(scope)
|> Repo.get!(id)
end
@doc """
Lists assets with Flop filtering, sorting, and pagination.
Lists the scope's assets with Flop filtering, sorting, and pagination.
"""
@spec list_assets(map()) :: {:ok, {[Asset.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_assets(params \\ %{}) do
Flop.validate_and_run(Asset, params, for: Asset)
@spec list_assets(Scope.t(), map()) ::
{:ok, {[Asset.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_assets(%Scope{} = scope, params \\ %{}) do
Asset
|> scope_owned(scope)
|> Flop.validate_and_run(params, for: Asset)
end
# --- Vulnerabilities ---
@doc """
Upserts a vulnerability, inserting if new or updating severity, title,
description, and references on conflict.
Upserts a vulnerability into the GLOBAL catalog, inserting if new or updating
severity, title, description, and references on conflict. Conflict is
determined by the `external_id` unique index. Raises on validation failure.
Conflict is determined by the `external_id` unique index.
Raises on validation failure.
Not scoped: the CVE catalog is shared across all users.
"""
@spec upsert_vulnerability!(map()) :: Vulnerability.t()
def upsert_vulnerability!(attrs) do
@@ -151,27 +209,36 @@ defmodule Bulwark.Security do
end
@doc """
Fetches a vulnerability by ID, raising `Ecto.NoResultsError` if not found.
Fetches a vulnerability visible to the scope (one the scope's user has a
finding for), raising `Ecto.NoResultsError` otherwise.
"""
@spec get_vulnerability!(integer()) :: Vulnerability.t()
def get_vulnerability!(id), do: Repo.get!(Vulnerability, id)
@doc """
Lists vulnerabilities with Flop filtering, sorting, and pagination.
"""
@spec list_vulnerabilities(map()) ::
{:ok, {[Vulnerability.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_vulnerabilities(params \\ %{}) do
Flop.validate_and_run(Vulnerability, params, for: Vulnerability)
@spec get_vulnerability!(Scope.t(), integer()) :: Vulnerability.t()
def get_vulnerability!(%Scope{} = scope, id) do
Vulnerability
|> scope_vulnerabilities(scope)
|> Repo.get!(id)
end
@doc """
Returns the most recently discovered vulnerabilities, ordered by insertion
time descending.
Lists vulnerabilities visible to the scope with Flop filtering, sorting, and
pagination.
"""
@spec recent_vulnerabilities(non_neg_integer()) :: [Vulnerability.t()]
def recent_vulnerabilities(limit \\ 10) do
@spec list_vulnerabilities(Scope.t(), map()) ::
{:ok, {[Vulnerability.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_vulnerabilities(%Scope{} = scope, params \\ %{}) do
Vulnerability
|> scope_vulnerabilities(scope)
|> Flop.validate_and_run(params, for: Vulnerability)
end
@doc """
Returns the most recently discovered vulnerabilities visible to the scope,
ordered by insertion time descending.
"""
@spec recent_vulnerabilities(Scope.t(), non_neg_integer()) :: [Vulnerability.t()]
def recent_vulnerabilities(%Scope{} = scope, limit \\ 10) do
Vulnerability
|> scope_vulnerabilities(scope)
|> order_by(desc: :inserted_at)
|> limit(^limit)
|> Repo.all()
@@ -180,76 +247,81 @@ defmodule Bulwark.Security do
# --- Findings ---
@doc """
Creates a finding record. Raises on validation failure.
Creates a finding record for the given user. Raises on validation failure.
"""
@spec create_finding!(map()) :: Finding.t()
def create_finding!(attrs) do
@spec create_finding!(integer(), map()) :: Finding.t()
def create_finding!(user_id, attrs) do
%Finding{}
|> Finding.changeset(attrs)
|> Finding.changeset(Map.put(attrs, :user_id, user_id))
|> Repo.insert!()
end
@doc """
Fetches a finding by ID with preloaded associations, raising
`Ecto.NoResultsError` if not found.
Fetches a finding owned by the scope with preloaded associations, raising
`Ecto.NoResultsError` if not found or owned by another user.
"""
@spec get_finding!(integer()) :: Finding.t()
def get_finding!(id) do
@spec get_finding!(Scope.t(), integer()) :: Finding.t()
def get_finding!(%Scope{} = scope, id) do
Finding
|> scope_owned(scope)
|> preload([:vulnerability, :asset, :scan])
|> Repo.get!(id)
end
@doc """
Lists all findings with Flop filtering, sorting, and pagination.
Lists the scope's findings with Flop filtering, sorting, and pagination.
Preloads `:vulnerability`, `:asset`, and `:scan` associations.
"""
@spec list_findings(map()) ::
@spec list_findings(Scope.t(), map()) ::
{:ok, {[Finding.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_findings(params \\ %{}) do
def list_findings(%Scope{} = scope, params \\ %{}) do
Finding
|> scope_owned(scope)
|> preload([:vulnerability, :asset, :scan])
|> Flop.validate_and_run(params, for: Finding)
end
@doc """
Lists findings for a specific scan with Flop pagination.
Lists the scope's findings for a specific scan with Flop pagination.
Preloads `:vulnerability` and `:asset`.
"""
@spec list_findings_for_scan(integer(), map()) ::
@spec list_findings_for_scan(Scope.t(), integer(), map()) ::
{:ok, {[Finding.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_findings_for_scan(scan_id, params \\ %{}) do
def list_findings_for_scan(%Scope{} = scope, scan_id, params \\ %{}) do
Finding
|> scope_owned(scope)
|> where(scan_id: ^scan_id)
|> preload([:vulnerability, :asset])
|> Flop.validate_and_run(params, for: Finding)
end
@doc """
Lists findings for a specific vulnerability with Flop pagination.
Lists the scope's findings for a specific vulnerability with Flop pagination.
Preloads `:asset` and `:scan`.
"""
@spec list_findings_for_vulnerability(integer(), map()) ::
@spec list_findings_for_vulnerability(Scope.t(), integer(), map()) ::
{:ok, {[Finding.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_findings_for_vulnerability(vulnerability_id, params \\ %{}) do
def list_findings_for_vulnerability(%Scope{} = scope, vulnerability_id, params \\ %{}) do
Finding
|> scope_owned(scope)
|> where(vulnerability_id: ^vulnerability_id)
|> preload([:asset, :scan])
|> Flop.validate_and_run(params, for: Finding)
end
@doc """
Lists findings for a specific asset with Flop pagination.
Lists the scope's findings for a specific asset with Flop pagination.
Preloads `:vulnerability` and `:scan`.
"""
@spec list_findings_for_asset(integer(), map()) ::
@spec list_findings_for_asset(Scope.t(), integer(), map()) ::
{:ok, {[Finding.t()], Flop.Meta.t()}} | {:error, Flop.Meta.t()}
def list_findings_for_asset(asset_id, params \\ %{}) do
def list_findings_for_asset(%Scope{} = scope, asset_id, params \\ %{}) do
Finding
|> scope_owned(scope)
|> where(asset_id: ^asset_id)
|> preload([:vulnerability, :scan])
|> Flop.validate_and_run(params, for: Finding)
@@ -260,8 +332,8 @@ defmodule Bulwark.Security do
Valid statuses: `"open"`, `"acknowledged"`, `"resolved"`, `"false_positive"`.
Broadcasts `{:finding_updated, finding}` on the `"findings"` PubSub topic
on success.
Broadcasts `{:finding_updated, finding}` on the owning user's findings PubSub
topic on success.
"""
@spec update_finding_status(Finding.t(), String.t()) ::
{:ok, Finding.t()} | {:error, Ecto.Changeset.t()}
@@ -274,8 +346,14 @@ defmodule Bulwark.Security do
case result do
{:ok, updated} ->
invalidate_dashboard_cache()
Phoenix.PubSub.broadcast(Bulwark.PubSub, "findings", {:finding_updated, updated})
invalidate_dashboard_cache(updated.user_id)
Phoenix.PubSub.broadcast(
Bulwark.PubSub,
findings_topic(updated.user_id),
{:finding_updated, updated}
)
{:ok, updated}
error ->
@@ -283,35 +361,66 @@ defmodule Bulwark.Security do
end
end
# --- PubSub topics ---
@doc "PubSub topic for a user's scan events."
@spec scans_topic(integer()) :: String.t()
def scans_topic(user_id), do: "user:#{user_id}:scans"
@doc "PubSub topic for a single scan's events, namespaced to its owner."
@spec scan_topic(integer(), integer()) :: String.t()
def scan_topic(user_id, scan_id), do: "user:#{user_id}:scan:#{scan_id}"
@doc "PubSub topic for a user's finding (triage) events."
@spec findings_topic(integer()) :: String.t()
def findings_topic(user_id), do: "user:#{user_id}:findings"
@doc """
Broadcasts a scan update on both the user's scans topic and the per-scan topic.
"""
@spec broadcast_scan(Scan.t()) :: :ok
def broadcast_scan(%Scan{} = scan) do
Phoenix.PubSub.broadcast(Bulwark.PubSub, scans_topic(scan.user_id), {:scan_updated, scan})
Phoenix.PubSub.broadcast(
Bulwark.PubSub,
scan_topic(scan.user_id, scan.id),
{:scan_updated, scan}
)
:ok
end
# --- Dashboard stats ---
@doc """
Returns a map of `%{severity => count}` for vulnerabilities with open findings.
Cached in Valkey; falls through to Postgres on a miss or cache outage.
Returns a map of `%{severity => count}` for the scope's vulnerabilities with
open findings. Cached in Valkey per user; falls through to Postgres on a miss.
"""
@spec vulnerability_counts_by_severity() :: %{String.t() => non_neg_integer()}
def vulnerability_counts_by_severity do
Cache.fetch("bulwark:dashboard:severity_counts", [ttl: @dashboard_ttl], fn ->
Vulnerability
|> join(:inner, [v], f in Finding, on: f.vulnerability_id == v.id and f.status == "open")
|> group_by([v], v.severity)
|> select([v], {v.severity, count(v.id)})
@spec vulnerability_counts_by_severity(Scope.t()) :: %{String.t() => non_neg_integer()}
def vulnerability_counts_by_severity(%Scope{} = scope) do
Cache.fetch(dashboard_key(scope, "severity_counts"), [ttl: @dashboard_ttl], fn ->
Finding
|> scope_owned(scope)
|> where([f], f.status == "open")
|> join(:inner, [f], v in Vulnerability, on: v.id == f.vulnerability_id)
|> group_by([f, v], v.severity)
|> select([f, v], {v.severity, count(f.id, :distinct)})
|> Repo.all()
|> Map.new()
end)
end
@doc """
Returns a map of `%{status => count}` for all scans.
Cached in Valkey; falls through to Postgres on a miss or cache outage.
Returns a map of `%{status => count}` for the scope's scans. Cached in Valkey
per user; falls through to Postgres on a miss.
"""
@spec scan_counts_by_status() :: %{String.t() => non_neg_integer()}
def scan_counts_by_status do
Cache.fetch("bulwark:dashboard:scan_counts", [ttl: @dashboard_ttl], fn ->
@spec scan_counts_by_status(Scope.t()) :: %{String.t() => non_neg_integer()}
def scan_counts_by_status(%Scope{} = scope) do
Cache.fetch(dashboard_key(scope, "scan_counts"), [ttl: @dashboard_ttl], fn ->
Scan
|> group_by(:status)
|> scope_owned(scope)
|> group_by([s], s.status)
|> select([s], {s.status, count(s.id)})
|> Repo.all()
|> Map.new()
@@ -319,25 +428,35 @@ defmodule Bulwark.Security do
end
@doc """
Returns the total count of findings with status `"open"`.
Cached in Valkey; falls through to Postgres on a miss or cache outage.
Returns the total count of the scope's findings with status `"open"`. Cached
in Valkey per user; falls through to Postgres on a miss.
"""
@spec total_open_findings() :: non_neg_integer()
def total_open_findings do
Cache.fetch("bulwark:dashboard:open_findings", [ttl: @dashboard_ttl], fn ->
@spec total_open_findings(Scope.t()) :: non_neg_integer()
def total_open_findings(%Scope{} = scope) do
Cache.fetch(dashboard_key(scope, "open_findings"), [ttl: @dashboard_ttl], fn ->
Finding
|> scope_owned(scope)
|> where(status: "open")
|> Repo.aggregate(:count)
end)
end
@doc """
Invalidates the cached dashboard aggregates. Called whenever scan or finding
state changes so the next dashboard render recomputes from Postgres.
Invalidates the cached dashboard aggregates for the given user (and the admin
global view), so the next render recomputes from Postgres.
"""
@spec invalidate_dashboard_cache() :: :ok
def invalidate_dashboard_cache do
Cache.delete(@dashboard_cache_keys)
@spec invalidate_dashboard_cache(integer()) :: :ok
def invalidate_dashboard_cache(user_id) do
keys =
for who <- [to_string(user_id), "admin"],
metric <- ~w(severity_counts scan_counts open_findings),
do: "bulwark:dashboard:#{who}:#{metric}"
Cache.delete(keys)
end
defp dashboard_key(%Scope{} = scope, metric) do
who = if Scope.admin?(scope), do: "admin", else: to_string(Scope.user_id(scope))
"bulwark:dashboard:#{who}:#{metric}"
end
end
+6 -4
View File
@@ -3,8 +3,9 @@ defmodule Bulwark.Security.Asset do
Represents a discoverable asset such as a container image, package, repository,
or filesystem.
Assets are upserted on the compound key `[:type, :identifier]`, so re-scanning
updates metadata rather than creating duplicates.
Assets are upserted per-user on the compound key `[:user_id, :type,
:identifier]`, so re-scanning updates metadata rather than creating
duplicates, while keeping each user's inventory isolated.
"""
use Ecto.Schema
import Ecto.Changeset
@@ -22,12 +23,13 @@ defmodule Bulwark.Security.Asset do
field :identifier, :string
field :metadata, :map, default: %{}
belongs_to :user, Bulwark.Accounts.User
has_many :findings, Bulwark.Security.Finding
timestamps(type: :utc_datetime)
end
@required ~w(type identifier)a
@required ~w(type identifier user_id)a
@doc """
Returns a changeset for creating or updating an asset.
@@ -37,6 +39,6 @@ defmodule Bulwark.Security.Asset do
|> cast(attrs, @required ++ [:metadata])
|> validate_required(@required)
|> validate_inclusion(:type, ~w(container_image repository filesystem package))
|> unique_constraint([:type, :identifier])
|> unique_constraint([:user_id, :type, :identifier])
end
end
+2 -1
View File
@@ -23,6 +23,7 @@ defmodule Bulwark.Security.Finding do
field :status, :string, default: "open"
field :raw_data, :map, default: %{}
belongs_to :user, Bulwark.Accounts.User
belongs_to :scan, Bulwark.Security.Scan
belongs_to :vulnerability, Bulwark.Security.Vulnerability
belongs_to :asset, Bulwark.Security.Asset
@@ -30,7 +31,7 @@ defmodule Bulwark.Security.Finding do
timestamps(type: :utc_datetime)
end
@required ~w(scan_id vulnerability_id asset_id)a
@required ~w(scan_id vulnerability_id asset_id user_id)a
@optional ~w(location installed_version fixed_version raw_data)a
@doc """
+2 -1
View File
@@ -27,12 +27,13 @@ defmodule Bulwark.Security.Scan do
field :started_at, :utc_datetime
field :completed_at, :utc_datetime
belongs_to :user, Bulwark.Accounts.User
has_many :findings, Bulwark.Security.Finding
timestamps(type: :utc_datetime)
end
@required ~w(source_tool source_format)a
@required ~w(source_tool source_format user_id)a
@optional ~w(file_name file_path)a
@doc """
@@ -75,7 +75,7 @@ defmodule BulwarkWeb.CoreComponents do
<.button navigate={~p"/"}>Home</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method download name value disabled)
attr :class, :string
attr :class, :string, default: nil, doc: "extra classes appended to the variant styles"
attr :variant, :string, values: ~w(primary)
slot :inner_block, required: true
@@ -86,12 +86,11 @@ defmodule BulwarkWeb.CoreComponents do
}
assigns =
assign_new(assigns, :class, fn ->
[
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 disabled:pointer-events-none disabled:opacity-50",
Map.fetch!(variants, assigns[:variant])
]
end)
assign(assigns, :class, [
"inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-500 disabled:pointer-events-none disabled:opacity-50",
Map.fetch!(variants, assigns[:variant]),
assigns[:class]
])
if rest[:href] || rest[:navigate] || rest[:patch] do
~H"""
+44 -1
View File
@@ -56,7 +56,7 @@ defmodule BulwarkWeb.Layouts do
phx-hook="Sidebar"
class="sidebar-transition fixed inset-y-0 left-0 z-40 flex shrink-0 -translate-x-full flex-col border-r border-zinc-800 bg-zinc-950 peer-checked:translate-x-0 lg:relative lg:translate-x-0"
>
<.sidebar active_section={@active_section} />
<.sidebar active_section={@active_section} current_scope={@current_scope} />
</aside>
<div class="flex min-w-0 flex-1 flex-col">
@@ -73,6 +73,27 @@ defmodule BulwarkWeb.Layouts do
"""
end
@doc """
Minimal centered shell for unauthenticated pages (login). No sidebar/header.
"""
attr :flash, :map, required: true, doc: "the map of flash messages"
slot :inner_block, required: true
def auth(assigns) do
~H"""
<div class="dot-grid flex min-h-screen items-center justify-center px-4">
<div class="w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-950/60 p-6 shadow-xl">
<div class="mb-6 flex items-center justify-center gap-2">
<.icon name="hero-shield-check" class="size-6 text-zinc-300" />
<span class="text-lg font-semibold tracking-tight text-zinc-100">Bulwark</span>
</div>
{render_slot(@inner_block)}
</div>
<.flash_group flash={@flash} />
</div>
"""
end
@nav_items [
%{section: :dashboard, label: "Dashboard", icon: "hero-chart-bar-square", path: "/"},
%{section: :scans, label: "Scans", icon: "hero-arrow-up-tray", path: "/scans"},
@@ -92,6 +113,7 @@ defmodule BulwarkWeb.Layouts do
]
attr :active_section, :atom, default: nil
attr :current_scope, :map, default: nil
defp sidebar(assigns) do
assigns = assign(assigns, :nav_items, @nav_items)
@@ -122,6 +144,17 @@ defmodule BulwarkWeb.Layouts do
</nav>
<div class="border-t border-zinc-800 space-y-0.5 px-2 py-3">
<.link
:if={@current_scope && Bulwark.Accounts.Scope.admin?(@current_scope)}
navigate={~p"/admin/users"}
class={nav_item_class(:admin, @active_section)}
aria-current={if :admin == @active_section, do: "page"}
title="Users"
phx-click={JS.dispatch("click", to: "#mobile-nav")}
>
<.icon name="hero-users" class="size-4 shrink-0" />
<span class="sidebar-label text-xs font-medium">Users</span>
</.link>
<.link
navigate={~p"/settings"}
class={nav_item_class(:settings, @active_section)}
@@ -132,6 +165,15 @@ defmodule BulwarkWeb.Layouts do
<.icon name="hero-cog-6-tooth" class="size-4 shrink-0" />
<span class="sidebar-label text-xs font-medium">Settings</span>
</.link>
<.link
href={~p"/users/log-out"}
method="delete"
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-zinc-500 hover:bg-zinc-900/50 hover:text-zinc-300"
title="Log out"
>
<.icon name="hero-arrow-right-start-on-rectangle" class="size-4 shrink-0" />
<span class="sidebar-label text-xs font-medium">Log out</span>
</.link>
<button
data-sidebar-toggle
class="hidden lg:flex w-full items-center gap-3 rounded-md px-2 py-2 text-zinc-500 hover:bg-zinc-900/50 hover:text-zinc-300"
@@ -271,6 +313,7 @@ defmodule BulwarkWeb.Layouts do
defp section_label(:findings), do: "Findings"
defp section_label(:assets), do: "Assets"
defp section_label(:settings), do: "Settings"
defp section_label(:admin), do: "Users"
defp section_label(_), do: nil
defp section_path("Scans"), do: ~p"/scans"
@@ -4,6 +4,10 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="csrf-token" content={get_csrf_token()} />
<%!-- Private SOC tool behind the tunnel/Authentik edge: keep it out of any
crawler index if a URL ever leaks. The opposite of SEO, on purpose. --%>
<meta name="robots" content="noindex, nofollow" />
<meta name="theme-color" content="#09090b" />
<.live_title default="Bulwark" suffix=" · Bulwark">
{assigns[:page_title]}
</.live_title>
@@ -0,0 +1,45 @@
defmodule BulwarkWeb.UserSessionController do
use BulwarkWeb, :controller
alias Bulwark.Accounts
alias BulwarkWeb.UserAuth
def create(conn, params) do
create(conn, params, "Welcome back!")
end
# email + password login
defp create(conn, %{"user" => user_params}, info) do
%{"email" => email, "password" => password} = user_params
if user = Accounts.get_user_by_email_and_password(email, password) do
conn
|> put_flash(:info, info)
|> UserAuth.log_in_user(user, user_params)
else
# In order to prevent user enumeration attacks, don't disclose whether the email is registered.
conn
|> put_flash(:error, "Invalid email or password")
|> put_flash(:email, String.slice(email, 0, 160))
|> redirect(to: ~p"/users/log-in")
end
end
def update_password(conn, %{"user" => user_params} = params) do
user = conn.assigns.current_scope.user
{:ok, {_user, expired_tokens}} = Accounts.update_user_password(user, user_params)
# disconnect all existing LiveViews with old sessions
UserAuth.disconnect_sessions(expired_tokens)
conn
|> put_session(:user_return_to, ~p"/")
|> create(params, "Password updated successfully!")
end
def delete(conn, _params) do
conn
|> put_flash(:info, "Logged out successfully.")
|> UserAuth.log_out_user()
end
end
+235
View File
@@ -0,0 +1,235 @@
defmodule BulwarkWeb.AdminLive.Users do
@moduledoc """
Admin-only user management at `/admin/users`.
Lists all users and lets the sole admin create accounts (with a generated
temporary password shown once), reset a user's password, and change a user's
role. Registration is closed: accounts only exist by admin action.
"""
use BulwarkWeb, :live_view
alias Bulwark.Accounts
alias Bulwark.Accounts.User
@impl true
def mount(_params, _session, socket) do
socket =
socket
|> assign(:page_title, "Users")
|> assign(:active_section, :admin)
|> assign(:new_user_form, to_form(Accounts.change_admin_user(), as: :user))
|> assign(:generated_credential, nil)
|> load_users()
{:ok, socket}
end
defp load_users(socket), do: assign(socket, :users, Accounts.list_users())
@impl true
def handle_event("validate", %{"user" => params}, socket) do
form =
%User{}
|> Accounts.change_admin_user(params)
|> Map.put(:action, :validate)
|> to_form(as: :user)
{:noreply, assign(socket, :new_user_form, form)}
end
def handle_event("create-user", %{"user" => params}, socket) do
password = generate_password()
attrs = Map.put(params, "password", password)
case Accounts.create_user_as_admin(attrs) do
{:ok, user} ->
{:noreply,
socket
|> assign(:new_user_form, to_form(Accounts.change_admin_user(), as: :user))
|> assign(:generated_credential, %{email: user.email, password: password})
|> put_flash(:info, "User #{user.email} created")
|> load_users()}
{:error, changeset} ->
{:noreply, assign(socket, :new_user_form, to_form(changeset, as: :user))}
end
end
def handle_event("reset-password", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
password = generate_password()
case Accounts.admin_reset_user_password(user, %{password: password}) do
{:ok, {user, _expired}} ->
{:noreply,
socket
|> assign(:generated_credential, %{email: user.email, password: password})
|> put_flash(:info, "Password reset for #{user.email}")
|> load_users()}
{:error, _changeset} ->
{:noreply, put_flash(socket, :error, "Failed to reset password")}
end
end
def handle_event("set-role", %{"user_id" => id, "role" => role}, socket) do
current_id = socket.assigns.current_scope.user.id
if to_string(current_id) == id and role != "admin" do
{:noreply,
socket
|> put_flash(:error, "You can't remove your own admin role")
|> load_users()}
else
user = Accounts.get_user!(id)
case Accounts.update_user_role(user, %{role: role}) do
{:ok, _user} -> {:noreply, load_users(socket)}
{:error, _changeset} -> {:noreply, put_flash(socket, :error, "Failed to update role")}
end
end
end
def handle_event("dismiss-credential", _params, socket) do
{:noreply, assign(socket, :generated_credential, nil)}
end
# 18 random bytes -> 24-char URL-safe password. Exceeds the 12-char minimum.
defp generate_password do
:crypto.strong_rand_bytes(18) |> Base.url_encode64(padding: false)
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-5xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Users</h1>
<p class="mt-1 text-sm text-zinc-400">
Create and manage accounts. Registration is admin-only.
</p>
</header>
<div
:if={@generated_credential}
class="rounded-md border border-sky-500/40 bg-sky-500/10 px-4 py-3"
>
<div class="flex items-start justify-between gap-3">
<div class="space-y-1">
<p class="text-sm font-medium text-sky-200">
Temporary password for {@generated_credential.email}
</p>
<p class="text-xs text-sky-300/80">
Copy it now — it won't be shown again. The user must change it on first login.
</p>
<p class="mt-2 font-mono text-sm text-zinc-100 select-all">
{@generated_credential.password}
</p>
</div>
<button
type="button"
phx-click="dismiss-credential"
class="rounded p-1 text-sky-300 hover:bg-sky-500/20"
aria-label="Dismiss credential"
>
<.icon name="hero-x-mark" class="size-4" />
</button>
</div>
</div>
<section class="rounded-md border border-zinc-800 bg-zinc-900">
<div class="card-header border-b border-zinc-800 px-5 py-3">
<h2 class="text-sm font-semibold text-zinc-100">Create User</h2>
</div>
<.form
for={@new_user_form}
id="new-user-form"
phx-change="validate"
phx-submit="create-user"
class="flex flex-wrap items-end gap-3 px-5 py-4"
>
<div class="min-w-64 flex-1">
<.input field={@new_user_form[:email]} type="email" label="Email" required />
</div>
<div class="w-40">
<.input
field={@new_user_form[:role]}
type="select"
label="Role"
options={Enum.map(User.roles(), &{String.capitalize(&1), &1})}
/>
</div>
<.button variant="primary" phx-disable-with="Creating...">
Create
</.button>
</.form>
</section>
<section class="overflow-x-auto rounded-md border border-zinc-800">
<table class="data-table">
<thead>
<tr>
<th>Email</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th></th>
</tr>
</thead>
<tbody>
<tr :for={user <- @users} id={"user-#{user.id}"}>
<td class="font-mono text-xs text-zinc-200">{user.email}</td>
<td>
<form phx-change="set-role" class="inline">
<input type="hidden" name="user_id" value={user.id} />
<select
name="role"
class="rounded border border-zinc-700 bg-zinc-800 px-1.5 py-0.5 font-mono text-xxs text-zinc-300"
>
<option
:for={role <- User.roles()}
value={role}
selected={role == user.role}
>
{role}
</option>
</select>
</form>
</td>
<td>
<span
:if={user.must_change_password}
class="rounded border border-yellow-500/40 bg-yellow-500/10 px-1.5 py-0.5 font-mono text-xxs text-yellow-300"
>
Temp password
</span>
<span
:if={!user.must_change_password}
class="rounded border border-green-500/40 bg-green-500/10 px-1.5 py-0.5 font-mono text-xxs text-green-300"
>
Active
</span>
</td>
<td class="font-mono text-xxs text-zinc-500 tabular-nums">
{Calendar.strftime(user.inserted_at, "%Y-%m-%d")}
</td>
<td class="text-right">
<button
phx-click="reset-password"
phx-value-id={user.id}
data-confirm={"Reset password for #{user.email}? This expires their sessions."}
class="rounded px-1.5 py-0.5 text-xxs font-mono text-zinc-500 hover:bg-zinc-800 hover:text-zinc-200"
>
Reset password
</button>
</td>
</tr>
</tbody>
</table>
</section>
</div>
</Layouts.app>
"""
end
end
+2 -2
View File
@@ -24,7 +24,7 @@ defmodule BulwarkWeb.AssetLive.Index do
def handle_params(params, _uri, socket) do
flop_params = build_flop_params(params)
case Security.list_assets(flop_params) do
case Security.list_assets(socket.assigns.current_scope, flop_params) do
{:ok, {assets, meta}} ->
{:noreply,
socket
@@ -85,7 +85,7 @@ defmodule BulwarkWeb.AssetLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-7xl space-y-4">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Assets</h1>
+9 -3
View File
@@ -16,9 +16,10 @@ defmodule BulwarkWeb.AssetLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _uri, socket) do
asset = Security.get_asset!(id)
scope = socket.assigns.current_scope
asset = Security.get_asset!(scope, id)
case Security.list_findings_for_asset(asset.id, params) do
case Security.list_findings_for_asset(scope, asset.id, params) do
{:ok, {findings, meta}} ->
{:noreply,
socket
@@ -40,7 +41,12 @@ defmodule BulwarkWeb.AssetLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section} page_title={@page_title}>
<Layouts.app
flash={@flash}
current_scope={@current_scope}
active_section={@active_section}
page_title={@page_title}
>
<div class="mx-auto max-w-7xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">{@asset.identifier}</h1>
+12 -7
View File
@@ -12,9 +12,12 @@ defmodule BulwarkWeb.DashboardLive do
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
uid = scope.user.id
if connected?(socket) do
Phoenix.PubSub.subscribe(Bulwark.PubSub, "scans")
Phoenix.PubSub.subscribe(Bulwark.PubSub, "findings")
Phoenix.PubSub.subscribe(Bulwark.PubSub, Security.scans_topic(uid))
Phoenix.PubSub.subscribe(Bulwark.PubSub, Security.findings_topic(uid))
end
socket =
@@ -22,8 +25,8 @@ defmodule BulwarkWeb.DashboardLive do
|> assign(:page_title, "Dashboard")
|> assign(:active_section, :dashboard)
|> assign_stats()
|> assign(:scans, Security.recent_scans(5))
|> assign(:recent_vulnerabilities, Security.recent_vulnerabilities(5))
|> assign(:scans, Security.recent_scans(scope, 5))
|> assign(:recent_vulnerabilities, Security.recent_vulnerabilities(scope, 5))
{:ok, socket}
end
@@ -47,15 +50,17 @@ defmodule BulwarkWeb.DashboardLive do
end
defp assign_stats(socket) do
scope = socket.assigns.current_scope
socket
|> assign(:severity_counts, Security.vulnerability_counts_by_severity())
|> assign(:open_findings, Security.total_open_findings())
|> assign(:severity_counts, Security.vulnerability_counts_by_severity(scope))
|> assign(:open_findings, Security.total_open_findings(scope))
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-7xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Security Dashboard</h1>
+5 -4
View File
@@ -13,7 +13,8 @@ defmodule BulwarkWeb.FindingLive.Index do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Bulwark.PubSub, "findings")
uid = socket.assigns.current_scope.user.id
Phoenix.PubSub.subscribe(Bulwark.PubSub, Security.findings_topic(uid))
end
socket =
@@ -28,7 +29,7 @@ defmodule BulwarkWeb.FindingLive.Index do
def handle_params(params, _uri, socket) do
flop_params = build_flop_params(params)
case Security.list_findings(flop_params) do
case Security.list_findings(socket.assigns.current_scope, flop_params) do
{:ok, {findings, meta}} ->
{:noreply,
socket
@@ -57,7 +58,7 @@ defmodule BulwarkWeb.FindingLive.Index do
end
def handle_event("update-status", %{"id" => id, "status" => status}, socket) do
finding = Security.get_finding!(id)
finding = Security.get_finding!(socket.assigns.current_scope, id)
case Security.update_finding_status(finding, status) do
{:ok, _updated} ->
@@ -92,7 +93,7 @@ defmodule BulwarkWeb.FindingLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-7xl space-y-4">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Findings</h1>
+6 -5
View File
@@ -22,7 +22,8 @@ defmodule BulwarkWeb.ScanLive.Index do
@impl true
def mount(_params, _session, socket) do
if connected?(socket) do
Phoenix.PubSub.subscribe(Bulwark.PubSub, "scans")
uid = socket.assigns.current_scope.user.id
Phoenix.PubSub.subscribe(Bulwark.PubSub, Security.scans_topic(uid))
end
socket =
@@ -48,7 +49,7 @@ defmodule BulwarkWeb.ScanLive.Index do
@impl true
def handle_params(params, _uri, socket) do
case Security.list_scans(params) do
case Security.list_scans(socket.assigns.current_scope, params) do
{:ok, {scans, meta}} ->
{:noreply, assign(socket, scans: scans, meta: meta)}
@@ -78,7 +79,7 @@ defmodule BulwarkWeb.ScanLive.Index do
end
def handle_event("cancel-scan", %{"id" => id}, socket) do
scan = Security.get_scan!(id)
scan = Security.get_scan!(socket.assigns.current_scope, id)
case Security.cancel_scan(scan) do
{:ok, _} ->
@@ -116,7 +117,7 @@ defmodule BulwarkWeb.ScanLive.Index do
{tool, format} = Detector.detect(data)
{:ok, scan} =
Security.create_scan(%{
Security.create_scan(socket.assigns.current_scope, %{
source_tool: tool,
source_format: format,
file_name: file_name,
@@ -149,7 +150,7 @@ defmodule BulwarkWeb.ScanLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-7xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Scans</h1>
+10 -4
View File
@@ -17,13 +17,14 @@ defmodule BulwarkWeb.ScanLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _uri, socket) do
scan = Security.get_scan!(id)
scope = socket.assigns.current_scope
scan = Security.get_scan!(scope, id)
if connected?(socket) do
Phoenix.PubSub.subscribe(Bulwark.PubSub, "scan:#{id}")
Phoenix.PubSub.subscribe(Bulwark.PubSub, Security.scan_topic(scope.user.id, id))
end
case Security.list_findings_for_scan(scan.id, params) do
case Security.list_findings_for_scan(scope, scan.id, params) do
{:ok, {findings, meta}} ->
{:noreply,
socket
@@ -53,7 +54,12 @@ defmodule BulwarkWeb.ScanLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section} page_title={@page_title}>
<Layouts.app
flash={@flash}
current_scope={@current_scope}
active_section={@active_section}
page_title={@page_title}
>
<div class="mx-auto max-w-7xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">
+92 -6
View File
@@ -1,20 +1,28 @@
defmodule BulwarkWeb.SettingsLive do
@moduledoc """
LiveView for application display settings at `/settings`.
LiveView for account and display settings at `/settings`.
Settings are stored client-side in localStorage via the `Settings` JS hook.
The hook pushes current values to the server on mount so toggles render
correctly, and handles persistence when the user changes a setting.
Combines self-service password change (also where a user with a forced
password change clears that flag) with display preferences. Display settings
are stored client-side in localStorage via the `Settings` JS hook, which
pushes current values to the server on mount so toggles render correctly.
"""
use BulwarkWeb, :live_view
alias Bulwark.Accounts
@impl true
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
password_changeset = Accounts.change_user_password(user, %{}, hash_password: false)
socket =
socket
|> assign(:page_title, "Settings")
|> assign(:active_section, :settings)
|> assign(:accents, true)
|> assign(:password_form, to_form(password_changeset))
|> assign(:trigger_submit, false)
{:ok, socket}
end
@@ -29,18 +37,96 @@ defmodule BulwarkWeb.SettingsLive do
{:noreply, push_event(socket, "setting-changed", %{key: "accents", value: new_value})}
end
def handle_event("validate_password", %{"user" => user_params}, socket) do
password_form =
socket.assigns.current_scope.user
|> Accounts.change_user_password(user_params, hash_password: false)
|> Map.put(:action, :validate)
|> to_form()
{:noreply, assign(socket, password_form: password_form)}
end
def handle_event("update_password", %{"user" => user_params}, socket) do
user = socket.assigns.current_scope.user
case Accounts.change_user_password(user, user_params, hash_password: false) do
%{valid?: true} = changeset ->
{:noreply, assign(socket, trigger_submit: true, password_form: to_form(changeset))}
changeset ->
{:noreply, assign(socket, password_form: to_form(changeset, action: :insert))}
end
end
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div id="settings" phx-hook="Settings" class="mx-auto max-w-3xl space-y-6">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Settings</h1>
<p class="mt-1 text-sm text-zinc-400">
Configure display preferences for the dashboard
Manage your account and display preferences
</p>
</header>
<section class="rounded-md border border-zinc-800 bg-zinc-900">
<div class="card-header border-b border-zinc-800 px-5 py-3">
<h2 class="text-sm font-semibold text-zinc-100">Account</h2>
</div>
<div class="space-y-4 px-5 py-4">
<p class="text-xs text-zinc-500">
Signed in as <span class="font-mono text-zinc-300">{@current_scope.user.email}</span>
</p>
<div
:if={@current_scope.user.must_change_password}
class="rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-sm text-yellow-300"
>
You're using a temporary password. Set a new one to continue.
</div>
<.form
for={@password_form}
id="password_form"
action={~p"/users/update-password"}
method="post"
phx-change="validate_password"
phx-submit="update_password"
phx-trigger-action={@trigger_submit}
class="max-w-sm space-y-4"
>
<input
name={@password_form[:email].name}
type="hidden"
id="hidden_user_email"
autocomplete="username"
value={@current_scope.user.email}
/>
<.input
field={@password_form[:password]}
type="password"
label="New password"
autocomplete="new-password"
spellcheck="false"
required
/>
<.input
field={@password_form[:password_confirmation]}
type="password"
label="Confirm new password"
autocomplete="new-password"
spellcheck="false"
/>
<.button variant="primary" phx-disable-with="Saving...">
Save password
</.button>
</.form>
</div>
</section>
<section class="rounded-md border border-zinc-800 bg-zinc-900">
<div class="card-header border-b border-zinc-800 px-5 py-3">
<h2 class="text-sm font-semibold text-zinc-100">Display</h2>
+63
View File
@@ -0,0 +1,63 @@
defmodule BulwarkWeb.UserLive.Login do
@moduledoc """
Password-only login. Registration is closed (admin-managed accounts only),
so there is no sign-up link and no magic-link flow.
"""
use BulwarkWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.auth flash={@flash}>
<.header>
Sign in to Bulwark
<:subtitle>Authorized analysts only.</:subtitle>
</.header>
<.form
:let={f}
for={@form}
id="login_form"
action={~p"/users/log-in"}
phx-submit="submit"
phx-trigger-action={@trigger_submit}
class="mt-6 space-y-4"
>
<.input
field={f[:email]}
type="email"
label="Email"
autocomplete="username"
spellcheck="false"
required
phx-mounted={JS.focus()}
/>
<.input
field={f[:password]}
type="password"
label="Password"
autocomplete="current-password"
spellcheck="false"
required
/>
<.input field={f[:remember_me]} type="checkbox" label="Keep me signed in" />
<.button variant="primary" class="w-full" phx-disable-with="Signing in...">
Sign in
</.button>
</.form>
</Layouts.auth>
"""
end
@impl true
def mount(_params, _session, socket) do
email = Phoenix.Flash.get(socket.assigns.flash, :email)
form = to_form(%{"email" => email}, as: "user")
{:ok, assign(socket, form: form, trigger_submit: false), temporary_assigns: [form: form]}
end
@impl true
def handle_event("submit", _params, socket) do
{:noreply, assign(socket, :trigger_submit, true)}
end
end
@@ -24,7 +24,7 @@ defmodule BulwarkWeb.VulnerabilityLive.Index do
def handle_params(params, _uri, socket) do
flop_params = build_flop_params(params)
case Security.list_vulnerabilities(flop_params) do
case Security.list_vulnerabilities(socket.assigns.current_scope, flop_params) do
{:ok, {vulns, meta}} ->
{:noreply,
socket
@@ -85,7 +85,7 @@ defmodule BulwarkWeb.VulnerabilityLive.Index do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section}>
<Layouts.app flash={@flash} current_scope={@current_scope} active_section={@active_section}>
<div class="mx-auto max-w-7xl space-y-4">
<header>
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">Vulnerabilities</h1>
@@ -16,9 +16,10 @@ defmodule BulwarkWeb.VulnerabilityLive.Show do
@impl true
def handle_params(%{"id" => id} = params, _uri, socket) do
vuln = Security.get_vulnerability!(id)
scope = socket.assigns.current_scope
vuln = Security.get_vulnerability!(scope, id)
case Security.list_findings_for_vulnerability(vuln.id, params) do
case Security.list_findings_for_vulnerability(scope, vuln.id, params) do
{:ok, {findings, meta}} ->
{:noreply,
socket
@@ -40,7 +41,12 @@ defmodule BulwarkWeb.VulnerabilityLive.Show do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} active_section={@active_section} page_title={@page_title}>
<Layouts.app
flash={@flash}
current_scope={@current_scope}
active_section={@active_section}
page_title={@page_title}
>
<div class="mx-auto max-w-7xl space-y-6">
<header class="flex items-center gap-3">
<h1 class="text-2xl font-bold tracking-tight text-zinc-100">
+55 -12
View File
@@ -1,6 +1,8 @@
defmodule BulwarkWeb.Router do
use BulwarkWeb, :router
import BulwarkWeb.UserAuth
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
@@ -8,6 +10,7 @@ defmodule BulwarkWeb.Router do
plug :put_root_layout, html: {BulwarkWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :fetch_current_scope_for_user
end
pipeline :api do
@@ -22,17 +25,44 @@ defmodule BulwarkWeb.Router do
end
scope "/", BulwarkWeb do
pipe_through :browser
pipe_through [:browser, :require_authenticated_user]
live "/", DashboardLive
live "/scans", ScanLive.Index
live "/scans/:id", ScanLive.Show
live "/vulnerabilities", VulnerabilityLive.Index
live "/vulnerabilities/:id", VulnerabilityLive.Show
live "/findings", FindingLive.Index
live "/assets", AssetLive.Index
live "/assets/:id", AssetLive.Show
live "/settings", SettingsLive
live_session :require_authenticated_user,
on_mount: [
{BulwarkWeb.UserAuth, :require_authenticated},
{BulwarkWeb.UserAuth, :require_password_changed}
] do
live "/", DashboardLive
live "/scans", ScanLive.Index
live "/scans/:id", ScanLive.Show
live "/vulnerabilities", VulnerabilityLive.Index
live "/vulnerabilities/:id", VulnerabilityLive.Show
live "/findings", FindingLive.Index
live "/assets", AssetLive.Index
live "/assets/:id", AssetLive.Show
end
# Settings (account + display): reachable even when a forced password change
# is pending (it is where the user clears that flag), so it skips the
# :require_password_changed guard.
live_session :user_account,
on_mount: [{BulwarkWeb.UserAuth, :require_authenticated}] do
live "/settings", SettingsLive
end
post "/users/update-password", UserSessionController, :update_password
end
scope "/", BulwarkWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_admin,
on_mount: [
{BulwarkWeb.UserAuth, :require_authenticated},
{BulwarkWeb.UserAuth, :require_admin}
] do
live "/admin/users", AdminLive.Users, :index
end
end
# Other scopes may use custom stacks.
@@ -40,7 +70,7 @@ defmodule BulwarkWeb.Router do
# pipe_through :api
# end
# Enable LiveDashboard and Swoosh mailbox preview in development
# Enable LiveDashboard in development
if Application.compile_env(:bulwark, :dev_routes) do
# If you want to use the LiveDashboard in production, you should put
# it behind authentication and allow only admins to access it.
@@ -53,7 +83,20 @@ defmodule BulwarkWeb.Router do
pipe_through :browser
live_dashboard "/dashboard", metrics: BulwarkWeb.Telemetry
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", BulwarkWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{BulwarkWeb.UserAuth, :mount_current_scope}] do
live "/users/log-in", UserLive.Login, :new
end
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end
+313
View File
@@ -0,0 +1,313 @@
defmodule BulwarkWeb.UserAuth do
use BulwarkWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
alias Bulwark.Accounts
alias Bulwark.Accounts.Scope
# Make the remember me cookie valid for 14 days. This should match
# the session validity setting in UserToken.
@max_cookie_age_in_days 14
@remember_me_cookie "_bulwark_web_user_remember_me"
@remember_me_options [
sign: true,
max_age: @max_cookie_age_in_days * 24 * 60 * 60,
same_site: "Lax"
]
# How old the session token should be before a new one is issued. When a request is made
# with a session token older than this value, then a new session token will be created
# and the session and remember-me cookies (if set) will be updated with the new token.
# Lowering this value will result in more tokens being created by active users. Increasing
# it will result in less time before a session token expires for a user to get issued a new
# token. This can be set to a value greater than `@max_cookie_age_in_days` to disable
# the reissuing of tokens completely.
@session_reissue_age_in_days 7
@doc """
Logs the user in.
Redirects to the session's `:user_return_to` path
or falls back to the `signed_in_path/1`.
"""
def log_in_user(conn, user, params \\ %{}) do
user_return_to = get_session(conn, :user_return_to)
conn
|> create_or_extend_session(user, params)
|> redirect(to: user_return_to || signed_in_path(conn))
end
@doc """
Logs the user out.
It clears all session data for safety. See renew_session.
"""
def log_out_user(conn) do
user_token = get_session(conn, :user_token)
user_token && Accounts.delete_user_session_token(user_token)
if live_socket_id = get_session(conn, :live_socket_id) do
BulwarkWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{})
end
conn
|> renew_session(nil)
|> delete_resp_cookie(@remember_me_cookie, @remember_me_options)
|> redirect(to: ~p"/")
end
@doc """
Authenticates the user by looking into the session and remember me token.
Will reissue the session token if it is older than the configured age.
"""
def fetch_current_scope_for_user(conn, _opts) do
with {token, conn} <- ensure_user_token(conn),
{user, token_inserted_at} <- Accounts.get_user_by_session_token(token) do
conn
|> assign(:current_scope, Scope.for_user(user))
|> maybe_reissue_user_session_token(user, token_inserted_at)
else
nil -> assign(conn, :current_scope, Scope.for_user(nil))
end
end
defp ensure_user_token(conn) do
if token = get_session(conn, :user_token) do
{token, conn}
else
conn = fetch_cookies(conn, signed: [@remember_me_cookie])
if token = conn.cookies[@remember_me_cookie] do
{token, conn |> put_token_in_session(token) |> put_session(:user_remember_me, true)}
else
nil
end
end
end
# Reissue the session token if it is older than the configured reissue age.
defp maybe_reissue_user_session_token(conn, user, token_inserted_at) do
token_age = DateTime.diff(DateTime.utc_now(:second), token_inserted_at, :day)
if token_age >= @session_reissue_age_in_days do
create_or_extend_session(conn, user, %{})
else
conn
end
end
# This function is the one responsible for creating session tokens
# and storing them safely in the session and cookies. It may be called
# either when logging in, during sudo mode, or to renew a session which
# will soon expire.
#
# When the session is created, rather than extended, the renew_session
# function will clear the session to avoid fixation attacks. See the
# renew_session function to customize this behaviour.
defp create_or_extend_session(conn, user, params) do
token = Accounts.generate_user_session_token(user)
remember_me = get_session(conn, :user_remember_me)
conn
|> renew_session(user)
|> put_token_in_session(token)
|> maybe_write_remember_me_cookie(token, params, remember_me)
end
# Do not renew session if the user is already logged in
# to prevent CSRF errors or data being lost in tabs that are still open
defp renew_session(conn, user) when conn.assigns.current_scope.user.id == user.id do
conn
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing, for example:
#
# defp renew_session(conn, _user) do
# delete_csrf_token()
# preferred_locale = get_session(conn, :preferred_locale)
#
# conn
# |> configure_session(renew: true)
# |> clear_session()
# |> put_session(:preferred_locale, preferred_locale)
# end
#
defp renew_session(conn, _user) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
defp maybe_write_remember_me_cookie(conn, token, %{"remember_me" => "true"}, _),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, token, _params, true),
do: write_remember_me_cookie(conn, token)
defp maybe_write_remember_me_cookie(conn, _token, _params, _), do: conn
defp write_remember_me_cookie(conn, token) do
conn
|> put_session(:user_remember_me, true)
|> put_resp_cookie(@remember_me_cookie, token, @remember_me_options)
end
defp put_token_in_session(conn, token) do
conn
|> put_session(:user_token, token)
|> put_session(:live_socket_id, user_session_topic(token))
end
@doc """
Disconnects existing sockets for the given tokens.
"""
def disconnect_sessions(tokens) do
Enum.each(tokens, fn %{token: token} ->
BulwarkWeb.Endpoint.broadcast(user_session_topic(token), "disconnect", %{})
end)
end
defp user_session_topic(token), do: "users_sessions:#{Base.url_encode64(token)}"
@doc """
Handles mounting and authenticating the current_scope in LiveViews.
## `on_mount` arguments
* `:mount_current_scope` - Assigns current_scope
to socket assigns based on user_token, or nil if
there's no user_token or no matching user.
* `:require_authenticated` - Authenticates the user from the session,
and assigns the current_scope to socket assigns based
on user_token.
Redirects to login page if there's no logged user.
## Examples
Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate
the `current_scope`:
defmodule BulwarkWeb.PageLive do
use BulwarkWeb, :live_view
on_mount {BulwarkWeb.UserAuth, :mount_current_scope}
...
end
Or use the `live_session` of your router to invoke the on_mount callback:
live_session :authenticated, on_mount: [{BulwarkWeb.UserAuth, :require_authenticated}] do
live "/profile", ProfileLive, :index
end
"""
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
def on_mount(:require_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
def on_mount(:require_admin, _params, session, socket) do
socket = mount_current_scope(socket, session)
if Bulwark.Accounts.Scope.admin?(socket.assigns.current_scope) do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You do not have access to this page.")
|> Phoenix.LiveView.redirect(to: ~p"/")
{:halt, socket}
end
end
def on_mount(:require_password_changed, _params, session, socket) do
socket = mount_current_scope(socket, session)
user = socket.assigns.current_scope && socket.assigns.current_scope.user
if user && user.must_change_password do
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must change your password before continuing.")
|> Phoenix.LiveView.redirect(to: ~p"/settings")
{:halt, socket}
else
{:cont, socket}
end
end
def on_mount(:require_sudo_mode, _params, session, socket) do
socket = mount_current_scope(socket, session)
if Accounts.sudo_mode?(socket.assigns.current_scope.user, -10) do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must re-authenticate to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
{user, _} =
if user_token = session["user_token"] do
Accounts.get_user_by_session_token(user_token)
end || {nil, nil}
Scope.for_user(user)
end)
end
@doc "Returns the path to redirect to after log in."
def signed_in_path(_conn), do: ~p"/"
@doc """
Plug for routes that require the user to be authenticated.
"""
def require_authenticated_user(conn, _opts) do
if conn.assigns.current_scope && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> maybe_store_return_to()
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
defp maybe_store_return_to(%{method: "GET"} = conn) do
put_session(conn, :user_return_to, current_path(conn))
end
defp maybe_store_return_to(conn), do: conn
end
+1 -1
View File
@@ -40,6 +40,7 @@ defmodule Bulwark.MixProject do
# Type `mix help deps` for examples and options.
defp deps do
[
{:argon2_elixir, "~> 4.0"},
{:phoenix, "~> 1.8.1"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
@@ -58,7 +59,6 @@ defmodule Bulwark.MixProject do
app: false,
compile: false,
depth: 1},
{:swoosh, "~> 1.16"},
{:req, "~> 0.5"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
+2 -3
View File
@@ -1,7 +1,9 @@
%{
"argon2_elixir": {:hex, :argon2_elixir, "4.1.3", "4f28318286f89453364d7fbb53e03d4563fd7ed2438a60237eba5e426e97785f", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "7c295b8d8e0eaf6f43641698f962526cdf87c6feb7d14bd21e599271b510608c"},
"bandit": {:hex, :bandit, "1.11.1", "1eb33123cc3c17ae0c3447874eb83399ee530f960c39711ed240342fbd4865fa", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d4401016df9abbc6dcd325c0b78b2b193e7c7c96bb68f31e576112be025d84a5"},
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
@@ -18,7 +20,6 @@
"gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"},
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
@@ -39,13 +40,11 @@
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"swoosh": {:hex, :swoosh, "1.25.2", "cd3e53b0391439395492e5dce8c22288733f22603e21136162d03cd153669be9", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0aecf65b2845f13f4d440e0945715432bbde2d815e2302adf7df549cd9bdafed"},
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}
@@ -0,0 +1,32 @@
defmodule Bulwark.Repo.Migrations.CreateUsersAuthTables do
use Ecto.Migration
def change do
execute "CREATE EXTENSION IF NOT EXISTS citext", ""
create table(:users) do
add :email, :citext, null: false
add :hashed_password, :string
add :role, :string, null: false, default: "user"
add :must_change_password, :boolean, null: false, default: false
add :confirmed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
create unique_index(:users, [:email])
create table(:users_tokens) do
add :user_id, references(:users, on_delete: :delete_all), null: false
add :token, :binary, null: false
add :context, :string, null: false
add :sent_to, :string
add :authenticated_at, :utc_datetime
timestamps(type: :utc_datetime, updated_at: false)
end
create index(:users_tokens, [:user_id])
create unique_index(:users_tokens, [:context, :token])
end
end
@@ -0,0 +1,30 @@
defmodule Bulwark.Repo.Migrations.AddUserTenancy do
use Ecto.Migration
# Per-user data tenancy. Scans, assets, and findings belong to the user who
# uploaded the originating scan. Vulnerabilities stay GLOBAL (shared CVE
# catalog) and intentionally get no user_id. Vuln/asset visibility for a user
# is derived THROUGH findings.
def change do
alter table(:scans) do
add :user_id, references(:users, on_delete: :delete_all), null: false
end
create index(:scans, [:user_id])
alter table(:assets) do
add :user_id, references(:users, on_delete: :delete_all), null: false
end
# Assets are upserted per-user now: the same image identifier owned by two
# users is two rows. Replace the global uniqueness with a per-user one.
drop unique_index(:assets, [:type, :identifier])
create unique_index(:assets, [:user_id, :type, :identifier])
alter table(:findings) do
add :user_id, references(:users, on_delete: :delete_all), null: false
end
create index(:findings, [:user_id])
end
end
+37 -6
View File
@@ -2,10 +2,41 @@
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
# Seeds the sole admin account for LOCAL DEV. Registration is closed
# (admin-managed), so a bootstrap admin must exist before anyone can log in.
# This script is idempotent: if an admin already exists, it does nothing.
#
# Bulwark.Repo.insert!(%Bulwark.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
# The dev admin uses fixed, well-known credentials and is forced to change the
# password on first login (must_change_password is set). For the cluster, use
# the `seed-admin` release task instead, which generates a random temp password
# — see DEPLOYMENT.md §3a.
alias Bulwark.Accounts
alias Bulwark.Accounts.User
alias Bulwark.Repo
import Ecto.Query
admin_email = System.get_env("ADMIN_EMAIL", "cfahlin@bulwark.local")
# Min length is 12 (see User.validate_password), so "password" alone is too
# short — pad it to a memorable 12-char dev default.
admin_password = System.get_env("ADMIN_PASSWORD", "password1234")
if Repo.exists?(from u in User, where: u.role == "admin") do
IO.puts("[seeds] Admin already exists — skipping.")
else
case Accounts.create_user_as_admin(%{
email: admin_email,
role: "admin",
password: admin_password
}) do
{:ok, user} ->
IO.puts("[seeds] Created admin #{user.email} (must change password on first login).")
IO.puts("[seeds] Temporary password: #{admin_password}")
{:error, changeset} ->
IO.puts("[seeds] Failed to create admin:")
IO.inspect(changeset.errors)
exit({:shutdown, 1})
end
end
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 71 48" fill="currentColor" aria-hidden="true">
<path
d="m26.371 33.477-.552-.1c-3.92-.729-6.397-3.1-7.57-6.829-.733-2.324.597-4.035 3.035-4.148 1.995-.092 3.362 1.055 4.57 2.39 1.557 1.72 2.984 3.558 4.514 5.305 2.202 2.515 4.797 4.134 8.347 3.634 3.183-.448 5.958-1.725 8.371-3.828.363-.316.761-.592 1.144-.886l-.241-.284c-2.027.63-4.093.841-6.205.735-3.195-.16-6.24-.828-8.964-2.582-2.486-1.601-4.319-3.746-5.19-6.611-.704-2.315.736-3.934 3.135-3.6.948.133 1.746.56 2.463 1.165.583.493 1.143 1.015 1.738 1.493 2.8 2.25 6.712 2.375 10.265-.068-5.842-.026-9.817-3.24-13.308-7.313-1.366-1.594-2.7-3.216-4.095-4.785-2.698-3.036-5.692-5.71-9.79-6.623C12.8-.623 7.745.14 2.893 2.361 1.926 2.804.997 3.319 0 4.149c.494 0 .763.006 1.032 0 2.446-.064 4.28 1.023 5.602 3.024.962 1.457 1.415 3.104 1.761 4.798.513 2.515.247 5.078.544 7.605.761 6.494 4.08 11.026 10.26 13.346 2.267.852 4.591 1.135 7.172.555ZM10.751 3.852c-.976.246-1.756-.148-2.56-.962 1.377-.343 2.592-.476 3.897-.528-.107.848-.607 1.306-1.336 1.49Zm32.002 37.924c-.085-.626-.62-.901-1.04-1.228-1.857-1.446-4.03-1.958-6.333-2-1.375-.026-2.735-.128-4.031-.61-.595-.22-1.26-.505-1.244-1.272.015-.78.693-1 1.31-1.184.505-.15 1.026-.247 1.6-.382-1.46-.936-2.886-1.065-4.787-.3-2.993 1.202-5.943 1.06-8.926-.017-1.684-.608-3.179-1.563-4.735-2.408l-.077.057c1.29 2.115 3.034 3.817 5.004 5.271 3.793 2.8 7.936 4.471 12.784 3.73A66.714 66.714 0 0 1 37 40.877c1.98-.16 3.866.398 5.753.899Zm-9.14-30.345c-.105-.076-.206-.266-.42-.069 1.745 2.36 3.985 4.098 6.683 5.193 4.354 1.767 8.773 2.07 13.293.51 3.51-1.21 6.033-.028 7.343 3.38.19-3.955-2.137-6.837-5.843-7.401-2.084-.318-4.01.373-5.962.94-5.434 1.575-10.485.798-15.094-2.553Zm27.085 15.425c.708.059 1.416.123 2.124.185-1.6-1.405-3.55-1.517-5.523-1.404-3.003.17-5.167 1.903-7.14 3.972-1.739 1.824-3.31 3.87-5.903 4.604.043.078.054.117.066.117.35.005.699.021 1.047.005 3.768-.17 7.317-.965 10.14-3.7.89-.86 1.685-1.817 2.544-2.71.716-.746 1.584-1.159 2.645-1.07Zm-8.753-4.67c-2.812.246-5.254 1.409-7.548 2.943-1.766 1.18-3.654 1.738-5.776 1.37-.374-.066-.75-.114-1.124-.17l-.013.156c.135.07.265.151.405.207.354.14.702.308 1.07.395 4.083.971 7.992.474 11.516-1.803 2.221-1.435 4.521-1.707 7.013-1.336.252.038.503.083.756.107.234.022.479.255.795.003-2.179-1.574-4.526-2.096-7.094-1.872Zm-10.049-9.544c1.475.051 2.943-.142 4.486-1.059-.452.04-.643.04-.827.076-2.126.424-4.033-.04-5.733-1.383-.623-.493-1.257-.974-1.889-1.457-2.503-1.914-5.374-2.555-8.514-2.5.05.154.054.26.108.315 3.417 3.455 7.371 5.836 12.369 6.008Zm24.727 17.731c-2.114-2.097-4.952-2.367-7.578-.537 1.738.078 3.043.632 4.101 1.728a13 13 0 0 0 1.182 1.106c1.6 1.29 4.311 1.352 5.896.155-1.861-.726-1.861-.726-3.601-2.452Zm-21.058 16.06c-1.858-3.46-4.981-4.24-8.59-4.008a9.667 9.667 0 0 1 2.977 1.39c.84.586 1.547 1.311 2.243 2.055 1.38 1.473 3.534 2.376 4.962 2.07-.656-.412-1.238-.848-1.592-1.507Zl-.006.006-.036-.004.021.018.012.053Za.127.127 0 0 0 .015.043c.005.008.038 0 .058-.002Zl-.008.01.005.026.024.014Z"
fill="#FD4F00"
/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.
@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -eu
cd -P -- "$(dirname -- "$0")"
exec ./bulwark eval Bulwark.Release.seed_admin
+408
View File
@@ -0,0 +1,408 @@
defmodule Bulwark.AccountsTest do
use Bulwark.DataCase
alias Bulwark.Accounts
import Bulwark.AccountsFixtures
alias Bulwark.Accounts.{User, UserToken}
describe "get_user_by_email/1" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email("unknown@example.com")
end
test "returns the user if the email exists" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user_by_email(user.email)
end
end
describe "get_user_by_email_and_password/2" do
test "does not return the user if the email does not exist" do
refute Accounts.get_user_by_email_and_password("unknown@example.com", "hello world!")
end
test "does not return the user if the password is not valid" do
user = user_fixture() |> set_password()
refute Accounts.get_user_by_email_and_password(user.email, "invalid")
end
test "returns the user if the email and password are valid" do
%{id: id} = user = user_fixture() |> set_password()
assert %User{id: ^id} =
Accounts.get_user_by_email_and_password(user.email, valid_user_password())
end
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!(-1)
end
end
test "returns the user with the given id" do
%{id: id} = user = user_fixture()
assert %User{id: ^id} = Accounts.get_user!(user.id)
end
end
describe "list_users/0" do
test "returns all users ordered by email" do
a = user_fixture(%{email: "aaa@example.com"})
c = user_fixture(%{email: "ccc@example.com"})
b = user_fixture(%{email: "bbb@example.com"})
assert Enum.map(Accounts.list_users(), & &1.id) == [a.id, b.id, c.id]
end
test "returns an empty list when there are no users" do
assert Accounts.list_users() == []
end
end
describe "create_user_as_admin/1" do
test "creates a user with the given email and role" do
email = unique_user_email()
{:ok, user} =
Accounts.create_user_as_admin(%{
email: email,
role: "admin",
password: valid_user_password()
})
assert user.email == email
assert user.role == "admin"
end
test "forces a password change on first login" do
{:ok, user} =
Accounts.create_user_as_admin(valid_user_attributes())
assert user.must_change_password
end
test "sets confirmed_at" do
{:ok, user} = Accounts.create_user_as_admin(valid_user_attributes())
assert user.confirmed_at
end
test "hashes the password and clears the virtual field" do
{:ok, user} = Accounts.create_user_as_admin(valid_user_attributes())
assert is_binary(user.hashed_password)
assert is_nil(user.password)
assert User.valid_password?(user, valid_user_password())
end
test "defaults role to user when not given" do
{:ok, user} =
Accounts.create_user_as_admin(%{
email: unique_user_email(),
password: valid_user_password()
})
assert user.role == "user"
end
test "requires email" do
{:error, changeset} =
Accounts.create_user_as_admin(%{role: "user", password: valid_user_password()})
assert %{email: ["can't be blank"]} = errors_on(changeset)
end
test "validates email format" do
{:error, changeset} =
Accounts.create_user_as_admin(%{email: "not valid", password: valid_user_password()})
assert %{email: ["must have the @ sign and no spaces"]} = errors_on(changeset)
end
test "validates email uniqueness" do
%{email: email} = user_fixture()
{:error, changeset} =
Accounts.create_user_as_admin(%{email: email, password: valid_user_password()})
assert "has already been taken" in errors_on(changeset).email
end
test "rejects invalid roles" do
{:error, changeset} =
Accounts.create_user_as_admin(%{
email: unique_user_email(),
role: "superuser",
password: valid_user_password()
})
assert "is invalid" in errors_on(changeset).role
end
test "requires a password" do
{:error, changeset} =
Accounts.create_user_as_admin(%{email: unique_user_email(), role: "user"})
assert %{password: ["can't be blank"]} = errors_on(changeset)
end
test "validates minimum password length" do
{:error, changeset} =
Accounts.create_user_as_admin(%{email: unique_user_email(), password: "short"})
assert "should be at least 12 character(s)" in errors_on(changeset).password
end
end
describe "update_user_role/2" do
test "updates the user's role" do
user = user_fixture()
assert user.role == "user"
{:ok, updated} = Accounts.update_user_role(user, %{role: "admin"})
assert updated.role == "admin"
end
test "rejects an invalid role" do
user = user_fixture()
{:error, changeset} = Accounts.update_user_role(user, %{role: "wheel"})
assert "is invalid" in errors_on(changeset).role
end
test "requires a role" do
user = user_fixture()
{:error, changeset} = Accounts.update_user_role(user, %{role: nil})
assert %{role: ["can't be blank"]} = errors_on(changeset)
end
end
describe "change_admin_user/2" do
test "returns a changeset for the admin create form" do
assert %Ecto.Changeset{} = changeset = Accounts.change_admin_user(%User{})
assert :email in changeset.required
assert :password in changeset.required
end
test "does not hash the password (form validation)" do
changeset =
Accounts.change_admin_user(%User{}, %{
email: unique_user_email(),
role: "user",
password: valid_user_password()
})
assert changeset.valid?
assert get_change(changeset, :password) == valid_user_password()
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "admin_reset_user_password/2" do
setup do
%{user: user_fixture()}
end
test "resets the password to a new value", %{user: user} do
{:ok, {user, _}} =
Accounts.admin_reset_user_password(user, %{password: "brand new password!"})
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "brand new password!")
end
test "forces a password change on next login", %{user: user} do
refute user.must_change_password
{:ok, {user, _}} =
Accounts.admin_reset_user_password(user, %{password: "brand new password!"})
assert user.must_change_password
end
test "expires all existing tokens", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, {_, expired}} =
Accounts.admin_reset_user_password(user, %{password: "brand new password!"})
assert length(expired) == 1
refute Repo.get_by(UserToken, user_id: user.id)
end
test "validates the new password", %{user: user} do
{:error, changeset} = Accounts.admin_reset_user_password(user, %{password: "short"})
assert "should be at least 12 character(s)" in errors_on(changeset).password
end
end
describe "sudo_mode?/2" do
test "validates the authenticated_at time" do
now = DateTime.utc_now()
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.utc_now()})
assert Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -19, :minute)})
refute Accounts.sudo_mode?(%User{authenticated_at: DateTime.add(now, -21, :minute)})
# minute override
refute Accounts.sudo_mode?(
%User{authenticated_at: DateTime.add(now, -11, :minute)},
-10
)
# not authenticated
refute Accounts.sudo_mode?(%User{})
end
end
describe "change_user_password/3" do
test "returns a user changeset" do
assert %Ecto.Changeset{} = changeset = Accounts.change_user_password(%User{})
assert changeset.required == [:password]
end
test "allows fields to be set" do
changeset =
Accounts.change_user_password(
%User{},
%{
"password" => "new valid password"
},
hash_password: false
)
assert changeset.valid?
assert get_change(changeset, :password) == "new valid password"
assert is_nil(get_change(changeset, :hashed_password))
end
end
describe "update_user_password/2" do
setup do
%{user: user_fixture()}
end
test "validates password", %{user: user} do
{:error, changeset} =
Accounts.update_user_password(user, %{
password: "not valid",
password_confirmation: "another"
})
assert %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match password"]
} = errors_on(changeset)
end
test "validates maximum values for password for security", %{user: user} do
too_long = String.duplicate("db", 100)
{:error, changeset} =
Accounts.update_user_password(user, %{password: too_long})
assert "should be at most 72 character(s)" in errors_on(changeset).password
end
test "updates the password", %{user: user} do
{:ok, {user, expired_tokens}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
assert expired_tokens == []
assert is_nil(user.password)
assert Accounts.get_user_by_email_and_password(user.email, "new valid password")
end
test "clears the must_change_password flag" do
user = user_fixture(%{must_change_password: true})
assert user.must_change_password
{:ok, {user, _}} =
Accounts.update_user_password(user, %{password: "new valid password"})
refute user.must_change_password
end
test "deletes all tokens for the given user", %{user: user} do
_ = Accounts.generate_user_session_token(user)
{:ok, {_, _}} =
Accounts.update_user_password(user, %{
password: "new valid password"
})
refute Repo.get_by(UserToken, user_id: user.id)
end
end
describe "generate_user_session_token/1" do
setup do
%{user: user_fixture()}
end
test "generates a token", %{user: user} do
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.context == "session"
assert user_token.authenticated_at != nil
# Creating the same token for another user should fail
assert_raise Ecto.ConstraintError, fn ->
Repo.insert!(%UserToken{
token: user_token.token,
user_id: user_fixture().id,
context: "session"
})
end
end
test "duplicates the authenticated_at of given user in new token", %{user: user} do
user = %{user | authenticated_at: DateTime.add(DateTime.utc_now(:second), -3600)}
token = Accounts.generate_user_session_token(user)
assert user_token = Repo.get_by(UserToken, token: token)
assert user_token.authenticated_at == user.authenticated_at
assert DateTime.compare(user_token.inserted_at, user.authenticated_at) == :gt
end
end
describe "get_user_by_session_token/1" do
setup do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
%{user: user, token: token}
end
test "returns user by token", %{user: user, token: token} do
assert {session_user, token_inserted_at} = Accounts.get_user_by_session_token(token)
assert session_user.id == user.id
assert session_user.authenticated_at != nil
assert token_inserted_at != nil
end
test "does not return user for invalid token" do
refute Accounts.get_user_by_session_token("oops")
end
test "does not return user for expired token", %{token: token} do
dt = ~N[2020-01-01 00:00:00]
{1, nil} = Repo.update_all(UserToken, set: [inserted_at: dt, authenticated_at: dt])
refute Accounts.get_user_by_session_token(token)
end
end
describe "delete_user_session_token/1" do
test "deletes the token" do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert Accounts.delete_user_session_token(token) == :ok
refute Accounts.get_user_by_session_token(token)
end
end
describe "inspect/2 for the User module" do
test "does not include password" do
refute inspect(%User{password: "123456"}) =~ "password: \"123456\""
end
end
end
+18 -12
View File
@@ -1,10 +1,16 @@
defmodule Bulwark.Ingestion.ParseJobTest do
use Bulwark.DataCase, async: true
import Bulwark.AccountsFixtures
alias Bulwark.Ingestion.ParseJob
alias Bulwark.Security
alias Bulwark.Security.{Finding, Vulnerability}
setup do
%{scope: user_scope_fixture()}
end
defp write_report(map) do
path = Path.join(System.tmp_dir!(), "bulwark-test-#{System.unique_integer([:positive])}.json")
File.write!(path, Jason.encode!(map))
@@ -12,11 +18,11 @@ defmodule Bulwark.Ingestion.ParseJobTest do
path
end
defp run_scan(report, tool \\ "trivy", format \\ "json") do
defp run_scan(scope, report, tool \\ "trivy", format \\ "json") do
path = write_report(report)
{:ok, scan} =
Security.create_scan(%{
Security.create_scan(scope, %{
source_tool: tool,
source_format: format,
file_name: "report.json",
@@ -24,10 +30,10 @@ defmodule Bulwark.Ingestion.ParseJobTest do
})
:ok = ParseJob.perform(%Oban.Job{args: %{"scan_id" => scan.id}})
Security.get_scan!(scan.id)
Security.get_scan!(scope, scan.id)
end
test "persists findings from a well-formed Trivy report" do
test "persists findings from a well-formed Trivy report", %{scope: scope} do
report = %{
"SchemaVersion" => 2,
"ArtifactName" => "myapp:latest",
@@ -43,14 +49,14 @@ defmodule Bulwark.Ingestion.ParseJobTest do
]
}
scan = run_scan(report)
scan = run_scan(scope, report)
assert scan.status == "completed"
assert scan.finding_count == 2
assert Repo.aggregate(Finding, :count) == 2
end
test "an exotic severity is normalized and does not drop the finding" do
test "an exotic severity is normalized and does not drop the finding", %{scope: scope} do
report = %{
"SchemaVersion" => 2,
"ArtifactName" => "myapp",
@@ -65,7 +71,7 @@ defmodule Bulwark.Ingestion.ParseJobTest do
]
}
scan = run_scan(report)
scan = run_scan(scope, report)
assert scan.status == "completed"
assert scan.finding_count == 1
@@ -73,7 +79,7 @@ defmodule Bulwark.Ingestion.ParseJobTest do
assert vuln.severity == "low"
end
test "findings missing an external id get distinct synthetic ids (no collapse)" do
test "findings missing an external id get distinct synthetic ids (no collapse)", %{scope: scope} do
# Two SARIF results with no ruleId at distinct locations must not merge.
report = %{
"runs" => [
@@ -107,7 +113,7 @@ defmodule Bulwark.Ingestion.ParseJobTest do
]
}
scan = run_scan(report, "generic", "sarif")
scan = run_scan(scope, report, "generic", "sarif")
assert scan.status == "completed"
# ruleId defaults to "unknown" in the SARIF parser; the persistence layer
@@ -116,14 +122,14 @@ defmodule Bulwark.Ingestion.ParseJobTest do
assert Repo.aggregate(Vulnerability, :count) == 2
end
test "a malformed report (non-map) fails the scan without crashing" do
test "a malformed report (non-map) fails the scan without crashing", %{scope: scope} do
path = write_report([1, 2, 3])
{:ok, scan} =
Security.create_scan(%{source_tool: "trivy", source_format: "json", file_path: path})
Security.create_scan(scope, %{source_tool: "trivy", source_format: "json", file_path: path})
# Top-level array: Trivy.parse/1 rejects it -> scan failed, no crash.
assert {:error, _} = ParseJob.perform(%Oban.Job{args: %{"scan_id" => scan.id}})
assert Security.get_scan!(scan.id).status == "failed"
assert Security.get_scan!(scope, scan.id).status == "failed"
end
end
+150
View File
@@ -0,0 +1,150 @@
defmodule Bulwark.Security.TenancyTest do
@moduledoc """
Cross-tenant isolation guarantees for the Security context.
Two regular users (A and B) each ingest a scan. Neither may observe the
other's scans, assets, findings, or (catalog-derived) vulnerabilities. An
admin sees everything. Ownership-checked getters must raise NoResultsError
for another tenant's id rather than leak an existence oracle.
"""
use Bulwark.DataCase, async: true
import Bulwark.AccountsFixtures
alias Bulwark.Ingestion.ParseJob
alias Bulwark.Security
alias Bulwark.Accounts.Scope
# Ingests a one-vulnerability Trivy report for the given scope and returns the
# completed scan. The CVE id is parameterized so each tenant gets a distinct
# vulnerability row (the global catalog upserts on external_id).
defp ingest(scope, cve) do
report = %{
"SchemaVersion" => 2,
"ArtifactName" => "img-#{cve}",
"ArtifactType" => "container_image",
"Results" => [
%{
"Target" => "img",
"Vulnerabilities" => [
%{"VulnerabilityID" => cve, "Severity" => "HIGH", "PkgName" => "pkg-#{cve}"}
]
}
]
}
path = Path.join(System.tmp_dir!(), "tenancy-#{System.unique_integer([:positive])}.json")
File.write!(path, Jason.encode!(report))
on_exit(fn -> File.rm(path) end)
{:ok, scan} =
Security.create_scan(scope, %{
source_tool: "trivy",
source_format: "json",
file_name: "report.json",
file_path: path
})
:ok = ParseJob.perform(%Oban.Job{args: %{"scan_id" => scan.id}})
Security.get_scan!(scope, scan.id)
end
setup do
a = user_scope_fixture()
b = user_scope_fixture()
admin = admin_scope_fixture()
scan_a = ingest(a, "CVE-AAA-0001")
scan_b = ingest(b, "CVE-BBB-0001")
%{a: a, b: b, admin: admin, scan_a: scan_a, scan_b: scan_b}
end
describe "scan visibility" do
test "a user lists only their own scans", %{a: a, scan_a: scan_a} do
{:ok, {scans, _meta}} = Security.list_scans(a, %{})
assert Enum.map(scans, & &1.id) == [scan_a.id]
end
test "a user cannot fetch another tenant's scan", %{a: a, scan_b: scan_b} do
assert_raise Ecto.NoResultsError, fn -> Security.get_scan!(a, scan_b.id) end
end
test "admin sees all scans", %{admin: admin, scan_a: scan_a, scan_b: scan_b} do
{:ok, {scans, _meta}} = Security.list_scans(admin, %{})
ids = Enum.map(scans, & &1.id) |> Enum.sort()
assert ids == Enum.sort([scan_a.id, scan_b.id])
end
end
describe "finding visibility" do
test "a user lists only their own findings", %{a: a, b: b} do
{:ok, {a_findings, _}} = Security.list_findings(a, %{})
{:ok, {b_findings, _}} = Security.list_findings(b, %{})
assert length(a_findings) == 1
assert length(b_findings) == 1
assert Enum.all?(a_findings, &(&1.user_id == Scope.user_id(a)))
refute Enum.any?(a_findings, &(&1.user_id == Scope.user_id(b)))
end
test "a user cannot fetch another tenant's finding", %{a: a, b: b} do
{:ok, {[b_finding], _}} = Security.list_findings(b, %{})
assert_raise Ecto.NoResultsError, fn -> Security.get_finding!(a, b_finding.id) end
end
end
describe "asset visibility" do
test "a user lists only their own assets", %{a: a, b: b} do
{:ok, {a_assets, _}} = Security.list_assets(a, %{})
{:ok, {b_assets, _}} = Security.list_assets(b, %{})
assert length(a_assets) == 1
assert length(b_assets) == 1
assert MapSet.disjoint?(
MapSet.new(a_assets, & &1.id),
MapSet.new(b_assets, & &1.id)
)
end
test "a user cannot fetch another tenant's asset", %{a: a, b: b} do
{:ok, {[b_asset], _}} = Security.list_assets(b, %{})
assert_raise Ecto.NoResultsError, fn -> Security.get_asset!(a, b_asset.id) end
end
end
describe "vulnerability visibility (global catalog, finding-derived)" do
test "a user sees only vulnerabilities they have a finding for", %{a: a, b: b} do
{:ok, {a_vulns, _}} = Security.list_vulnerabilities(a, %{})
{:ok, {b_vulns, _}} = Security.list_vulnerabilities(b, %{})
assert Enum.map(a_vulns, & &1.external_id) == ["CVE-AAA-0001"]
assert Enum.map(b_vulns, & &1.external_id) == ["CVE-BBB-0001"]
end
test "a user cannot fetch a vulnerability only another tenant has", %{a: a, b: b} do
{:ok, {[b_vuln], _}} = Security.list_vulnerabilities(b, %{})
assert_raise Ecto.NoResultsError, fn -> Security.get_vulnerability!(a, b_vuln.id) end
end
test "admin sees the whole catalog", %{admin: admin} do
{:ok, {vulns, _}} = Security.list_vulnerabilities(admin, %{})
ids = Enum.map(vulns, & &1.external_id) |> Enum.sort()
assert ids == ["CVE-AAA-0001", "CVE-BBB-0001"]
end
end
describe "dashboard aggregates are per-tenant" do
test "open-finding counts don't leak across tenants", %{a: a, b: b, admin: admin} do
assert Security.total_open_findings(a) == 1
assert Security.total_open_findings(b) == 1
assert Security.total_open_findings(admin) == 2
end
test "severity counts are scoped", %{a: a, admin: admin} do
assert Security.vulnerability_counts_by_severity(a) == %{"high" => 1}
assert Security.vulnerability_counts_by_severity(admin)["high"] == 2
end
end
end
@@ -0,0 +1,124 @@
defmodule BulwarkWeb.UserSessionControllerTest do
use BulwarkWeb.ConnCase, async: true
import Bulwark.AccountsFixtures
alias Bulwark.Accounts
setup do
%{user: user_fixture()}
end
describe "POST /users/log-in - email and password" do
test "logs the user in", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email, "password" => valid_user_password()}
})
assert get_session(conn, :user_token)
assert redirected_to(conn) == ~p"/"
# Now follow with a logged-in request to confirm the session works.
conn = get(conn, ~p"/")
assert html_response(conn, 200)
end
test "logs the user in with remember me", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password(),
"remember_me" => "true"
}
})
assert conn.resp_cookies["_bulwark_web_user_remember_me"]
assert redirected_to(conn) == ~p"/"
end
test "logs the user in with return to", %{conn: conn, user: user} do
conn =
conn
|> init_test_session(user_return_to: "/foo/bar")
|> post(~p"/users/log-in", %{
"user" => %{
"email" => user.email,
"password" => valid_user_password()
}
})
assert redirected_to(conn) == "/foo/bar"
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Welcome back!"
end
test "redirects to login page with invalid credentials", %{conn: conn, user: user} do
conn =
post(conn, ~p"/users/log-in", %{
"user" => %{"email" => user.email, "password" => "invalid_password"}
})
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
assert redirected_to(conn) == ~p"/users/log-in"
end
end
describe "POST /users/update-password" do
setup %{conn: conn, user: user} do
%{conn: log_in_user(conn, user), user: user}
end
test "updates the password and redirects to the dashboard", %{conn: conn, user: user} do
new_password = "a brand new password"
conn =
post(conn, ~p"/users/update-password", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
test "clears the forced-password-change flag", %{conn: _conn} do
user = user_fixture(%{must_change_password: true})
assert user.must_change_password
new_password = "a brand new password"
build_conn()
|> log_in_user(user)
|> post(~p"/users/update-password", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
refute Accounts.get_user!(user.id).must_change_password
end
end
describe "DELETE /users/log-out" do
test "logs the user out", %{conn: conn, user: user} do
conn = conn |> log_in_user(user) |> delete(~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
test "succeeds even if the user is not logged in", %{conn: conn} do
conn = delete(conn, ~p"/users/log-out")
assert redirected_to(conn) == ~p"/"
refute get_session(conn, :user_token)
assert Phoenix.Flash.get(conn.assigns.flash, :info) =~ "Logged out successfully"
end
end
end
@@ -0,0 +1,112 @@
defmodule BulwarkWeb.AuthorizationTest do
@moduledoc """
End-to-end authorization at the LiveView/router layer: authentication gating,
admin RBAC on /admin/users, and cross-tenant resource isolation (one user's
scan/asset/vulnerability detail URLs must 404 for another user).
"""
use BulwarkWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bulwark.AccountsFixtures
alias Bulwark.Ingestion.ParseJob
alias Bulwark.Security
defp ingest(scope, cve) do
report = %{
"SchemaVersion" => 2,
"ArtifactName" => "img-#{cve}",
"ArtifactType" => "container_image",
"Results" => [
%{
"Target" => "img",
"Vulnerabilities" => [
%{"VulnerabilityID" => cve, "Severity" => "HIGH", "PkgName" => "pkg"}
]
}
]
}
path = Path.join(System.tmp_dir!(), "authz-#{System.unique_integer([:positive])}.json")
File.write!(path, Jason.encode!(report))
on_exit(fn -> File.rm(path) end)
{:ok, scan} =
Security.create_scan(scope, %{
source_tool: "trivy",
source_format: "json",
file_name: "report.json",
file_path: path
})
:ok = ParseJob.perform(%Oban.Job{args: %{"scan_id" => scan.id}})
{Security.get_scan!(scope, scan.id), scope}
end
describe "authentication gating" do
test "unauthenticated user is redirected from app pages to log-in", %{conn: conn} do
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/")
assert path == ~p"/users/log-in"
end
test "authenticated user reaches the dashboard", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
assert {:ok, _lv, html} = live(conn, ~p"/")
assert html =~ "Security Dashboard"
end
end
describe "admin RBAC on /admin/users" do
test "a non-admin is redirected away", %{conn: conn} do
conn = log_in_user(conn, user_fixture())
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/admin/users")
assert path == ~p"/"
end
test "an admin can reach the users page", %{conn: conn} do
conn = log_in_user(conn, admin_fixture())
assert {:ok, _lv, html} = live(conn, ~p"/admin/users")
assert html =~ "Create User"
end
end
describe "forced password change" do
test "a temp-password user is redirected to settings from app pages", %{conn: conn} do
user = user_fixture(%{must_change_password: true})
conn = log_in_user(conn, user)
assert {:error, {:redirect, %{to: path}}} = live(conn, ~p"/scans")
assert path == ~p"/settings"
end
test "but can still reach settings to clear it", %{conn: conn} do
user = user_fixture(%{must_change_password: true})
conn = log_in_user(conn, user)
assert {:ok, _lv, _html} = live(conn, ~p"/settings")
end
end
describe "cross-tenant resource isolation" do
setup %{conn: conn} do
{scan_b, scope_b} = ingest(user_scope_fixture(), "CVE-OTHER-9999")
{:ok, {[finding_b], _}} = Security.list_findings(scope_b, %{})
{:ok, {[asset_b], _}} = Security.list_assets(scope_b, %{})
{:ok, {[vuln_b], _}} = Security.list_vulnerabilities(scope_b, %{})
conn = log_in_user(conn, user_fixture())
%{conn: conn, scan_b: scan_b, asset_b: asset_b, vuln_b: vuln_b, finding_b: finding_b}
end
test "another tenant's scan detail 404s", %{conn: conn, scan_b: scan_b} do
assert_raise Ecto.NoResultsError, fn -> live(conn, ~p"/scans/#{scan_b.id}") end
end
test "another tenant's asset detail 404s", %{conn: conn, asset_b: asset_b} do
assert_raise Ecto.NoResultsError, fn -> live(conn, ~p"/assets/#{asset_b.id}") end
end
test "another tenant's vulnerability detail 404s", %{conn: conn, vuln_b: vuln_b} do
assert_raise Ecto.NoResultsError, fn -> live(conn, ~p"/vulnerabilities/#{vuln_b.id}") end
end
end
end
@@ -0,0 +1,55 @@
defmodule BulwarkWeb.UserLive.LoginTest do
use BulwarkWeb.ConnCase, async: true
import Phoenix.LiveViewTest
import Bulwark.AccountsFixtures
describe "login page" do
test "renders login page", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
assert html =~ "Sign in to Bulwark"
assert html =~ "Email"
assert html =~ "Password"
assert html =~ "Keep me signed in"
end
test "does not offer registration or magic-link flows", %{conn: conn} do
{:ok, _lv, html} = live(conn, ~p"/users/log-in")
refute html =~ "Register"
refute html =~ "Sign up"
refute html =~ "magic link"
end
end
describe "user login" do
test "redirects to the dashboard with valid credentials", %{conn: conn} do
user = user_fixture()
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
form =
form(lv, "#login_form",
user: %{email: user.email, password: valid_user_password(), remember_me: true}
)
conn = submit_form(form, conn)
assert redirected_to(conn) == ~p"/"
assert get_session(conn, :user_token)
end
test "redirects to login page with a flash error if credentials are invalid", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/users/log-in")
form =
form(lv, "#login_form", user: %{email: "test@email.com", password: "invalid password"})
conn = submit_form(form, conn)
assert Phoenix.Flash.get(conn.assigns.flash, :error) == "Invalid email or password"
assert redirected_to(conn) == ~p"/users/log-in"
end
end
end
@@ -0,0 +1,111 @@
defmodule BulwarkWeb.UserLive.SettingsTest do
use BulwarkWeb.ConnCase, async: true
alias Bulwark.Accounts
import Phoenix.LiveViewTest
import Bulwark.AccountsFixtures
describe "Settings page" do
test "renders the settings page", %{conn: conn} do
{:ok, _lv, html} =
conn
|> log_in_user(user_fixture())
|> live(~p"/settings")
assert html =~ "Settings"
assert html =~ "Account"
assert html =~ "New password"
assert html =~ "Save password"
end
test "is reachable when the user must change their password", %{conn: conn} do
user = user_fixture(%{must_change_password: true})
{:ok, _lv, html} =
conn
|> log_in_user(user)
|> live(~p"/settings")
assert html =~ "temporary password"
end
test "redirects if user is not logged in", %{conn: conn} do
assert {:error, redirect} = live(conn, ~p"/settings")
assert {:redirect, %{to: path, flash: flash}} = redirect
assert path == ~p"/users/log-in"
assert %{"error" => "You must log in to access this page."} = flash
end
end
describe "update password form" do
setup %{conn: conn} do
user = user_fixture()
%{conn: log_in_user(conn, user), user: user}
end
test "updates the user password", %{conn: conn, user: user} do
new_password = "a brand new password"
{:ok, lv, _html} = live(conn, ~p"/settings")
form =
form(lv, "#password_form", %{
"user" => %{
"email" => user.email,
"password" => new_password,
"password_confirmation" => new_password
}
})
render_submit(form)
new_password_conn = follow_trigger_action(form, conn)
assert redirected_to(new_password_conn) == ~p"/"
assert get_session(new_password_conn, :user_token) != get_session(conn, :user_token)
assert Phoenix.Flash.get(new_password_conn.assigns.flash, :info) =~
"Password updated successfully"
assert Accounts.get_user_by_email_and_password(user.email, new_password)
end
test "renders errors with invalid data (phx-change)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/settings")
result =
lv
|> element("#password_form")
|> render_change(%{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
assert result =~ "Save password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
test "renders errors with invalid data (phx-submit)", %{conn: conn} do
{:ok, lv, _html} = live(conn, ~p"/settings")
result =
lv
|> form("#password_form", %{
"user" => %{
"password" => "too short",
"password_confirmation" => "does not match"
}
})
|> render_submit()
assert result =~ "Save password"
assert result =~ "should be at least 12 character(s)"
assert result =~ "does not match password"
end
end
end
+476
View File
@@ -0,0 +1,476 @@
defmodule BulwarkWeb.UserAuthTest do
use BulwarkWeb.ConnCase, async: true
alias Phoenix.LiveView
alias Bulwark.Accounts
alias Bulwark.Accounts.Scope
alias BulwarkWeb.UserAuth
import Bulwark.AccountsFixtures
@remember_me_cookie "_bulwark_web_user_remember_me"
@remember_me_cookie_max_age 60 * 60 * 24 * 14
setup %{conn: conn} do
conn =
conn
|> Map.replace!(:secret_key_base, BulwarkWeb.Endpoint.config(:secret_key_base))
|> init_test_session(%{})
%{user: %{user_fixture() | authenticated_at: DateTime.utc_now(:second)}, conn: conn}
end
describe "log_in_user/3" do
test "stores the user token in the session", %{conn: conn, user: user} do
conn = UserAuth.log_in_user(conn, user)
assert token = get_session(conn, :user_token)
assert get_session(conn, :live_socket_id) == "users_sessions:#{Base.url_encode64(token)}"
assert redirected_to(conn) == ~p"/"
assert Accounts.get_user_by_session_token(token)
end
test "clears everything previously stored in the session", %{conn: conn, user: user} do
conn = conn |> put_session(:to_be_removed, "value") |> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "keeps session when re-authenticating", %{conn: conn, user: user} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> put_session(:to_be_removed, "value")
|> UserAuth.log_in_user(user)
assert get_session(conn, :to_be_removed)
end
test "clears session when user does not match when re-authenticating", %{
conn: conn,
user: user
} do
other_user = user_fixture()
conn =
conn
|> assign(:current_scope, Scope.for_user(other_user))
|> put_session(:to_be_removed, "value")
|> UserAuth.log_in_user(user)
refute get_session(conn, :to_be_removed)
end
test "redirects to the configured path", %{conn: conn, user: user} do
conn = conn |> put_session(:user_return_to, "/hello") |> UserAuth.log_in_user(user)
assert redirected_to(conn) == "/hello"
end
test "writes a cookie if remember_me is configured", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :user_remember_me) == true
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == @remember_me_cookie_max_age
end
test "redirects to the signed-in path when user is already logged in", %{
conn: conn,
user: user
} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.log_in_user(user)
assert redirected_to(conn) == ~p"/"
end
test "writes a cookie if remember_me was set in previous session", %{conn: conn, user: user} do
conn = conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
assert get_session(conn, :user_token) == conn.cookies[@remember_me_cookie]
assert get_session(conn, :user_remember_me) == true
conn =
conn
|> recycle()
|> Map.replace!(:secret_key_base, BulwarkWeb.Endpoint.config(:secret_key_base))
|> fetch_cookies()
|> init_test_session(%{user_remember_me: true})
# the conn is already logged in and has the remember_me cookie set,
# now we log in again and even without explicitly setting remember_me,
# the cookie should be set again
conn = conn |> UserAuth.log_in_user(user, %{})
assert %{value: signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert signed_token != get_session(conn, :user_token)
assert max_age == @remember_me_cookie_max_age
assert get_session(conn, :user_remember_me) == true
end
end
describe "logout_user/1" do
test "erases session and cookies", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn
|> put_session(:user_token, user_token)
|> put_req_cookie(@remember_me_cookie, user_token)
|> fetch_cookies()
|> UserAuth.log_out_user()
refute get_session(conn, :user_token)
refute conn.cookies[@remember_me_cookie]
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
refute Accounts.get_user_by_session_token(user_token)
end
test "broadcasts to the given live_socket_id", %{conn: conn} do
live_socket_id = "users_sessions:abcdef-token"
BulwarkWeb.Endpoint.subscribe(live_socket_id)
conn
|> put_session(:live_socket_id, live_socket_id)
|> UserAuth.log_out_user()
assert_receive %Phoenix.Socket.Broadcast{event: "disconnect", topic: ^live_socket_id}
end
test "works even if user is already logged out", %{conn: conn} do
conn = conn |> fetch_cookies() |> UserAuth.log_out_user()
refute get_session(conn, :user_token)
assert %{max_age: 0} = conn.resp_cookies[@remember_me_cookie]
assert redirected_to(conn) == ~p"/"
end
end
describe "fetch_current_scope_for_user/2" do
test "authenticates user from session", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
conn =
conn |> put_session(:user_token, user_token) |> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert get_session(conn, :user_token) == user_token
end
test "authenticates user from cookies", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
user_token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
conn =
conn
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert get_session(conn, :user_token) == user_token
assert get_session(conn, :user_remember_me)
assert get_session(conn, :live_socket_id) ==
"users_sessions:#{Base.url_encode64(user_token)}"
end
test "does not authenticate if data is missing", %{conn: conn, user: user} do
_ = Accounts.generate_user_session_token(user)
conn = UserAuth.fetch_current_scope_for_user(conn, [])
refute get_session(conn, :user_token)
refute conn.assigns.current_scope
end
test "reissues a new token after a few days and refreshes cookie", %{conn: conn, user: user} do
logged_in_conn =
conn |> fetch_cookies() |> UserAuth.log_in_user(user, %{"remember_me" => "true"})
token = logged_in_conn.cookies[@remember_me_cookie]
%{value: signed_token} = logged_in_conn.resp_cookies[@remember_me_cookie]
offset_user_token(token, -10, :day)
{user, _} = Accounts.get_user_by_session_token(token)
conn =
conn
|> put_session(:user_token, token)
|> put_session(:user_remember_me, true)
|> put_req_cookie(@remember_me_cookie, signed_token)
|> UserAuth.fetch_current_scope_for_user([])
assert conn.assigns.current_scope.user.id == user.id
assert conn.assigns.current_scope.user.authenticated_at == user.authenticated_at
assert new_token = get_session(conn, :user_token)
assert new_token != token
assert %{value: new_signed_token, max_age: max_age} = conn.resp_cookies[@remember_me_cookie]
assert new_signed_token != signed_token
assert max_age == @remember_me_cookie_max_age
end
end
describe "on_mount :mount_current_scope" do
setup %{conn: conn} do
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
end
test "assigns current_scope based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope.user.id == user.id
end
test "assigns nil to current_scope assign if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope == nil
end
test "assigns nil to current_scope assign if there isn't a user_token", %{conn: conn} do
session = conn |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:mount_current_scope, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope == nil
end
end
describe "on_mount :require_authenticated" do
test "authenticates current_scope based on a valid user_token", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:require_authenticated, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope.user.id == user.id
end
test "redirects to login page if there isn't a valid user_token", %{conn: conn} do
user_token = "invalid_token"
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:require_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_scope == nil
assert {:redirect, %{to: path}} = updated_socket.redirected
assert path == ~p"/users/log-in"
end
test "redirects to login page if there isn't a user_token", %{conn: conn} do
session = conn |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:require_authenticated, %{}, session, socket)
assert updated_socket.assigns.current_scope == nil
assert {:redirect, %{to: path}} = updated_socket.redirected
assert path == ~p"/users/log-in"
end
end
describe "on_mount :require_admin" do
test "allows admins through", %{conn: conn} do
admin = admin_fixture()
user_token = Accounts.generate_user_session_token(admin)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:require_admin, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope.user.id == admin.id
end
test "halts and redirects non-admins to /", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:require_admin, %{}, session, socket)
assert {:redirect, %{to: path}} = updated_socket.redirected
assert path == ~p"/"
end
test "halts and redirects logged-out users to /", %{conn: conn} do
session = conn |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} = UserAuth.on_mount(:require_admin, %{}, session, socket)
assert {:redirect, %{to: path}} = updated_socket.redirected
assert path == ~p"/"
end
end
describe "on_mount :require_password_changed" do
test "continues for a user that does not need to change their password", %{
conn: conn,
user: user
} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
{:cont, updated_socket} =
UserAuth.on_mount(:require_password_changed, %{}, session, %LiveView.Socket{})
assert updated_socket.assigns.current_scope.user.id == user.id
end
test "halts and redirects a user who must change their password to /settings", %{conn: conn} do
user = user_fixture(%{must_change_password: true})
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
{:halt, updated_socket} =
UserAuth.on_mount(:require_password_changed, %{}, session, socket)
assert {:redirect, %{to: path}} = updated_socket.redirected
assert path == ~p"/settings"
end
test "continues when there is no user", %{conn: conn} do
session = conn |> get_session()
{:cont, _updated_socket} =
UserAuth.on_mount(:require_password_changed, %{}, session, %LiveView.Socket{})
end
end
describe "on_mount :require_sudo_mode" do
test "allows users that have authenticated in the last 10 minutes", %{conn: conn, user: user} do
user_token = Accounts.generate_user_session_token(user)
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
assert {:cont, _updated_socket} =
UserAuth.on_mount(:require_sudo_mode, %{}, session, socket)
end
test "redirects when authentication is too old", %{conn: conn, user: user} do
eleven_minutes_ago = DateTime.utc_now(:second) |> DateTime.add(-11, :minute)
user = %{user | authenticated_at: eleven_minutes_ago}
user_token = Accounts.generate_user_session_token(user)
{user, token_inserted_at} = Accounts.get_user_by_session_token(user_token)
assert DateTime.compare(token_inserted_at, user.authenticated_at) == :gt
session = conn |> put_session(:user_token, user_token) |> get_session()
socket = %LiveView.Socket{
endpoint: BulwarkWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
assert {:halt, _updated_socket} =
UserAuth.on_mount(:require_sudo_mode, %{}, session, socket)
end
end
describe "require_authenticated_user/2" do
setup %{conn: conn} do
%{conn: UserAuth.fetch_current_scope_for_user(conn, [])}
end
test "redirects if user is not authenticated", %{conn: conn} do
conn = conn |> fetch_flash() |> UserAuth.require_authenticated_user([])
assert conn.halted
assert redirected_to(conn) == ~p"/users/log-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"You must log in to access this page."
end
test "stores the path to redirect to on GET", %{conn: conn} do
halted_conn =
%{conn | path_info: ["foo"], query_string: ""}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar=baz"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
assert get_session(halted_conn, :user_return_to) == "/foo?bar=baz"
halted_conn =
%{conn | path_info: ["foo"], query_string: "bar", method: "POST"}
|> fetch_flash()
|> UserAuth.require_authenticated_user([])
assert halted_conn.halted
refute get_session(halted_conn, :user_return_to)
end
test "does not redirect if user is authenticated", %{conn: conn, user: user} do
conn =
conn
|> assign(:current_scope, Scope.for_user(user))
|> UserAuth.require_authenticated_user([])
refute conn.halted
refute conn.status
end
end
describe "disconnect_sessions/1" do
test "broadcasts disconnect messages for each token" do
tokens = [%{token: "token1"}, %{token: "token2"}]
for %{token: token} <- tokens do
BulwarkWeb.Endpoint.subscribe("users_sessions:#{Base.url_encode64(token)}")
end
UserAuth.disconnect_sessions(tokens)
assert_receive %Phoenix.Socket.Broadcast{
event: "disconnect",
topic: "users_sessions:dG9rZW4x"
}
assert_receive %Phoenix.Socket.Broadcast{
event: "disconnect",
topic: "users_sessions:dG9rZW4y"
}
end
end
end
+52
View File
@@ -35,4 +35,56 @@ defmodule BulwarkWeb.ConnCase do
Bulwark.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
@doc """
Setup helper that registers and logs in users.
setup :register_and_log_in_user
It stores an updated connection and a registered user in the
test context.
"""
def register_and_log_in_user(%{conn: conn} = context) do
user = Bulwark.AccountsFixtures.user_fixture()
scope = Bulwark.Accounts.Scope.for_user(user)
opts =
context
|> Map.take([:token_authenticated_at])
|> Enum.into([])
%{conn: log_in_user(conn, user, opts), user: user, scope: scope}
end
@doc """
Setup helper that logs in an admin user.
setup :register_and_log_in_admin
"""
def register_and_log_in_admin(%{conn: conn}) do
user = Bulwark.AccountsFixtures.admin_fixture()
scope = Bulwark.Accounts.Scope.for_user(user)
%{conn: log_in_user(conn, user), user: user, scope: scope}
end
@doc """
Logs the given `user` into the `conn`.
It returns an updated `conn`.
"""
def log_in_user(conn, user, opts \\ []) do
token = Bulwark.Accounts.generate_user_session_token(user)
maybe_set_token_authenticated_at(token, opts[:token_authenticated_at])
conn
|> Phoenix.ConnTest.init_test_session(%{})
|> Plug.Conn.put_session(:user_token, token)
end
defp maybe_set_token_authenticated_at(_token, nil), do: nil
defp maybe_set_token_authenticated_at(token, authenticated_at) do
Bulwark.AccountsFixtures.override_token_authenticated_at(token, authenticated_at)
end
end
@@ -0,0 +1,73 @@
defmodule Bulwark.AccountsFixtures do
@moduledoc """
Test helpers for creating users via the `Bulwark.Accounts` context.
Registration is admin-managed (closed), so fixtures build users directly
through `User.admin_create_changeset/3` and then clear the forced-password
flag via `set_password/2` so the user is "active" by default.
"""
import Ecto.Query
alias Bulwark.Accounts
alias Bulwark.Accounts.{Scope, User}
alias Bulwark.Repo
def unique_user_email, do: "user#{System.unique_integer([:positive])}@example.com"
def valid_user_password, do: "hello world! password"
def valid_user_attributes(attrs \\ %{}) do
Enum.into(attrs, %{
email: unique_user_email(),
role: "user",
password: valid_user_password()
})
end
@doc """
Creates an active user (forced-password flag cleared) with a known password.
Pass `role: "admin"` for an admin. Pass `must_change_password: true` to keep
the temporary-password state.
"""
def user_fixture(attrs \\ %{}) do
attrs = valid_user_attributes(attrs)
keep_temp? = Map.get(attrs, :must_change_password, false)
{:ok, user} =
%User{}
|> User.admin_create_changeset(attrs)
|> Repo.insert()
if keep_temp?, do: user, else: set_password(user)
end
def admin_fixture(attrs \\ %{}) do
attrs |> Map.put(:role, "admin") |> user_fixture()
end
def user_scope_fixture, do: user_fixture() |> user_scope_fixture()
def user_scope_fixture(user), do: Scope.for_user(user)
def admin_scope_fixture, do: admin_fixture() |> Scope.for_user()
@doc "Sets a user's password to the known test password and clears the temp flag."
def set_password(user, password \\ valid_user_password()) do
{:ok, {user, _expired_tokens}} = Accounts.update_user_password(user, %{password: password})
user
end
def offset_user_token(token, amount_to_add, unit) do
dt = DateTime.add(DateTime.utc_now(:second), amount_to_add, unit)
Repo.update_all(
from(ut in Accounts.UserToken, where: ut.token == ^token),
set: [inserted_at: dt, authenticated_at: dt]
)
end
def override_token_authenticated_at(token, authenticated_at) when is_binary(token) do
Repo.update_all(
from(t in Accounts.UserToken, where: t.token == ^token),
set: [authenticated_at: authenticated_at]
)
end
end