Licet (Licet v0.1.0)

Copy Markdown View Source

Relationship-based authorization, stored in your Postgres database.

This module is the whole runtime API: start an instance, write relationships, and ask questions about them. Getting started walks through a first setup, and Concepts explains the vocabulary.

Starting an instance

Add an instance to your supervision tree, after the repo it uses:

children = [
  MyApp.Repo,
  {Licet,
   name: MyApp.Licet,
   repo: MyApp.Repo,
   schema: {:file, Application.app_dir(:my_app, "priv/licet/schema.zed")}}
]

The tables must exist first. Create them with mix licet.gen.migration.

Instance names

Every function takes the instance name as its first argument. It is the atom given in the child spec, usually a module-like name such as MyApp.Licet. Choose it in code, never from user input. Calling a function on a name that isn't running returns {:error, %Licet.Error{reason: :not_started}}.

Options

  • :name (atom/0) - Required. The instance name, passed as the first argument of every Licet function.

  • :repo (atom/0) - Required. The Ecto.Repo that holds Licet's tables.

  • :prefix (String.t/0) - The Postgres schema that holds the tables. It must match ^[a-z_][a-z0-9_]{0,62}$. The default value is "licet".

  • :schema - The schema to load at startup, as {:file, path} or {:string, source}. When it differs from the stored schema, it is stored as a new version, unless the change would strand stored relationships. nil keeps the stored schema. The default value is nil.

  • :quantization (pos_integer/0) - Milliseconds between refreshes of the snapshot that :minimize_latency reads. The default value is 5000.

  • :timeout (pos_integer/0) - Milliseconds allowed for one check or lookup. The default value is 5000.

  • :max_depth (pos_integer/0) - Arrow and userset hops allowed in one check. The default value is 50.

  • :max_lookup (pos_integer/0) - Results one lookup may compute before failing with :lookup_too_large. The default value is 10000.

  • :max_updates (pos_integer/0) - Updates allowed in one Licet.write/3. The default value is 1000.

  • :max_import (pos_integer/0) - Relationships allowed in one Licet.import/3. The default value is 10000.

  • :max_preconditions (pos_integer/0) - Preconditions allowed on one write. The default value is 1000.

  • :gc_interval - Milliseconds between garbage collections, or false to disable them. The default value is 300000.

  • :gc_window (pos_integer/0) - Seconds of history to keep. Exact reads of older tokens fail. The default value is 86400.

  • :gc_batch (pos_integer/0) - Rows deleted per garbage collection batch. The default value is 1000.

Objects and relationships

Functions take objects and relationships as text, in SpiceDB's format:

  • an object is type:id, such as "document:roadmap";
  • a userset subject is type:id#relation, such as "team:eng#member";
  • a wildcard subject is type:*, such as "user:*";
  • a relationship is resource#relation@subject, such as "document:roadmap#viewer@team:eng#member", optionally followed by [expiration:2027-01-01T00:00:00Z].

Ids are 1 to 1024 characters from A-Z, a-z, 0-9, and /_|=-+. Type and relation names are lowercase, 3 to 64 characters, and types may be namespaced as prefix/name, up to 128 characters. Licet.Relationship parses and prints the relationship format.

Consistency

Writes return a token. Reads take a :consistency option: :minimize_latency (the default), {:at_least, token}, {:exact, token}, or :full. See Consistency.

Transactions

Writes join the caller's Repo.transaction/2 when one is open. A check in that transaction sees its uncommitted writes. See Transactions.

Errors

Every function returns {:error, %Licet.Error{}} on failure, with a reason atom you can match on. Licet.Error lists the reasons.

Summary

Types

Which snapshot of the data a read sees. See Consistency.

Selects relationships by any of their fields. Fields that are absent match anything. A list matches any of its values. Other keys, and nil values, are rejected with :invalid_filter.

The name of a running instance, as given in its child spec.

An opaque consistency token returned by writes.

One relationship change for write/3.

Functions

Checks whether subject has permission on resource.

Runs several checks at one snapshot.

Deletes every stored relationship that matches filter and returns a token.

Runs a check and returns how it was decided.

Creates many relationships in one transaction and returns a token.

Lists the ids of resources of type on which subject has permission.

Lists the subjects of subject_type that have permission on resource.

Returns the stored relationships that match filter.

Starts an instance and links it to the current process.

Applies a batch of relationship updates atomically and returns a token.

Replaces the schema at runtime and returns a token.

Types

consistency()

