Skip to content

Authorization and identity

Arc decides what a caller may do. It does not decide who the caller is: that stays with the authentication you already have. Arc reads the result.

Declare the requirement on the artifact. With no declaration it is open.

authorize roles: ["editor"] # at least one of these roles
authorize :authenticated # any signed-in caller
authorize policy: :can_publish # a named policy
allow_anonymous() # explicitly open

On a read model, authorize applies to every query in the module. A single query overrides it: @query authorize: [roles: ["admin"]] or @query allow_anonymous: true.

A caller who is not signed in and hits a requirement gets isAuthorized: false with status 403 and the reason in authorizationFailureReason. Authorization runs before binding and validation, so a denied caller learns nothing about the request’s validity.

A policy is a module that decides from the full context. Register it by name on the application.

defmodule MyApp.Policies.CanPublish do
@behaviour Arc.Policy
@impl true
def authorize?(ctx, _subject), do: ctx.principal.claims["publisher"] == true
end
config :my_app, MyApp.Arc, policies: [can_publish: MyApp.Policies.CanPublish]

subject is the command struct for a command and :query for a query.

An Arc.Principal holds id, name, authenticated?, roles and claims. Arc builds one from your authentication through a chain of handlers, in the order of the authentication: option. The first handler that recognizes the request decides:

A handler returns Result
{:authenticated, principal} The chain stops with that principal
:failed Terminal: later handlers do not run and the caller gets a generic 401
:anonymous Not recognized; the next handler tries. Anonymous is final only when none recognized the request

The default handler, Arc.Authentication.FromAssigns, reads what your own plug assigned:

  • conn.assigns.arc_principal, an Arc.Principal;
  • conn.assigns.current_scope.user, the phx.gen.auth shape;
  • conn.assigns.current_user, a map with :id and optionally :name, :email, :roles.

So with phx.gen.auth, put Arc.Plug after the plug that fetches the current user and nothing else is needed. For tokens or a gateway, write a plug that assigns arc_principal, or implement Arc.Authentication:

defmodule MyApp.TokenAuthentication do
@behaviour Arc.Authentication
@impl true
def authenticate(conn) do
case Plug.Conn.get_req_header(conn, "authorization") do
["Bearer " <> token] ->
case MyApp.Tokens.verify(token) do
{:ok, user} -> {:authenticated, Arc.Principal.new(user.id, user.name, roles: user.roles)}
:error -> :failed
end
_ ->
:anonymous
end
end
end

The Phoenix sample’s Library.Web.DevAuth is a demo sign-in built the same way; it is for demonstration, not production.

Authentication establishes who is calling. Identity is what the UI shows about them: name, roles and application-specific details. The React packages read it from GET /.cratis/me, and LiveView reads the same data in process.

Identity is presentation data and never an authorization decision. It adds no trusted claims and protects no command or query; those authorize themselves.

Implement Arc.Identity.Provider. Arc finds it in your application, so there is nothing to register.

Samples/phoenix/lib/library/identity/provider.ex
use Arc.Identity.Provider, details: Library.Identity.IdentityDetails
@impl true
def details(%Arc.Identity.Context{name: name, principal: principal}) do
{:ok,
%Library.Identity.IdentityDetails{
display_name: "Hello, #{name}",
can_rename: "editor" in principal.roles
}}
end

details: names the type of the details, an Arc.Schema module:

Samples/phoenix/lib/library/identity/identity_details.ex
use Arc.Schema
schema do
field :display_name, :string, required: true
field :can_rename, :boolean, required: true
end

It does three jobs. /.cratis/identity-details/schema serves its JSON schema. Proxy generation turns it into a TypeScript class, so useIdentity is typed. And the wire uses its camelCase field names.

The callback receives an Arc.Identity.Context built from the request principal on every call, so cache expensive lookups inside it:

Field Meaning
id The identity identifier; "unknown" when the principal has none
name The display name
claims {type, value} string pairs from the principal, plus a {"role", role} pair per role
principal The full Arc.Principal

It returns {:ok, details}, or {:unauthorized, details} when the user is signed in but may not use the application at all. An Arc.Identity.Details struct says the same with a flag. An application without a provider does not serve /.cratis/me, and the client treats that as “no identity endpoint”. Give the application more than one provider and startup fails, naming them; pick one with the identity_details: option.

Caller Status Body
Not signed in 401 empty
Signed in, provider says :unauthorized 403 empty
Signed in and allowed 200 {id, name, isAuthenticated, isAuthorized, roles, details}

Every response is private: Cache-Control: no-store, private and Vary: Cookie, because it holds per-user data. Do not let a shared cache override that. Arc does not read or write an identity cookie.

assign_identity/2 assigns the same Arc.Identity.Result the endpoint would serve. See LiveView.

An Arc.Filter is a cross-cutting rule that runs for every command and query, after authorization and before validation. It sees the context and the descriptor of the artifact, and answers :ok or {:unauthorized, reason}. Register the filters on the application with filters: [...]. This one requires a role of every artifact in one namespace, without the artifacts saying anything about it:

