feat: add app-level auth with RBAC and per-user tenancy
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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
defmodule Bulwark.Mailer do
|
||||
use Swoosh.Mailer, otp_app: :bulwark
|
||||
end
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 """
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
@@ -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.
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.
Binary file not shown.
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd -P -- "$(dirname -- "$0")"
|
||||
exec ./bulwark eval Bulwark.Release.seed_admin
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user