Skip to content

tempestweb.access

The role → permission map and the token-claim reader, so the view can decide what to draw. Modes A and B (Mode C refuses the import at build time). This is not authorization: the server decides whether a request may happen; this decides whether a button is drawn.

Guide with examples: Permissions in the view.

tempestweb.access

Deciding what the view draws, from the permissions a user carries.

The tempest-fastapi-sdk validates the token and the server decides what a request may do. On the screen side there was nothing: an app that shows "Delete user" only to admins spread if state.role == "admin" across the view, and read the permission list out of the JWT with json.loads in some corner.

Two pieces close that, and neither of them is authorization:

  • :func:unverified_access_from_token reads roles, permissions and expiry off a JWT without checking the signature — there is nothing to check it with in the browser, and the server already did.
  • :class:AccessControl holds the role → permission map once, and resolving it gives an :class:Access the view asks questions of.
Example
from tempestweb.access import AccessControl

ACCESS = AccessControl(
    roles={
        "admin": ["users:*", "audit:read"],
        "viewer": ["users:read"],
    }
)

access = ACCESS.for_roles(["viewer"])
access.can("users:read")  # True
access.can("users:delete")  # False — "users:read" is not "users:*"

The view then asks the question where it draws: if access.can("users:delete"): children.append(Button(...)). A full screen is in the recipe.

Hiding a button is not access control

Everything here runs where the user can change it. A screen that draws no Delete button still sits in front of an endpoint that deletes, and reaching that endpoint takes a terminal, not an exploit. The pair is:

Where Decides With
Server (tempest-fastapi-sdk) whether the request may happen the key
Here whether the button is drawn unverified claims

If the server does not enforce it, it is not enforced.

Modes A and B only

Mode C transpiles the app's own Python into JavaScript and serves a fixed set of modules — tempest_core, tempestweb.components and tempestweb.native. Importing this package from a Mode C app is refused at build time with a named error.

Import everything from this package level rather than from submodules.

ClaimNames dataclass

Which claims to read, for a server that names them differently.

Attributes:

Name Type Description
roles str

The claim holding role names.

permissions str

The claim holding explicit permissions.

scope str

The claim holding space-separated OAuth scopes.

Source code in tempestweb/access/claims.py
@dataclass(frozen=True)
class ClaimNames:
    """Which claims to read, for a server that names them differently.

    Attributes:
        roles: The claim holding role names.
        permissions: The claim holding explicit permissions.
        scope: The claim holding space-separated OAuth scopes.
    """

    roles: str = ROLES_CLAIM
    permissions: str = PERMISSIONS_CLAIM
    scope: str = SCOPE_CLAIM

TokenAccess dataclass

The access-related claims read off a token, unverified.

Attributes:

Name Type Description
roles tuple[str, ...]

The role names the token claims, in the order the claim listed them.

permissions tuple[str, ...]

The permissions the token claims directly, including any OAuth scopes.

expires_at float | None

The exp claim as UNIX seconds, or None when the token carries none.

Source code in tempestweb/access/claims.py
@dataclass(frozen=True)
class TokenAccess:
    """The access-related claims read off a token, unverified.

    Attributes:
        roles: The role names the token claims, in the order the claim listed
            them.
        permissions: The permissions the token claims directly, including any
            OAuth scopes.
        expires_at: The ``exp`` claim as UNIX seconds, or ``None`` when the
            token carries none.
    """

    roles: tuple[str, ...] = ()
    permissions: tuple[str, ...] = ()
    expires_at: float | None = None

    def is_expired(self, *, now: float, leeway_seconds: float = 0.0) -> bool:
        """Report whether the token's expiry has passed.

        Reports rather than raises: an expired token is an ordinary state an app
        handles by refreshing, not an exceptional one.

        Args:
            now: The current UNIX time in seconds. Passed in rather than read
                from the clock so the caller owns the time source, and so a test
                pins expiry without freezing a clock.
            leeway_seconds: Treat the token as expired this many seconds early,
                to absorb clock skew.

        Returns:
            ``False`` when the token carries no ``exp`` claim — a token without
            an expiry does not expire.
        """
        if self.expires_at is None:
            return False
        return now + leeway_seconds >= self.expires_at

is_expired

