loom.core.authz

Role-based authorization primitives, free of any product vocabulary.

A product declares its permissions and roles in code, stores grants (who holds which role on which scope) wherever it likes, and asks the pure functions here for decisions. Scopes are paths whose meaning the product chooses; a grant on a scope reaches everything below it.

class loom.core.authz.AuthzProduct(*args, **kwargs)[source]

Bases: Protocol

What loom needs from a product to decide, delegate and elevate.

class loom.core.authz.CompositionRules(custom_prefix, may_extend_base, non_extensible, ceiling)[source]

Bases: object

What a product allows custom roles to do on top of its base catalog.

Parameters:
custom_prefix

Prefix of every custom role name.

Type:

str

may_extend_base

Whether a custom role may extend a base role at all.

Type:

bool

non_extensible

Base roles no custom role may extend.

Type:

frozenset[str]

ceiling

The most a custom role may hold; the product passes the creator’s effective permissions at the boundary, never None.

Type:

frozenset[loom.core.authz._roles.Permission]

class loom.core.authz.Decision(allowed, grant)[source]

Bases: object

Outcome of an access check, truthy when allowed.

Parameters:
allowed

Whether the permission is held on the scope.

Type:

bool

grant

The grant that allowed it; None when denied.

Type:

loom.core.authz._grants.Grant | None

class loom.core.authz.Grant(subject, role, scope, expires_at=None)[source]

Bases: object

An assignment of a role to a subject on a scope, possibly until a moment.

Grants order by subject, role, scope and then expiry, with grants that never expire before those that do, soonest first.

Parameters:
subject

Opaque identifier of the holder, such as an identity subject or a service account id.

Type:

str

role

Name of the role; its permissions are looked up in the catalog at decision time.

Type:

str

scope

Where the role applies, including everything below it.

Type:

loom.core.authz._scope.Scope

expires_at

The timezone-aware instant from which the grant allows nothing; None when it never expires.

Type:

datetime.datetime | None

class loom.core.authz.GrantCheck(allowed, missing)[source]

Bases: object

Outcome of a delegation check, truthy when allowed.

Parameters:
allowed

Whether the granter may hand out or remove the role there.

Type:

bool

missing

Permissions the granter lacks on that scope.

Type:

frozenset[loom.core.authz._roles.Permission]

class loom.core.authz.GrantSource(*args, **kwargs)[source]

Bases: Protocol

Where the grants of a subject are loaded from.

Any class with this method satisfies the protocol structurally, with no inheritance required.

async grants_for(subject)[source]

Return every grant held by subject.

Parameters:

subject (str) – Identifier of the holder.

Returns:

The subject’s grants; empty when it holds none.

Return type:

Collection[Grant]

class loom.core.authz.InMemoryGrantSource(grants)[source]

Bases: object

A GrantSource over a fixed list, for tests and examples.

Parameters:

grants (Iterable[Grant])

async grants_for(subject)[source]

Return the grants of subject, in the order they were given.

Parameters:

subject (str) – Identifier of the holder.

Returns:

The subject’s grants.

Return type:

Collection[Grant]

class loom.core.authz.Permission(name)[source]

Bases: object

A capability the product protects, such as "catalog.read".

Parameters:

name (str)

name

Stable identifier of the capability.

Type:

str

class loom.core.authz.Role(name, permissions=())[source]

Bases: object

A named set of permissions.

Grants store the role name only, so changing a role here changes what every stored grant of it allows.

Parameters:
name

Stable identifier of the role.

Type:

str

permissions

What the role allows.

Type:

frozenset[loom.core.authz._roles.Permission]

class loom.core.authz.RoleCatalog(permissions, roles)[source]

Bases: object

Every permission and role a product declares, validated once.

Example:

read, operate = Permission("catalog.read"), Permission("etl.operate")
catalog = RoleCatalog(
    [read, operate],
    [Role("viewer", {read}), Role("operator", {read, operate})],
)
Parameters:
classmethod from_specs(permissions, specs)[source]

Build a catalog from declarative specs, flattening extends.

Parameters:
Returns:

The catalog, equal to one built from the flattened roles.

Raises:

