Rolling Your Own Pike Store
View SourcePike's extensible architecture allows you to implement custom store backends to suit your specific needs. This guide covers how to create your own store implementation and explores various use cases.
Store Behavior
To implement a custom store, your module must implement the Pike.Store behavior.
Required Callbacks
defmodule MyApp.CustomStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
# Retrieve the key and return either:
# {:ok, key_data} or :error
# A third option is {:error, :disabled} for disabled keys
end
@impl true
def action?(key_data, resource, action) do
# Check if the key has permission for the given action on the resource
# Return boolean
end
endOptional Callbacks
defmodule MyApp.FullFeaturedStore do
@behaviour Pike.Store
# Required callbacks from above...
@impl true
def insert(key_data) do
# Insert a new key into the store
# Return :ok or {:error, reason}
end
@impl true
def delete_key(key) do
# Delete a key from the store
# Return :ok or {:error, reason}
end
@impl true
def update_key(key, updates) do
# Update a key in the store
# Return :ok or {:error, reason}
end
endCommon Use Cases
Here are some practical scenarios where a custom store implementation would be valuable:
1. Database-Backed Persistence
For production environments, you might want to store API keys in a database:
defmodule MyApp.PostgresStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
case Repo.get_by(ApiKey, key: key) do
nil -> :error
%{active: false} = api_key -> {:error, :disabled}
api_key -> {:ok, map_from_schema(api_key)}
end
end
@impl true
def action?(key_data, resource, action) do
# First check if the key is enabled
case Map.get(key_data, :enabled, true) do
false -> false
true ->
# Check permissions from the key_data structure
Enum.any?(key_data.permissions, fn
%{resource: ^resource, scopes: scopes} when is_list(scopes) -> action in scopes
_ -> false
end)
end
end
@impl true
def insert(key_data) do
%ApiKey{}
|> ApiKey.changeset(map_to_schema(key_data))
|> Repo.insert()
|> case do
{:ok, _} -> :ok
error -> error
end
end
defp map_from_schema(schema) do
# Convert database schema to Pike's key format
# ...
end
defp map_to_schema(key_data) do
# Convert Pike's key format to database schema
# ...
end
end2. Layered Caching Store
Combine database persistence with in-memory caching for optimal performance:
defmodule MyApp.CachingStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
case :ets.lookup(:pike_keys_cache, key) do
[{^key, key_data}] ->
# Cache hit
{:ok, key_data}
[] ->
# Cache miss - check database
case MyApp.PostgresStore.get_key(key) do
{:ok, key_data} = result ->
# Store in cache for future lookups
:ets.insert(:pike_keys_cache, {key, key_data})
result
error ->
error
end
end
end
@impl true
def action?(key_data, resource, action) do
# First check if the key is enabled
case Map.get(key_data, :enabled, true) do
false -> false
true ->
# Delegate to core permission check logic for enabled keys
Enum.any?(key_data.permissions, fn
%{resource: ^resource, scopes: scopes} when is_list(scopes) -> action in scopes
_ -> false
end)
end
end
@impl true
def insert(key_data) do
with :ok <- MyApp.PostgresStore.insert(key_data) do
# Also update the cache
:ets.insert(:pike_keys_cache, {key_data.key, key_data})
:ok
end
end
# Cache initialization
def init do
:ets.new(:pike_keys_cache, [:set, :public, :named_table])
:ok
end
# Cache invalidation
def invalidate(key) do
:ets.delete(:pike_keys_cache, key)
:ok
end
# Warm cache from database
def warm_cache do
MyApp.Repo.all(MyApp.ApiKey)
|> Enum.each(fn api_key ->
key_data = map_from_schema(api_key)
:ets.insert(:pike_keys_cache, {key_data.key, key_data})
end)
end
end3. Redis-Backed Store
For distributed systems, Redis provides a shared storage solution:
defmodule MyApp.RedisStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
case Redix.command(:redix, ["GET", "pike:keys:#{key}"]) do
{:ok, nil} -> :error
{:ok, json} ->
{:ok, Jason.decode!(json, keys: :atoms)}
{:error, _} -> :error
end
end
@impl true
def action?(key_data, resource, action) do
# First check if the key is enabled
case Map.get(key_data, :enabled, true) do
false -> false
true ->
# Check permissions from the key_data structure
Enum.any?(key_data.permissions, fn
%{resource: ^resource, scopes: scopes} when is_list(scopes) -> action in scopes
_ -> false
end)
end
end
@impl true
def insert(key_data) do
json = Jason.encode!(key_data)
case Redix.command(:redix, ["SET", "pike:keys:#{key_data.key}", json]) do
{:ok, "OK"} -> :ok
error -> {:error, error}
end
end
end4. Dynamic Rules Store
Add runtime configuration of permission rules:
defmodule MyApp.DynamicRulesStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
# Basic key retrieval
Pike.Store.ETS.get_key(key)
end
@impl true
def action?(key_data, resource, action) do
# First check global rules
with false <- global_rule_allows?(key_data, resource, action),
# Then check the key's specific permissions
false <- Pike.Store.ETS.action?(key_data, resource, action) do
false
else
true -> true
end
end
defp global_rule_allows?(key_data, resource, action) do
# Check for company-wide or role-based rules that
# might allow this action regardless of specific permissions
# ...
end
end5. External Auth Service Integration
Delegate authentication to an external service:
defmodule MyApp.ExternalAuthStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
case HTTPoison.get("https://auth.example.com/validate", [], headers: [{"Authorization", "Bearer #{key}"}]) do
{:ok, %{status_code: 200, body: body}} ->
{:ok, Jason.decode!(body, keys: :atoms)}
_error ->
:error
end
end
@impl true
def action?(key_data, resource, action) do
# First check if the key is enabled
case Map.get(key_data, :enabled, true) do
false -> false
true ->
# Check permissions from the key_data structure
Enum.any?(key_data.permissions, fn
%{resource: ^resource, scopes: scopes} when is_list(scopes) -> action in scopes
_ -> false
end)
end
end
end6. Multi-Tenant Store
Support different permissions per tenant:
defmodule MyApp.MultiTenantStore do
@behaviour Pike.Store
@impl true
def get_key(key) do
case Repo.get_by(ApiKey, key: key) do
nil -> :error
api_key ->
tenant_id = api_key.tenant_id
permissions = load_tenant_permissions(tenant_id, api_key.role)
{:ok, %{
key: api_key.key,
tenant_id: tenant_id,
permissions: permissions
}}
end
end
@impl true
def action?(key_data, resource, action) do
# Check permissions, scoped to tenant
# ...
end
defp load_tenant_permissions(tenant_id, role) do
# Load the permissions specific to this tenant and role
# ...
end
endImplementation Considerations
When implementing your own store, consider the following aspects:
Performance
API key validation happens on every request, so optimize for read performance:
- Use in-memory caching for frequently accessed keys
- Minimize network calls and database queries
- Consider background loading/refreshing of keys
Security
Protect sensitive API key information:
- Encrypt keys at rest if storing in a database
- Use secure channels for communication with external services
- Implement proper error handling to avoid leaking information
Consistency
For distributed systems:
- Ensure consistent behavior across nodes
- Consider cache invalidation strategies
- Use appropriate locking or versioning for updates
Testability
Make your store implementation easy to test:
- Allow dependency injection
- Provide a way to mock external dependencies
- Include comprehensive tests for permission logic
Example: Complete Store Implementation
Here's a complete example of a production-ready store with caching and database persistence:
defmodule MyApp.ApiKeyStore do
@behaviour Pike.Store
use GenServer
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl Pike.Store
def get_key(key) do
case :ets.lookup(:pike_keys_cache, key) do
[{^key, key_data}] ->
{:ok, key_data}
[] ->
GenServer.call(__MODULE__, {:db_get_key, key})
end
end
@impl Pike.Store
def action?(key_data, resource, action) do
Enum.any?(key_data.permissions, fn permission ->
permission.resource == resource &&
Enum.member?(permission.scopes, action)
end)
end
@impl Pike.Store
def insert(key_data) do
GenServer.call(__MODULE__, {:insert, key_data})
end
# Server callbacks
@impl GenServer
def init(_opts) do
:ets.new(:pike_keys_cache, [:set, :public, :named_table])
# Initial load from database
load_keys_from_database()
# Schedule periodic refresh
schedule_refresh()
{:ok, %{last_refresh: DateTime.utc_now()}}
end
@impl GenServer
def handle_call({:db_get_key, key}, _from, state) do
result = case MyApp.Repo.get_by(ApiKey, key: key, active: true) do
nil -> :error
api_key ->
key_data = convert_to_key_data(api_key)
# Check if the key is disabled
case Map.get(key_data, :enabled, true) do
false -> {:error, :disabled}
true ->
:ets.insert(:pike_keys_cache, {key, key_data})
{:ok, key_data}
end
end
{:reply, result, state}
end
@impl GenServer
def handle_call({:insert, key_data}, _from, state) do
result = MyApp.Repo.transaction(fn ->
changeset = ApiKey.changeset(%ApiKey{}, %{
key: key_data.key,
permissions: serialize_permissions(key_data.permissions),
active: true
})
case MyApp.Repo.insert(changeset) do
{:ok, _api_key} ->
:ets.insert(:pike_keys_cache, {key_data.key, key_data})
:ok
{:error, changeset} ->
{:error, changeset}
end
end)
{:reply, result, state}
end
@impl GenServer
def handle_info(:refresh, state) do
load_keys_from_database()
schedule_refresh()
{:noreply, %{state | last_refresh: DateTime.utc_now()}}
end
# Private functions
defp load_keys_from_database do
MyApp.Repo.all(ApiKey)
|> Enum.each(fn api_key ->
key_data = convert_to_key_data(api_key)
:ets.insert(:pike_keys_cache, {api_key.key, key_data})
end)
end
defp schedule_refresh do
# Refresh cache every hour
Process.send_after(self(), :refresh, 60 * 60 * 1000)
end
defp convert_to_key_data(api_key) do
%{
key: api_key.key,
enabled: api_key.active,
permissions: deserialize_permissions(api_key.permissions)
}
end
defp serialize_permissions(permissions) do
Jason.encode!(permissions)
end
defp deserialize_permissions(json) do
Jason.decode!(json, keys: :atoms)
|> Enum.map(fn permission ->
%{
resource: permission.resource,
scopes: Enum.map(permission.scopes, &String.to_atom/1)
}
end)
end
endConclusion
Rolling your own Pike store provides tremendous flexibility to tailor the authentication and authorization system to your specific needs. Whether you're optimizing for performance, integrating with existing systems, or implementing complex permission rules, a custom store allows you to extend Pike while maintaining its clean API.
Remember to carefully test your store implementation, especially around edge cases in permission checks, as these are critical to your application's security.