Samples/showcase/lib/samples/cross_cutting_authorization/filter.ex
@behaviour Arc.Filter
@protected ["Samples", "CrossCuttingAuthorization"]
@required_role "CrossCuttingAuthorization"
@impl true
def on_command(ctx, %Arc.Descriptor{namespace: @protected}), do: check(ctx)
def on_command(_ctx, _descriptor), do: :ok
@impl true
def on_query(ctx, %Arc.Descriptor{namespace: @protected}, _query), do: check(ctx)
def on_query(_ctx, _descriptor, _query), do: :ok

ctx.query holds the paging and sorting of a query, for a filter that cares. An unauthorized result is a 403 and the artifact never runs.

Tools such as Lens read /.cratis/users and /.cratis/tenants to offer users and tenants to sign in as. Implement Arc.Identity.UsersProvider and Arc.Identity.TenantsProvider in development-only modules; Arc discovers them and concatenates what they return.

Samples/phoenix/lib/library/identity/dev_users.ex
@behaviour Arc.Identity.UsersProvider
@impl true
def users do
[
%Arc.Identity.User{user_id: "ada", user_details: "ada", roles: []},
%Arc.Identity.User{user_id: "ed", user_details: "ed", roles: ["editor"]}
]
end

A user is listed on the wire as a microsoftIdentity client principal (identityProvider, userId, userDetails, userRoles, claims) plus details; a tenant as {id, name}. A list is not proof of authentication or membership, and it is not how a request’s tenant is chosen. Keep these modules out of production builds.

Arc.Authentication.MicrosoftIdentityPlatform authenticates from the Microsoft identity platform headers an ingress supplies, and from the same headers Lens injects in development. It matches Arc on .NET:

Header Content
x-ms-client-principal The base64 client principal: identityProvider, userId, userDetails, userRoles, claims
x-ms-client-principal-id The identifier; the RFC 8187 UTF-8''… form is percent-decoded
x-ms-client-principal-name The name, used when userDetails is empty

All three headers are required on the unsigned path. The id comes from x-ms-client-principal-id, the name from userDetails, and the roles from userRoles and from claims of the type http://schemas.microsoft.com/ws/2008/06/identity/claims/role. The identity provider is kept as the claim urn:cratis:arc:identity:provider. A payload that is not valid base64 JSON fails the request with 401.

config :my_app, MyApp.Arc,
authentication: [Arc.Authentication.MicrosoftIdentityPlatform, Arc.Authentication.FromAssigns],
trust_forwarded_identity_headers: true

The signed envelope lets the application verify that the identity headers came from your ingress, so it does not have to trust the network. Arc verifies it exactly as Arc on .NET does: an HMAC-SHA256 over a canonical form of the request, carried in x-cratis-forwarded-identity-signature as v1.<keyId>.<timestamp>.<nonce>.<mac>.

config :my_app, MyApp.Arc,
authentication: [Arc.Authentication.MicrosoftIdentityPlatform],
trust_forwarded_identity_headers: true,
forwarded_identity: [
enabled: true,
audience: "my-app",
keys: [[key_id: "k1", secret_base64: System.fetch_env!("ARC_FORWARDED_IDENTITY_KEY")]]
]

Each secret is exactly 32 random bytes, standard-base64 encoded. Configure one to eight keys, so a key can be rotated while the old one still verifies. With the envelope enabled, a request with identity headers but no valid signature is rejected with 401; the headers alone are never enough.

The MAC covers the audience, key id, timestamp, nonce, method, canonical path and query, the tenant header, and the identity headers, so none can be changed in flight. A signature is accepted for 60 seconds in the past and 30 seconds ahead, and a nonce can be used once per audience and key.

An ingress can let a support person or a service act as a user. It adds a signed assertion that names the actor, and Arc then answers with the user as the principal and the actor beside it, as Arc on .NET does.

config :my_app, MyApp.Arc,
trust_forwarded_identity_headers: true,
on_behalf_of: [
enabled: true,
issuer: "https://auth.example.com",
audience: "my-app",
service: "my-app",
clock_skew: 30,
signing_keys: [[key_id: "authproxy-2026", public_key_pem: File.read!("authproxy.pub.pem")]]
]

The assertion is an RS256 JWS. Arc checks the signature against RSA public keys of at least 2048 bits (a private key is refused), then the issuer, audience, optional service, the key id, a lifetime of at most five minutes, a unique jti, the HTTP method and path, the tenant, and hashes that bind the assertion to the user and actor headers. Any failure is a 401 with a fixed message and never reveals which check failed.

On success Arc.Principal.actor/1 returns the acting principal and Arc.Principal.acted_on_behalf_of?/1 is true. Chronicle records the actor as the cause of an append, on behalf of the user. Without the on_behalf_of option, a request that carries an assertion is rejected rather than ignored.

/.cratis/commands, /.cratis/queries, /.cratis/identity-details/schema, /.cratis/users and /.cratis/tenants list your operations, schemas and development fixtures to anyone who can reach them. By default they require an authenticated caller (401 otherwise). Set introspection: :anonymous for development, or introspection: false to remove all five. /.cratis/me is not affected. See Configuration.