Gatekeeper - Lightweight authorization middleware

Hi everyone :waving_hand:

I’ve just released a small shard called Gatekeeper, and I wanted to share it here in case it’s useful to others.

:backhand_index_pointing_right: Repository: GitHub - henrikac/gatekeeper.cr: Lightweight authorization middleware with pluggable authenticators.
:backhand_index_pointing_right: Latest release: v0.5.0


:sparkles: What is it?

Gatekeeper is a lightweight authorization middleware.
It doesn’t try to be a full security framework — instead, it focuses on one thing:

Enforcing role-based access rules for your routes, using whatever authentication mechanism you choose.

It integrates into any Crystal HTTP handler chain, evaluates your rules, runs your authenticators, and returns 401 or 403 when needed.


:puzzle_piece: Key Features

  • Simple rule-based authorization (regex path matching + optional HTTP methods)
  • Pluggable authenticators
    – sessions, JWT, API tokens, whatever fits your app
  • Identity abstraction with a built-in IdentityUser(ID)
  • Optional per-rule authenticators
  • Customize responses for
    • 401 Unauthorized
    • 403 Forbidden

:test_tube: Small Example

require "gatekeeper"

class AppHandler
  include HTTP::Handler

  def call(context)
    case context.request.path
    when "/"
      context.response.print "Hello World!"
    when "/admin"
      context.response.print "Hello admin!"
    else
      context.response.status = HTTP::Status::NOT_FOUND
      context.response.print "Not found"
    end
  end
end

# Configure Gatekeeper
Gatekeeper.config do |config|
  config.on_unauthenticated = Gatekeeper::ContextHandler.new do |ctx|
    ctx.response.print "You must log in first."
  end

  config.on_unauthorized = Gatekeeper::ContextHandler.new do |ctx|
    ctx.response.print "You do not have permission."
  end

  # Simple authenticator example
  config.authenticators << Gatekeeper.authenticator do |ctx|
    Gatekeeper::IdentityUser(Int32).new(1, Set{"admin"})
  end

  # Optional: role hierarchy
  # "admin" inherits permissions from "user" and "reader"
  config.role_hierarchy = {
    "admin"  => ["user", "reader"],
    "manager" => ["user"],
  }
end

Gatekeeper.rules do |r|
  r.allow_get "/"                     # allow GET / (exact match)
  r.allow_post "/login"               # allow POST /login
  r.allow "/admin", roles: ["admin"]  # exact match
  r.allow /^\/api/                    # prefix /api (regex as-is)
end

handlers = [
  Gatekeeper::AuthHandler.new,
  AppHandler.new,
]

server = HTTP::Server.new(handlers)

address = server.bind_tcp "0.0.0.0", 3000
puts "Listening on http://#{address}"
server.listen

:rocket: Status

This is v0.5.0, but the API is stable, fully tested, and intentionally small.
I plan to iterate slowly with community feedback (rule helpers, role hierarchies, route attributes, etc.).

Any thoughts, suggestions, or ideas are very welcome!

Thanks for reading :folded_hands:

— Henrik

3 Likes

Technically Kemal::Handler is just a light wrapper around the stdlib’s HTTP::Handler. So with some small refactoring, you could likely make this usable beyond just kemal itself. Just a thought :slight_smile:.

2 Likes

Noted - already removed the dependency and instead `include HTTP::Handler`.

Now that it is not a kemal-only shard (when I publish v0.2.0 after work) anymore what about the name? Should it be updated to something like `Guardian` instead? :slight_smile:

2 Likes

The dependency on Kemal has now been removed and the shard has been renamed.

I appreciate the feedback! Let me know if there are other ideas :smiley:

I’m not sure if the Basic Auth scheme is supported, but if not, that might be useful for simple scenarios.

1 Like

Absolutely — Basic Auth isn’t built in, but it works perfectly through a custom authenticator.

Gatekeeper intentionally doesn’t implement any authentication schemes (Basic, Bearer, cookies, sessions, JWT, etc.). Instead it just consumes whatever you return from an authenticator.
So adding Basic Auth is only a few lines of code:

require "base64"

Gatekeeper.config do |config|
  config.authenticators << Gatekeeper.authenticator "basic auth" do |ctx|
    header = ctx.request.headers["Authorization"]?
    next nil unless header && header.starts_with?("Basic ")

    encoded = header.lchop("Basic ").strip
    decoded = String.new(Base64.decode(encoded).to_slice)

    username, password = decoded.split(":", 2)

    # Replace this with your own credential check
    if username == "admin" && password == "secret"
      Gatekeeper::IdentityUser(String).new(username, Set{"admin"})
    else
      nil
    end
  end
end

This keeps Gatekeeper focused on authorization only, while letting users plug in whatever authentication strategy fits their needs — including Basic Auth, JWT tokens, API keys, sessions, cookies, OAuth, etc.

2 Likes

Just a quick update for anyone following the shard:

Gatekeeper v0.4.0 introduces a much nicer way to define authorization rules.
Instead of always constructing Rule objects manually, you can now use a small helper API:

Gatekeeper.allow "/admin", roles: ["admin"]
Gatekeeper.allow /^\/api/

String paths become exact matches ("/admin"/^\/admin$/), and regex paths are used as-is.

There are also new HTTP verb helpers that automatically set the allowed method:

Gatekeeper.allow_get "/"
Gatekeeper.allow_post "/login"
Gatekeeper.allow_put "/admin", roles: ["admin"]

These helpers are also available inside the Gatekeeper.rules DSL, so you can keep everything grouped and readable:

Gatekeeper.rules do |r|
  r.allow_get "/"
  r.allow_post "/login"
  r.allow "/admin", roles: ["admin"]
end

No breaking changes — just a more ergonomic way to define rules.
If you try it out, I’d love to hear feedback!

2 Likes

Just a small update on Gatekeeper — I’ve released v0.5.0, which adds support for role hierarchy.

This is a feature I’ve wanted for a while because even simple applications tend to develop layered roles over time. Without hierarchy, identities often end up bloated with every role they implicitly need. With hierarchy, you can keep identities clean and let Gatekeeper infer the rest.

The idea is straightforward: define how your roles relate to each other, and Gatekeeper expands a user’s roles automatically during authorization. This keeps your rule definitions the same, but greatly simplifies how you represent users internally.

Here’s an example configuration:

config.role_hierarchy = {
  "superadmin" => ["admin", "auditor"],
  "admin"      => ["manager", "support", "user"],
  "manager"    => ["team_lead", "user"],
  "support"    => ["user"],
  "auditor"    => ["read_only"],
}

With this in place:

  • a user with "admin" automatically gains "manager", "support", and "user"
  • "manager" expands to "team_lead" and then "user"
  • "superadmin" inherits everything beneath it
  • cycles such as A -> B -> A are handled safely

The feature is fully optional. If no hierarchy is configured, Gatekeeper behaves exactly as before, so upgrading doesn’t change existing behavior.

If you try it out or have thoughts on where Gatekeeper could go next, I’d love to hear your feedback.