ValueError – On a cycle, an unknown base or an undeclared permission.

Return type:

RoleCatalog

classmethod compose(base, custom, rules, *, namespace)[source]

Add custom roles to a base catalog under the product’s rules.

A custom role is named f"{rules.custom_prefix}{namespace}/{name}", may only combine declared permissions, never exceeds the ceiling, never replaces a base role and never extends a non-extensible one.

Parameters:
  • base (RoleCatalog) – The fixed catalog the product ships.

  • custom (Mapping[str, RoleSpec]) – Custom role name to spec.

  • rules (CompositionRules) – What custom roles may do.

  • namespace (str) – Where the custom roles belong, such as a boundary id.

Returns:

A catalog with the base roles, unchanged, plus the custom ones.

Raises:

ValueError – When the namespace, a name, an extension or a permission breaks the rules; the message names the offender.

Return type:

RoleCatalog

digest()[source]

Return a SHA-256 over the sorted role:perm,perm lines of the catalog.

Return type:

str

property permissions: frozenset[Permission]

Every declared permission.

property roles: tuple[Role, ...]

Every declared role, in declaration order.

role(name)[source]

Return the role declared as name.

Parameters:

name (str) – Role name.

Returns:

The declared role.

Raises:

UnknownRole – When no role has that name.

Return type:

Role

permissions_of(name)[source]

Return the permissions of role name, or None when undeclared.

Parameters:

name (str) – Role name.

Returns:

The role’s permissions, or None for a role the code does not declare.

Return type:

frozenset[Permission] | None

roles_with(permission)[source]

Return the names of the roles that include permission.

Parameters:

permission (Permission) – A declared permission.

Returns:

The role names; empty when no role includes it.

Raises:

UnknownPermission – When permission is not declared.

Return type:

frozenset[str]

require(permission)[source]

Reject a permission the product never declared.

Parameters:

permission (Permission) – The permission about to be checked.

Raises:

UnknownPermission – When permission is not declared.

Return type:

None

class loom.core.authz.RoleSpec(permissions=frozenset({}), extends=())[source]

Bases: object

A role declared as data: its own permissions plus the roles it extends.

Parameters:
permissions

Names of the permissions the role adds.

Type:

frozenset[str]

extends

Names of the roles whose permissions it inherits.

Type:

tuple[str, …]

class loom.core.authz.Scope(path)[source]

Bases: object

A node in the product’s resource hierarchy.

loom gives the segments no meaning: the product decides what each level is. A scope covers itself and everything below it, so a grant on Scope.of("acme") reaches Scope.of("acme", "sales", "orders") but never Scope.of("globex") nor the root.

str(scope) and parse() are inverse and stable, so the text form is safe to store.

Parameters:

path (tuple[str, ...])

path

Segments from the root down; empty for the root.

Type:

tuple[str, …]

Example:

workspace = Scope.of("acme")
table = workspace.child("sales", "orders")
assert workspace.covers(table)
assert Scope.parse(str(table)) == table
classmethod root()[source]

Return the scope that covers every other scope.

Returns:

The root scope.

Return type:

Scope

classmethod of(*segments)[source]

Build the scope at segments below the root.

Parameters:

*segments (str) – One or more path segments, outermost first.

Returns:

The scope at that path.

Raises:

ValueError – When no segment is given (use root()) or a segment is empty or contains /.

Return type:

Scope

classmethod parse(text)[source]

Read a scope back from its str() form.

Parameters:

text (str) – / for the root, or /a/b.

Returns:

The scope text renders.

Raises:

ValueError – When text does not start with /, ends with one (other than the root) or holds an empty segment.

Return type:

Scope

child(*segments)[source]

Return the scope at segments below this one.

Parameters:

*segments (str) – Path segments to append, outermost first.

Returns:

The nested scope.

Raises:

ValueError – When a segment is empty or contains /.

Return type:

Scope

covers(other)[source]

Report whether a grant on this scope reaches other.

Parameters:

other (Scope) – The scope being accessed.

Returns:

True when other is this scope or lies below it.

Return type:

bool

exception loom.core.authz.UnknownPermission[source]

Bases: LookupError

