howdy/authorization

Role-based authorization, independent of login methods and HTTP transport. Grants apply only to their exact scope. Global grants do not imply access to organizations. No role name has implicit privileges or wildcard powers.

Types

pub opaque type Authorization

A defined role and its permissions.

pub type Role {
  Role(scope: Scope, name: String, permissions: List(String))
}

Constructors

  • Role(scope: Scope, name: String, permissions: List(String))
pub type Scope {
  Global
  Organization(String)
}

Constructors

  • Global
  • Organization(String)

Values

pub fn allowed(
  access: Authorization,
  principal: user.Principal,
  permission: String,
  scope: Scope,
) -> Result(Bool, service.Error)

RBAC permission check. Any currently assigned role may grant a permission. Unknown permissions and scopes deny access; database errors remain errors.

pub fn assign(
  access: Authorization,
  user_id: String,
  role: String,
  scope: Scope,
  by actor: user.Actor,
) -> Result(Nil, service.Error)

Assign an existing role to an existing user in exactly this scope. Privileged: the application must authorize the caller first.

pub fn assignments(
  access: Authorization,
  user_id: String,
) -> Result(List(#(Scope, String)), service.Error)

The roles a user holds, as scope and role name, for management consoles. Suspension does not remove assignments; checks ignore them while it lasts.

pub fn define_role(
  access: Authorization,
  scope: Scope,
  name: String,
  permissions: List(String),
  by actor: user.Actor,
) -> Result(Nil, service.Error)

Define or replace a role’s permissions atomically. This is a privileged management operation, not a public endpoint. Existing assignments remain.

pub fn delete_role(
  access: Authorization,
  scope: Scope,
  name: String,
  by actor: user.Actor,
) -> Result(Nil, service.Error)

Remove a role and every assignment of it. Privileged: authorize the caller first. NotFound when there is no such role.

pub fn has_role(
  access: Authorization,
  principal: user.Principal,
  role: String,
  scope: Scope,
) -> Result(Bool, service.Error)

Simple role check. The principal must come from authentication.

pub fn holders(
  access: Authorization,
  scope: Scope,
  role: String,
) -> Result(List(String), service.Error)

The ids of the users assigned a role, for management consoles.

pub fn new(
  repo: repo.Repo,
) -> Result(Authorization, service.Error)
pub fn require_permission(
  access: Authorization,
  permission: String,
  scope: Scope,
) -> fn(context.Context(user.Principal)) -> Result(
  Nil,
  service.Error,
)

Use with guard.require after auth.required. Use a resource’s validated organization ID for scope, not an unverified client-provided tenant header.

pub fn require_role(
  access: Authorization,
  role: String,
  scope: Scope,
) -> fn(context.Context(user.Principal)) -> Result(
  Nil,
  service.Error,
)
pub fn revoke(
  access: Authorization,
  user_id: String,
  role: String,
  scope: Scope,
  by actor: user.Actor,
) -> Result(Nil, service.Error)

Revoke a role immediately. Privileged: authorize the caller first.

pub fn roles(
  access: Authorization,
) -> Result(List(Role), service.Error)

Every defined role in every scope, with its permissions, for management consoles. Roles come sorted by scope then name.

pub fn scope_from_string(text: String) -> Result(Scope, Nil)

Parse what scope_to_string produced.

pub fn scope_to_string(scope: Scope) -> String

The text a scope is stored and displayed as: global, or org:<id>.

pub fn with_cache(
  access: Authorization,
  seconds seconds: Int,
) -> Result(Authorization, service.Error)

Opt into at most seconds (1–60) of stale authorization on changes made from other BEAM nodes or direct SQL. Local grant/suspension changes invalidate all caches. Construct once at application startup; authenticate every request before consulting authorization. The default always reads the database.

pub fn with_changes(run: fn() -> a) -> a

Wrap application-owned grant data changes, or a transaction opened directly through gloo/repo or pog, so invalidation happens after its actual commit/rollback, not a savepoint. Transactions opened with howdy/database or howdy/database/postgres need no wrapper. Cached decisions are bypassed inside the callback. Without this wrapper, direct SQL/external transactions have the same bounded TTL as remote nodes.

✨ Search Document