Permissions in the view (tempestweb.access)¶
What you'll learn
How to decide what the screen draws from the permissions a user carries —
without spreading if state.role == "admin" across the view and without
reading the JWT with json.loads in some corner. 🚀
First of all: hiding a button is not access control
Everything on this page runs where the user can change it. A screen that does not draw the "Delete" button still sits in front of an endpoint that deletes, and reaching that endpoint takes a terminal, not an exploit.
| Where | Decides | With |
|---|---|---|
Server (tempest-fastapi-sdk) |
whether the request may happen | the signing key |
| Here | whether the button is drawn | claims nobody verified |
If the server does not stop it, it is not stopped. This is user experience: not showing someone an action that would answer 403.
The problem¶
# ❌ The condition spread across the view
if app.state.role == "admin":
children.append(Button(label="Delete", key="del", on_click=delete))
...
if app.state.role == "admin":
children.append(audit_panel(app))
The day a second privileged role exists, you have to find every if. And the
first time somebody wants "admin can do everything under users", a
startswith("users:") is born — written one way in one file and another way in
the next.
The map, in one place¶
from tempestweb.access import AccessControl
ACCESS = AccessControl(
roles={
"admin": ["users:*", "audit:read"],
"viewer": ["users:read"],
}
)
And the view asks:
from tempest_core import App, Button, Column, Widget
from tempestweb.access import AccessControl
ACCESS = AccessControl(
roles={"admin": ["users:*", "audit:read"], "viewer": ["users:read"]}
)
def view(app: App[State]) -> Widget:
"""Draw the list, with Delete only for whoever may delete."""
access = ACCESS.for_roles(app.state.roles)
children: list[Widget] = [user_list(app)]
if access.can("users:delete"):
children.append(Button(label="Delete", key="del", on_click=delete))
return Column(key="body", children=children)
The wildcard¶
One separator (:) and one wildcard at the end. Not a glob.
| Granted | Requested | Result |
|---|---|---|
users:* |
users:delete |
✅ |
users:* |
users:a:b |
✅ |
users:* |
audit:read |
❌ different prefix |
users:* |
users |
❌ a different permission, not a shallower one |
users:read |
users:* |
❌ reading is not doing everything |
* |
anything | ✅ the superuser role |
Three questions, beyond can:
access.can("users:delete") # one
access.can_any("users:delete", "audit:read") # at least one
access.can_all("users:read", "audit:read") # all of them
can_all() with no arguments is True; can_any() is False
A screen declaring requires = [] must render. A screen asking "may the user
do any of []" must be granted nothing. The two answers are opposites, and
both are right.
Reading the token¶
from tempestweb.access import unverified_access_from_token
claims = unverified_access_from_token(token)
claims.roles # ('admin',)
claims.permissions # ('audit:read',) — OAuth scopes included
claims.is_expired(now=time.time())
And the step that joins the two — roles expanded plus direct permissions:
The name says unverified on purpose¶
The signature is not verified, and that is the design
In Mode A the app runs in the browser: the signing key would be in the
browser with it. There is nothing to verify with. The server verifies, with
the tempest-fastapi-sdk, before the request reaches anything.
A token with a forged signature decodes normally here — deliberately.
Refusing some tokens would suggest the accepted ones had been checked. They
were not: anyone can hand their own browser a token claiming
roles: ["admin"]. The only thing that decision changes is which button the
screen paints.
The unverified_ in the name exists so the word appears at every call site,
where a reviewer sees it.
This is pinned by a test (test_a_forged_signature_still_decodes_on_purpose):
the day somebody "fixes" it by adding verification, that test fails and says why.
An expired token reports; it does not raise¶
Expiry is an ordinary state, handled by refreshing — not an exception. And a
token with no exp does not expire: is_expired returns False.
now is a parameter, not a hidden clock
is_expired(now=...) takes the time rather than reading time.time()
internally: the caller owns the time source, and a test pins expiry without
freezing any clock.
A server that names its claims differently¶
from tempestweb.access import ClaimNames, unverified_access_from_token
claims = unverified_access_from_token(
token, claims=ClaimNames(roles="groups", permissions="scopes")
)
The OAuth 2.0 scope claim is always read alongside, space-separated, as the
spec requires.
When a claim arrives malformed¶
A claim with an unexpected shape — a number where a list belongs, a nested
object, null — does not take the screen down: it contributes nothing. The
worst case is one missing button, which a reload fixes; an exception in the view
is a blank page.
The same holds for an unknown role:
The server may gain a role before the app models it, and an app that breaks on a
new role is worse than one that hides a button. Whoever wants to notice has
ACCESS.known_roles.
Logged out¶
from tempestweb.access import NO_ACCESS
access = ACCESS.for_token(claims) if app.state.token else NO_ACCESS
NO_ACCESS answers False to everything. A better default than None, which
would raise AttributeError in the first view that forgot to check.
Modes A and B¶
Mode C refuses this import
Mode C transcribes your app's Python into JavaScript and serves a closed set
of modules — tempest_core, tempestweb.components and
tempestweb.native. A Mode C app importing tempestweb.access is
refused at build time, with a named error:
app.py:5: import from 'tempestweb.access' is not supported
(only tempest_core, `tempestweb.components` and `tempestweb.native`)
In a Mode C app the server sends along what the screen may draw — which is the more honest arrangement anyway: the decision comes from whoever holds the key.
Out of scope¶
- Verifying a JWT signature on the client. In Mode A the secret would be in the browser. The server validates.
- Dynamic roles from an external service. That is a feature flag, and it
already exists in
tempestweb.observability.
Recap¶
AccessControl(roles={...})holds the role → permission map once.for_roles/for_permissions/for_tokenresolve it;for_tokenis the one apps use, because it unions expanded roles with direct permissions.access.can(...),can_any(...),can_all(...)are what the view asks.users:*coversusers:delete; it covers neitheraudit:readnorusers.unverified_access_from_tokendoes not verify the signature, and the name says so at every call site.- None of this is authorization. The server decides; this draws.
- Mode A and Mode B. Mode C refuses the import at build time.