is_expired(*, now: float, leeway_seconds: float = 0.0) -> bool

Report whether the token's expiry has passed.

Reports rather than raises: an expired token is an ordinary state an app handles by refreshing, not an exceptional one.

Parameters:

Name Type Description Default
now float

The current UNIX time in seconds. Passed in rather than read from the clock so the caller owns the time source, and so a test pins expiry without freezing a clock.

required
leeway_seconds float

Treat the token as expired this many seconds early, to absorb clock skew.

0.0

Returns:

Type Description
bool

False when the token carries no exp claim — a token without

bool

an expiry does not expire.

Source code in tempestweb/access/claims.py
def is_expired(self, *, now: float, leeway_seconds: float = 0.0) -> bool:
    """Report whether the token's expiry has passed.

    Reports rather than raises: an expired token is an ordinary state an app
    handles by refreshing, not an exceptional one.

    Args:
        now: The current UNIX time in seconds. Passed in rather than read
            from the clock so the caller owns the time source, and so a test
            pins expiry without freezing a clock.
        leeway_seconds: Treat the token as expired this many seconds early,
            to absorb clock skew.

    Returns:
        ``False`` when the token carries no ``exp`` claim — a token without
        an expiry does not expire.
    """
    if self.expires_at is None:
        return False
    return now + leeway_seconds >= self.expires_at

Access dataclass

What one user may do, already resolved.

Attributes:

Name Type Description
permissions frozenset[str]

The permissions the user carries, wildcards included. This is the raw grant set, not an expansion of it — users:* stays users:* and is matched at question time.

Source code in tempestweb/access/control.py
@dataclass(frozen=True)
class Access:
    """What one user may do, already resolved.

    Attributes:
        permissions: The permissions the user carries, wildcards included. This
            is the raw grant set, not an expansion of it — ``users:*`` stays
            ``users:*`` and is matched at question time.
    """

    permissions: frozenset[str]

    def can(self, permission: str) -> bool:
        """Report whether the user carries a permission.

        Args:
            permission: The permission to test, such as ``"users:delete"``.

        Returns:
            ``True`` when a grant covers it exactly or by wildcard. An empty
            string is never granted.
        """
        if not permission:
            return False
        return any(_grants(grant, permission) for grant in self.permissions)

    def can_any(self, *permissions: str) -> bool:
        """Report whether the user carries at least one of the permissions.

        Args:
            *permissions: The permissions to test.

        Returns:
            ``True`` when any one is granted. With no arguments, ``False`` —
            asking for nothing grants nothing.
        """
        return any(self.can(permission) for permission in permissions)

    def can_all(self, *permissions: str) -> bool:
        """Report whether the user carries every one of the permissions.

        Args:
            *permissions: The permissions to test.

        Returns:
            ``True`` when all are granted. With no arguments, ``True`` — the
            empty requirement is satisfied, which keeps
            ``can_all(*screen.requires)`` correct for a screen requiring nothing.
        """
        return all(self.can(permission) for permission in permissions)

can

can(permission: str) -> bool

Report whether the user carries a permission.

Parameters:

Name Type Description Default
permission str

The permission to test, such as "users:delete".

required

Returns:

Type Description
bool

True when a grant covers it exactly or by wildcard. An empty

bool

string is never granted.

Source code in tempestweb/access/control.py
def can(self, permission: str) -> bool:
    """Report whether the user carries a permission.

    Args:
        permission: The permission to test, such as ``"users:delete"``.

    Returns:
        ``True`` when a grant covers it exactly or by wildcard. An empty
        string is never granted.
    """
    if not permission:
        return False
    return any(_grants(grant, permission) for grant in self.permissions)

can_any

can_any(*permissions: str) -> bool

Report whether the user carries at least one of the permissions.

Parameters:

Name Type Description Default
*permissions str

The permissions to test.

()

Returns:

Type Description
bool

True when any one is granted. With no arguments, False

bool

asking for nothing grants nothing.

Source code in tempestweb/access/control.py
def can_any(self, *permissions: str) -> bool:
    """Report whether the user carries at least one of the permissions.

    Args:
        *permissions: The permissions to test.

    Returns:
        ``True`` when any one is granted. With no arguments, ``False`` —
        asking for nothing grants nothing.
    """
    return any(self.can(permission) for permission in permissions)