@type consistency() ::
  :minimize_latency | {:at_least, token()} | {:exact, token()} | :full

Which snapshot of the data a read sees. See Consistency.

filter()

@type filter() :: %{
  optional(:resource_type) => String.t() | [String.t(), ...],
  optional(:resource_id) => String.t() | [String.t(), ...],
  optional(:relation) => String.t() | [String.t(), ...],
  optional(:subject_type) => String.t() | [String.t(), ...],
  optional(:subject_id) => String.t() | [String.t(), ...],
  optional(:subject_relation) => String.t() | [String.t(), ...]
}

Selects relationships by any of their fields. Fields that are absent match anything. A list matches any of its values. Other keys, and nil values, are rejected with :invalid_filter.

instance()

@type instance() :: atom()

The name of a running instance, as given in its child spec.

token()

@type token() :: String.t()

An opaque consistency token returned by writes.

update()

@type update() :: {:create | :touch | :delete, String.t() | Licet.Relationship.t()}

One relationship change for write/3.

Functions

check(name, resource, permission, subject, opts \\ [])

@spec check(instance(), String.t(), String.t(), String.t(), keyword()) ::
  {:ok, :allowed | :denied} | {:error, Licet.Error.t()}

Checks whether subject has permission on resource.

resource is an object such as "document:1". permission is a permission or relation of the resource's type. subject is an object such as "user:ada", or a userset such as "team:eng#member".

Returns {:ok, :allowed} or {:ok, :denied}. Treat {:error, _} as a denial.

Options

  • :consistency - which snapshot to read. Defaults to :minimize_latency, which is cached and can be a few seconds behind the latest write.

Errors

  • :invalid_relationship - malformed object text, or a wildcard resource
  • :unknown_type, :unknown_relation - not in the schema
  • :depth_exceeded, :cycle - the evaluation went deeper than max_depth or revisited itself
  • :timeout - longer than the instance timeout
  • :token_invalid, :token_not_committed, :snapshot_expired - see Consistency

Examples

Licet.check(MyApp.Licet, "document:1", "view", "user:ada")
#=> {:ok, :allowed}

Licet.check(MyApp.Licet, "document:1", "view", "user:bob", consistency: {:at_least, token})
#=> {:ok, :denied}

check_bulk(name, checks, opts \\ [])

@spec check_bulk(instance(), [map()], keyword()) ::
  {:ok, [result]} | {:error, Licet.Error.t()}
when result: {:ok, :allowed | :denied} | {:error, Licet.Error.t()}

Runs several checks at one snapshot.

Each check is a map with :resource, :permission, and :subject, as in check/5. The results come back in the same order. A failing check, including an item that isn't such a map, returns its own {:error, _} without stopping the others; the call as a whole fails only when the snapshot can't be resolved.

All checks read the same snapshot, so their answers are consistent with each other.

Options

  • :consistency - which snapshot to read. Defaults to :minimize_latency.

Examples

Licet.check_bulk(MyApp.Licet, [
  %{resource: "document:1", permission: "view", subject: "user:ada"},
  %{resource: "document:2", permission: "view", subject: "user:ada"},
  %{resource: "document:*", permission: "view", subject: "user:ada"}
])
#=> {:ok, [{:ok, :allowed}, {:ok, :denied}, {:error, %Licet.Error{reason: :invalid_relationship}}]}

delete(name, filter, opts \\ [])

@spec delete(instance(), filter(), keyword()) ::
  {:ok, token()} | {:error, Licet.Error.t()}

Deletes every stored relationship that matches filter and returns a token.

The filter must include :resource_type, so one call can't wipe every relationship by accident. Use it to clean up after a record is deleted, or to remove relationships before narrowing the schema.

Deleted relationships stay readable at older snapshots until garbage collection removes them, after gc_window.

Unlike write/3, a delete isn't bounded by max_updates: it removes every match in one statement, and it emits no telemetry event. Narrow the filter when a match could be very large.

Options

  • :metadata - a map stored with the write's transaction record.

Errors

  • :invalid_filter - the filter has no :resource_type, names an unknown field, or has a value that isn't a string or a list of strings

Examples

# Everything about document 1.
Licet.delete(MyApp.Licet, %{resource_type: "document", resource_id: "1"})

# Every relationship that names Bob on any document.
Licet.delete(MyApp.Licet, %{resource_type: "document", subject_type: "user", subject_id: "bob"})

explain(name, resource, permission, subject, opts \\ [])

