Revelry

AI-Driven Custom Software Development

Revelry image chatgpt image apr 2 2025 12 37 30 pm

Zero Trust Security is an essential best practice for modern application security, and at Revelry, we take it seriously.

One of our latest projects involves personal user data, making it absolutely critical to ensure that access is restricted to its rightful owner and never exposed to unauthorized parties. 

When implementing the Zero Trust Security model within our code and application architecture, our default approach to data manipulation is Prove It.” The application by default does not have direct access to user-owned data. It requires a token provided by the user (session ID) to acquire the proper authentication materials to work with sensitive data. This identity verification is actually enforced inside our API logic and in our Database schema. Anyone accessing sensitive user data has to prove ownership no matter how they are approaching the data.

This is not, however, without its challenges. when it comes to the development process. We have very specific and important security constraints under which the application must continue to work as expected. This means we potentially have several blind spots when it comes to implementing new features. This article outlines how we addressed these blind spots and embraced a zero-trust model while maintaining a smooth developer experience.

Implementing Zero Trust Security in Development

  • Easy setup: I can easily run the application locally
  • Database management: I can make the changes needed.
  • Development environment: My development environment is a very good if not perfect replica of the live environment.

The trick comes in maintaining these developer expectations while ensuring Zero Trust Security remains intact.

The API for this application is written in Elixir and when it comes to working with our data we are using Ecto, probably no surprises there. In my experience, it is typical to perform your local development work with the “default” database user. When it comes to Postgres this user typically has superuser privileges. For our particular project, this is both a problem and a necessity.

Zero Trust Security and Database Management

A critical part of Zero Trust Security implementation is ensuring that developers can modify the database structure securely. Including spots that involve sensitive data, we are holding in production. We need to be able to make changes, and, ideally make them without any new processes.

In order to satisfy this “duality,” we split the migration config and the runtime config for the main Repo:

defmodule Example.LocalSetupRepo do

  @moduledoc """

  This Repo exists solely to allow splitting the DB creation/migration and actual "runtime" execution into two phases for

  local dev work.

  When creating/dropping/migrating locally this repo is used.  In the `dev` and `test` envs it is configured to login as

  postgres and _should_ have access to do whatever you need as far as modifying the DB goes.  However both the `mix test`

  and regular server processes will log into the DB as a restricted user.  This way we get a better view of reality when

  it comes to our DB level security configuration.

  The migration for creating the `restricted_user` user can be found in `priv/dev_test_repo/migrations` and the new test

  flow can be found in `mix.exs` (it is simply an expansion to the previous `ecto.setup` and `ecto.reset` aliases in `mix.exs`).

  """

  use Ecto.Repo,

    otp_app: :example,

    adapter: Ecto.Adapters.Postgres

end

This module serves solely as an anchor for configuring our new multi-user local setup. To ensure that migrations run without any significant issues, we configure this new repo to connect as a superuser. This general pattern should still work even if your situation is different and you need to be able to restrict the ability to create or manipulate tables.

Configuration Example:

#

# dev.exs

#

config :example, Example.LocalSetupRepo,

  username: "postgres",

  password: "postgres",

  hostname: "localhost",

  database: "example_dev",

  stacktrace: true

config :example, Example.Repo,

  username: "restricted_dev_user",

  password: "postgres",

  hostname: "localhost",

  database: "example_dev",

  stacktrace: true

There are probably two things here worth mentioning. To prevent the need for a noisy but zero-sum PR and to eliminate the need to think about “which repo is which” all the migration logic uses the new Repo (new name) while the application continues to use the more traditional Repo name. All we are really doing in the end is “demoting” the application so that it runs with fewer permissions and allowing migrations to continue to have unfettered access.

The final piece of the migration puzzle is to try and make it more ergonomic for the dev team. If you’ve worked enough with Ecto, then mix ecto.create, mix ecto.drop, and mix ecto.migrate are probably muscle memory. We don’t want to change that – we don’t want people to have to remember that they need to run mix special_migrate or something equally weird. So we set up a few aliases that handle setting the correct repo. In the case of our mix ecto.setup alias, we also run the “extra” migration files to set up the user account and ensure its permissions are correct. This way devs don’t have to wade around in Postgres and get it right on their own.

#

# mix.exs

#

defp aliases do

  [

    "ecto.migrate": [

      "ecto.migrate -r Example.LocalSetupRepo --migrations-path priv/repo/migrations",

      "grant_default_db_access"

    ],

    "ecto.reset": ["ecto.drop", "ecto.setup"],

    "ecto.rollback": ["ecto.rollback -r Example.LocalSetupRepo --migrations-path priv/repo/migrations"],

    "ecto.setup": [

      "ecto.create",

      "ecto.migrate -r Example.LocalSetupRepo --migrations-path priv/dev_repo/migrations",

      fn _ -> Mix.Task.reenable("ecto.migrate") end,

      "ecto.migrate",

      "run priv/repo/seeds.exs"

    ]

  ]

end

Testing Zero Trust Security Implementation 

Now that we have our migration questions answered, we should do our best to try and ensure that nothing has changed in a way that would be undesirable. Remember: devs still have the ability to make changes to the database, and we want to ensure our security posture hasn’t been intentionally or accidentally circumvented.

We already have a test module aimed at testing data access validation. This is the perfect place for tests around these requirements. We have both negative and positive tests around all of the security constraints. For each data operation (SELECT, UPDATE, DELETE) we test full success, partial_success, and full_failure.

Keep in mind that these tests are not concerned with any of our business logic. Our goal here is to provide another layer of checks to let us know if something isn’t right. It is always better to catch any error as early as possible, especially in this case where sensitive data could be exposed. 

Startup Security Checks for Zero Trust

The final piece of the puzzle, from the dev perspective, is running the application. Hopefully, you are eating at least a little bit of your own dog food while developing. We are leveraging runtime checks to ensure the security configuration is correct in all environments. Zero Trust can be complicated, and keeping everything straight can result in a fair amount of toil.

To this end, we decided to leverage the application lifecycle in Elixir to perform checks on our database configuration and halt the API if we find any kind of misconfiguration.

To enforce security at runtime, we leverage Elixir’s application lifecycle to validate database configurations before the system starts:

defmodule Example.Application do

  # All the normal stuff should be here, it has just been elided for context

  @impl true

  def start_phase(:check_db_config, :normal, []) do

    case Example.Security.DBConfig.validate_all() do

      :ok -> :ok

      :error -> {:error, "DB config check failed"}

    end

  end

end

This helps us ensure that if something is wrong lower down in our stack, we mitigate the exposure. This not only limits the vectors available to a potential attacker but also sets off all kinds of alarms so we are immediately aware of it. In this scenario failing as hard and as loudly as possible is ideal.

Once the initial launch is finished, we plan to enhance the application with real-time security monitoring. By leveraging these same security checks to “poll” the database, shutting it down in the event of an unexpected change. Right now we are protecting ourselves from ourselves. However, that doesn’t mean someone can’t find another way into the database and potentially make changes. If our API can monitor that for us, then we can get a 24/7 security monitor with little effort 

Key Takeaways for a Secure Zero Trust Model

Hopefully, the main takeaway here is that security, especially a pattern like Zero Trust, can be complicated. Yet, with good tooling and a little extra time and work, it is still possible to maintain the ergonomics you are accustomed to and avoid the creation of extra Toil. The ultimate end goal should always be building something with genuine value. The ultimate trick is to understand what that really means. It is often easy to see the big pitfalls; what we are bad at is spotting and appreciating the “death by 1000 cuts” issues until they have become many.