can_all

can_all(*permissions: str) -> bool

Report whether the user carries every one of the permissions.

Parameters:

Name Type Description Default
*permissions str

The permissions to test.

()

Returns:

Type Description
bool

True when all are granted. With no arguments, True — the

bool

empty requirement is satisfied, which keeps

bool

can_all(*screen.requires) correct for a screen requiring nothing.

Source code in tempestweb/access/control.py
def can_all(self, *permissions: str) -> bool:
    """Report whether the user carries every one of the permissions.

    Args:
        *permissions: The permissions to test.

    Returns:
        ``True`` when all are granted. With no arguments, ``True`` — the
        empty requirement is satisfied, which keeps
        ``can_all(*screen.requires)`` correct for a screen requiring nothing.
    """
    return all(self.can(permission) for permission in permissions)

AccessControl

The role → permission map, in one place.

Example

control = AccessControl(roles={"editor": ["posts:*"]}) control.for_roles(["editor"]).can("posts:publish") True control.for_roles(["ghost"]).can("posts:publish") False

Source code in tempestweb/access/control.py
class AccessControl:
    """The role → permission map, in one place.

    Example:
        >>> control = AccessControl(roles={"editor": ["posts:*"]})
        >>> control.for_roles(["editor"]).can("posts:publish")
        True
        >>> control.for_roles(["ghost"]).can("posts:publish")
        False
    """

    def __init__(self, roles: Mapping[str, Iterable[str]] | None = None) -> None:
        """Build a control from a role map.

        Args:
            roles: Each role name mapped to the permissions it grants. Omitted
                for an app that reads permissions straight off the token and
                has no roles of its own.
        """
        self._roles: dict[str, frozenset[str]] = {
            name: frozenset(grants) for name, grants in (roles or {}).items()
        }

    @property
    def known_roles(self) -> frozenset[str]:
        """The role names this control knows about.

        Returns:
            Every key of the role map.
        """
        return frozenset(self._roles)

    def for_permissions(self, permissions: Iterable[str]) -> Access:
        """Resolve access from permissions the user carries directly.

        Args:
            permissions: The permission strings, wildcards allowed.

        Returns:
            The resolved :class:`Access`.
        """
        return Access(frozenset(permissions))

    def for_roles(self, roles: Iterable[str]) -> Access:
        """Resolve access from role names, expanding each through the map.

        A role this control does not know grants nothing, rather than raising:
        the roles come from a server that may add one before the app models it,
        and an app that crashes on a new role is worse than one that hides a
        button. :attr:`known_roles` is there for a caller that wants to notice.

        Args:
            roles: The role names the user holds.

        Returns:
            The resolved :class:`Access`, holding the union of every known
            role's grants.
        """
        granted: set[str] = set()
        for role in roles:
            granted |= self._roles.get(role, frozenset())
        return Access(frozenset(granted))

    def for_token(self, access: TokenAccess) -> Access:
        """Resolve access from a token's claims: roles expanded, plus direct.

        This is the call an app makes. A token may carry roles, explicit
        permissions, or both — the result is the union, so a user whose role
        grants ``users:read`` and who additionally carries ``audit:read`` gets
        both.

        Args:
            access: The claims read by
                :func:`~tempestweb.access.unverified_access_from_token`.

        Returns:
            The resolved :class:`Access`.
        """
        expanded = self.for_roles(access.roles).permissions
        return Access(expanded | frozenset(access.permissions))

known_roles property

known_roles: frozenset[str]

The role names this control knows about.

Returns:

Type Description
frozenset[str]

Every key of the role map.

for_permissions

for_permissions(permissions: Iterable[str]) -> Access

Resolve access from permissions the user carries directly.

Parameters:

Name Type Description Default
permissions Iterable[str]

The permission strings, wildcards allowed.

required

Returns:

Type Description
Access

The resolved :class:Access.

Source code in tempestweb/access/control.py
def for_permissions(self, permissions: Iterable[str]) -> Access:
    """Resolve access from permissions the user carries directly.

    Args:
        permissions: The permission strings, wildcards allowed.

    Returns:
        The resolved :class:`Access`.
    """
    return Access(frozenset(permissions))