Raised when a permission is not declared in the catalog.

exception loom.core.authz.UnknownRole[source]

Bases: LookupError

Raised when a role name is not declared in the catalog.

loom.core.authz.can_grant(catalog, granter_grants, role, scope, *, delegate, now=None)[source]

Decide whether a granter may assign role on scope.

The granter needs delegate and every permission of role, each held on a scope covering scope by a grant that is alive at now.

Parameters:
  • catalog (RoleCatalog) – The product’s roles.

  • granter_grants (Iterable[Grant]) – The granter’s grants.

  • role (str) – Name of the role to assign.

  • scope (Scope) – Where it would apply.

  • delegate (Permission) – The permission that allows assigning roles at all.

  • now (datetime | None) – The timezone-aware instant of the check; needed only when some of the granter’s grants carry an expiry.

Returns:

The check, listing the permissions the granter lacks there.

Raises:
  • UnknownRole – When role is not declared in the catalog.

  • UnknownPermission – When delegate is not declared in the catalog.

  • TypeError – When now is not a datetime.

  • ValueError – When now is naive, or is None while some of the granter’s grants carry an expiry.

Return type:

GrantCheck

loom.core.authz.can_revoke(catalog, granter_grants, grant, *, delegate, now=None)[source]

Decide whether a granter may remove grant.

Revoking needs what granting it would, whether or not grant has expired. A grant whose role the code does not declare allows nothing, so removing it needs delegate only.

Parameters:
  • catalog (RoleCatalog) – The product’s roles.

  • granter_grants (Iterable[Grant]) – The granter’s grants.

  • grant (Grant) – The assignment to remove.

  • delegate (Permission) – The permission that allows assigning roles at all.

  • now (datetime | None) – The timezone-aware instant of the check; needed only when some of the granter’s grants carry an expiry.

Returns:

The check, listing the permissions the granter lacks there.

Raises:
  • UnknownPermission – When delegate is not declared in the catalog.

  • TypeError – When now is not a datetime.

  • ValueError – When now is naive, or is None while some of the granter’s grants carry an expiry.

Return type:

GrantCheck

loom.core.authz.evaluate(catalog, grants, permission, scope, *, now=None)[source]

Decide whether a subject holds permission on scope.

Denies unless one of grants names a role that includes permission on a scope covering scope. A grant whose role the catalog does not declare allows nothing. When several grants allow, the decision names the one on the deepest scope, then the first by role name and subject, regardless of the order of grants; among grants that tie there, the one that lives longest. A grant is alive while now is before its expiry; an expired grant counts as absent.

Parameters:
  • catalog (RoleCatalog) – The product’s roles.

  • grants (Iterable[Grant]) – The grants of one subject.

  • permission (Permission) – The capability being exercised.

  • scope (Scope) – Where it is exercised.

  • now (datetime | None) – The timezone-aware instant of the decision; needed only when some grant carries an expiry.

Returns:

The decision, carrying the grant that allowed it.

Raises:
  • UnknownPermission – When permission is not declared in the catalog.

  • TypeError – When now is not a datetime.

  • ValueError – When now is naive, or is None while some grant carries an expiry.

Return type:

Decision

loom.core.authz.load_authz_product()[source]

Return the registered product, else the one the loom.authz entry point names.

Return type:

AuthzProduct | None

loom.core.authz.scopes_with(catalog, grants, permission, *, now=None)[source]

Return the outermost scopes on which a subject holds permission.

A scope covered by another returned scope is left out, so a listing can be filtered with one Scope.covers() test per returned scope. An expired grant counts as absent.

Parameters:
  • catalog (RoleCatalog) – The product’s roles.

  • grants (Iterable[Grant]) – The grants of one subject.

  • permission (Permission) – The capability being listed.

  • now (datetime | None) – The timezone-aware instant of the listing; needed only when some grant carries an expiry.

Returns:

The minimal set of scopes covering everything the subject may reach.

Raises:
  • UnknownPermission – When permission is not declared in the catalog.

  • TypeError – When now is not a datetime.

  • ValueError – When now is naive, or is None while some grant carries an expiry.

Return type:

frozenset[Scope]