@spec explain(instance(), String.t(), String.t(), String.t(), keyword()) ::
  {:ok, :allowed | :denied, map()} | {:error, Licet.Error.t()}

Runs a check and returns how it was decided.

Takes the same arguments and options as check/5, and returns the answer with a trace. Each trace node is a map with the :resource and :permission it evaluated, its :result, and the :children evaluated beneath it.

An allowed trace follows the path that granted access, down to the relationship that matched. A denied trace is shallower: it lists the relations checked directly on the resource, but omits the objects an arrow or userset reached without finding the subject.

Explain never uses the cache and evaluates arrows sequentially, so it's slower than check/5. Use it for debugging.

Examples

Licet.explain(MyApp.Licet, "document:1", "view", "user:ada")
#=> {:ok, :allowed,
#=>  %{resource: "document:1", permission: "view", result: :allowed, children: [...]}}

import(name, relationships, opts \\ [])

@spec import(instance(), [String.t() | Licet.Relationship.t()], keyword()) ::
  {:ok, token()} | {:error, Licet.Error.t()}

Creates many relationships in one transaction and returns a token.

Like write/3 with only :create updates, but limited by the instance max_import (10,000 by default) instead of max_updates. Use it to load existing data, for example when migrating from SpiceDB. The whole batch fails with :already_exists if any relationship is already stored.

Takes the same options and returns the same errors as write/3.

Examples

Licet.import(MyApp.Licet, [
  "document:1#owner@user:ada",
  "document:2#owner@user:ada",
  "document:2#viewer@team:eng#member"
])

lookup_resources(name, type, permission, subject, opts \\ [])

@spec lookup_resources(instance(), String.t(), String.t(), String.t(), keyword()) ::
  {:ok, [String.t()], String.t() | nil} | {:error, Licet.Error.t()}

Lists the ids of resources of type on which subject has permission.

Use it to filter a list page to what the current user may see. Ids are returned as strings, sorted, one page at a time. The third element is a cursor for the next page, or nil on the last page.

The full result is computed before a page is returned, and the call fails with :lookup_too_large when it exceeds the instance max_lookup.

Options

  • :limit - the page size. Defaults to 100.
  • :cursor - the cursor from the previous page.
  • :consistency - which snapshot to read. Defaults to :minimize_latency. Pass the same value for every page.

Errors

  • :invalid_relationship - malformed subject text
  • :unknown_type, :unknown_relation - not in the schema
  • :lookup_too_large, :depth_exceeded, :timeout - over an instance limit
  • :invalid_filter - a malformed cursor, or a :limit that isn't a positive integer

Examples

{:ok, ids, cursor} = Licet.lookup_resources(MyApp.Licet, "document", "view", "user:ada", limit: 2)
#=> {:ok, ["1", "2"], "v1.IjIi"}

{:ok, more, nil} =
  Licet.lookup_resources(MyApp.Licet, "document", "view", "user:ada", limit: 2, cursor: cursor)

lookup_subjects(name, resource, permission, subject_type, opts \\ [])

@spec lookup_subjects(instance(), String.t(), String.t(), String.t(), keyword()) ::
  {:ok, [%{subject: String.t(), excluded: [String.t()]}], String.t() | nil}
  | {:error, Licet.Error.t()}

Lists the subjects of subject_type that have permission on resource.

Use it to show who has access, for example in a sharing dialog. subject_type is an object type such as "user", or a userset type such as "team#member".

Each result is a map with the subject and the subjects it excluded. Only a wildcard excludes anything: %{subject: "user:*", excluded: ["user:dan"]} means every user except Dan. Concrete subjects are listed even when a wildcard also covers them. A userset counts as a member of itself, so "team:eng#member" is listed for "team#member" when the permission reaches it. This matches SpiceDB's LookupSubjects.

Results are sorted by subject and paginated like lookup_resources/5.

Options

  • :limit - the page size. Defaults to 100.
  • :cursor - the cursor from the previous page.
  • :consistency - which snapshot to read. Defaults to :minimize_latency.

Errors

  • :invalid_relationship - malformed resource text
  • :invalid_filter - a malformed subject type or cursor, or a :limit that isn't a positive integer
  • :unknown_type, :unknown_relation - not in the schema
  • :lookup_too_large, :depth_exceeded, :timeout - over an instance limit

Examples

Licet.lookup_subjects(MyApp.Licet, "document:1", "view", "user")
#=> {:ok, [%{subject: "user:*", excluded: ["user:dan"]}, %{subject: "user:tom", excluded: []}], nil}