for_roles

for_roles(roles: Iterable[str]) -> Access

Resolve access from role names, expanding each through the map.

A role this control does not know grants nothing, rather than raising: the roles come from a server that may add one before the app models it, and an app that crashes on a new role is worse than one that hides a button. :attr:known_roles is there for a caller that wants to notice.

Parameters:

Name Type Description Default
roles Iterable[str]

The role names the user holds.

required

Returns:

Type Description
Access

The resolved :class:Access, holding the union of every known

Access

role's grants.

Source code in tempestweb/access/control.py
def for_roles(self, roles: Iterable[str]) -> Access:
    """Resolve access from role names, expanding each through the map.

    A role this control does not know grants nothing, rather than raising:
    the roles come from a server that may add one before the app models it,
    and an app that crashes on a new role is worse than one that hides a
    button. :attr:`known_roles` is there for a caller that wants to notice.

    Args:
        roles: The role names the user holds.

    Returns:
        The resolved :class:`Access`, holding the union of every known
        role's grants.
    """
    granted: set[str] = set()
    for role in roles:
        granted |= self._roles.get(role, frozenset())
    return Access(frozenset(granted))

for_token

for_token(access: TokenAccess) -> Access

Resolve access from a token's claims: roles expanded, plus direct.

This is the call an app makes. A token may carry roles, explicit permissions, or both — the result is the union, so a user whose role grants users:read and who additionally carries audit:read gets both.

Parameters:

Name Type Description Default
access TokenAccess

The claims read by :func:~tempestweb.access.unverified_access_from_token.

required

Returns:

Type Description
Access

The resolved :class:Access.

Source code in tempestweb/access/control.py
def for_token(self, access: TokenAccess) -> Access:
    """Resolve access from a token's claims: roles expanded, plus direct.

    This is the call an app makes. A token may carry roles, explicit
    permissions, or both — the result is the union, so a user whose role
    grants ``users:read`` and who additionally carries ``audit:read`` gets
    both.

    Args:
        access: The claims read by
            :func:`~tempestweb.access.unverified_access_from_token`.

    Returns:
        The resolved :class:`Access`.
    """
    expanded = self.for_roles(access.roles).permissions
    return Access(expanded | frozenset(access.permissions))

unverified_access_from_token

unverified_access_from_token(token: str, *, claims: ClaimNames = DEFAULT_CLAIM_NAMES) -> TokenAccess

Read roles, permissions and expiry off a JWT without verifying it.

Parameters:

Name Type Description Default
token str

A compact-serialization JWT (header.payload.signature).

required
claims ClaimNames

Which claims to read, for a server naming them differently.

DEFAULT_CLAIM_NAMES

Returns:

Name Type Description
The TokenAccess

class:TokenAccess the payload describes. Missing claims yield

TokenAccess

empty tuples and None — a token carrying no roles is a valid token

TokenAccess

for a user with no roles, not an error.

Raises:

Type Description
JWTError

If the token is not three dot-separated segments, or its payload is not a JSON object. A wrong signature is not an error here: see the module's danger note.

Source code in tempestweb/access/claims.py
def unverified_access_from_token(
    token: str,
    *,
    claims: ClaimNames = DEFAULT_CLAIM_NAMES,
) -> TokenAccess:
    """Read roles, permissions and expiry off a JWT **without verifying it**.

    Args:
        token: A compact-serialization JWT (``header.payload.signature``).
        claims: Which claims to read, for a server naming them differently.

    Returns:
        The :class:`TokenAccess` the payload describes. Missing claims yield
        empty tuples and ``None`` — a token carrying no roles is a valid token
        for a user with no roles, not an error.

    Raises:
        JWTError: If the token is not three dot-separated segments, or its
            payload is not a JSON object. A **wrong signature is not an error**
            here: see the module's danger note.
    """
    payload = decode_jwt(token)
    permissions = list(_strings(payload.get(claims.permissions)))
    permissions.extend(_scopes(payload.get(claims.scope)))
    return TokenAccess(
        roles=tuple(_strings(payload.get(claims.roles))),
        permissions=tuple(dict.fromkeys(permissions)),
        expires_at=_seconds(payload.get(EXPIRY_CLAIM)),
    )