Licet.lookup_subjects(MyApp.Licet, "document:1", "view", "team#member")
#=> {:ok, [%{subject: "team:eng#member", excluded: []}], nil}

read(name, filter, opts \\ [])

@spec read(instance(), filter(), keyword()) ::
  {:ok, [Licet.Relationship.t()]} | {:error, Licet.Error.t()}

Returns the stored relationships that match filter.

The filter must name at least one field. Expired relationships aren't returned. This reads stored relationships only; to ask whether a subject has a permission, use check/5.

Options

  • :consistency - which snapshot to read. Defaults to :minimize_latency.

Errors

  • :invalid_filter - the filter is empty, names an unknown field, or has a value that isn't a string or a list of strings
  • :token_invalid, :token_not_committed, :snapshot_expired - see Consistency

Examples

{:ok, relationships} = Licet.read(MyApp.Licet, %{resource_type: "document", resource_id: "1"})
Enum.map(relationships, &Licet.Relationship.to_string/1)
#=> ["document:1#owner@user:ada", "document:1#viewer@user:bob"]

start_link(opts)

@spec start_link(keyword()) :: Supervisor.on_start()

Starts an instance and links it to the current process.

Usually you add {Licet, opts} to a supervision tree instead of calling this directly. See the module documentation for the options.

The instance fails to start if the Licet tables don't exist, or if the configured schema is invalid or would strand stored relationships.

write(name, updates, opts \\ [])

@spec write(instance(), [update()], keyword()) ::
  {:ok, token()} | {:error, Licet.Error.t()}

Applies a batch of relationship updates atomically and returns a token.

Each update is one of:

  • {:create, relationship} stores a new relationship and fails with :already_exists if it is already stored;
  • {:touch, relationship} stores it, or updates the expiration of an existing one;
  • {:delete, relationship} removes it, and does nothing if it isn't stored.

A relationship is text such as "document:1#viewer@user:ada" or a Licet.Relationship. Every relationship is validated against the current schema before anything is written.

All updates apply together or not at all. Inside an open Repo.transaction/2, the write joins it; see Transactions.

Options

  • :preconditions - a list of {:must_match, filter} or {:must_not_match, filter}, checked inside the write's transaction. The write fails with :precondition_failed if any doesn't hold.
  • :metadata - a map stored with the write's transaction record.

Errors

  • :invalid_relationship - malformed text, or a subject or expiration the relation doesn't allow
  • :unknown_type, :unknown_relation - not in the schema
  • :already_exists - a :create of a stored relationship
  • :precondition_failed - a precondition didn't hold
  • :too_many_updates, :too_many_preconditions - over max_updates or max_preconditions
  • :invalid_filter - :preconditions isn't a list, or a precondition isn't {:must_match | :must_not_match, filter} with a valid filter

Examples

{:ok, token} =
  Licet.write(MyApp.Licet, [
    {:create, "document:1#owner@user:ada"},
    {:touch, "document:1#viewer@team:eng#member"},
    {:delete, "document:1#viewer@user:bob"}
  ])

# Make Ada the owner only if the document has none yet.
Licet.write(MyApp.Licet, [{:create, "document:1#owner@user:ada"}],
  preconditions: [
    {:must_not_match, %{resource_type: "document", resource_id: "1", relation: "owner"}}
  ]
)

write_schema(name, source, opts \\ [])

@spec write_schema(instance(), String.t(), keyword()) ::
  {:ok, token()} | {:error, Licet.Error.t()}

Replaces the schema at runtime and returns a token.

The new schema is parsed, then compared with the stored relationships. It is rejected with :unsafe_schema_change if it would leave some of them meaningless: a removed definition or relation that still has relationships, use expiration removed while relationships carry an expiry, or a relation that no longer accepts a subject type it still has. Adding definitions, relations, permissions, and subject types is always accepted.

Most applications set the schema with the :schema option instead, which applies the same rules at startup. See Operations.

The new schema applies on this node immediately. Other nodes running an instance on the same tables pick it up within quantization milliseconds. Unlike write/3, it can't join an open transaction, because a rollback would leave nodes using a schema that was never stored.

Options

  • :metadata - a map stored with the write's transaction record.

Errors

  • :invalid_schema - the text doesn't parse; details has the line and column
  • :unsafe_schema_change - the change would strand stored relationships
  • :in_transaction - called inside an open Repo.transaction/2

Examples

{:ok, token} = Licet.write_schema(MyApp.Licet, File.read!("priv/licet/schema.zed"))