Permissões na view (tempestweb.access)¶
O que você vai aprender
A decidir o que a tela desenha a partir das permissões que o usuário
carrega — sem espalhar if state.role == "admin" pela view e sem ler o
JWT com json.loads num canto qualquer. 🚀
Antes de tudo: esconder um botão não é controle de acesso
Tudo nesta página roda onde o usuário pode mexer. Uma tela que não desenha o botão "Excluir" continua na frente de um endpoint que exclui, e chegar nesse endpoint precisa de um terminal, não de um exploit.
| Onde | Decide | Com o quê |
|---|---|---|
Servidor (tempest-fastapi-sdk) |
se a requisição pode acontecer | a chave de assinatura |
| Aqui | se o botão é desenhado | claims que ninguém verificou |
Se o servidor não impede, não está impedido. Isto aqui é experiência de uso: não mostrar ao usuário uma ação que ele receberia 403 ao tentar.
O problema¶
# ❌ A condição espalhada pela view
if app.state.role == "admin":
children.append(Button(label="Excluir", key="del", on_click=delete))
...
if app.state.role == "admin":
children.append(audit_panel(app))
No dia em que existir um segundo papel privilegiado, você precisa achar todos os
if. E na primeira vez que alguém quiser "admin pode tudo em usuários", nasce um
startswith("users:") — escrito de um jeito num arquivo e de outro no seguinte.
O mapa, num lugar só¶
from tempestweb.access import AccessControl
ACCESS = AccessControl(
roles={
"admin": ["users:*", "audit:read"],
"viewer": ["users:read"],
}
)
E a view pergunta:
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:
"""Desenha a lista, com Excluir só para quem pode excluir."""
access = ACCESS.for_roles(app.state.roles)
children: list[Widget] = [user_list(app)]
if access.can("users:delete"):
children.append(Button(label="Excluir", key="del", on_click=delete))
return Column(key="body", children=children)
O curinga¶
Um separador (:) e um curinga no fim. Nada de glob.
| Concedido | Pedido | Resultado |
|---|---|---|
users:* |
users:delete |
✅ |
users:* |
users:a:b |
✅ |
users:* |
audit:read |
❌ outro prefixo |
users:* |
users |
❌ é outra permissão, não uma mais rasa |
users:read |
users:* |
❌ ler não é poder tudo |
* |
qualquer coisa | ✅ o papel de superusuário |
Três perguntas, além do can:
access.can("users:delete") # uma
access.can_any("users:delete", "audit:read") # pelo menos uma
access.can_all("users:read", "audit:read") # todas
can_all() sem argumento é True; can_any() sem argumento é False
Uma tela que declara requires = [] precisa renderizar. Uma tela que
pergunta "pode alguma de []" não pode ganhar nada. As duas respostas são o
contrário uma da outra, e as duas estão certas.
Lendo o token¶
from tempestweb.access import unverified_access_from_token
claims = unverified_access_from_token(token)
claims.roles # ('admin',)
claims.permissions # ('audit:read',) — inclui os scopes de OAuth
claims.is_expired(now=time.time())
E o passo que junta os dois — papéis expandidos mais as permissões diretas:
O nome diz unverified de propósito¶
A assinatura não é verificada, e isso é o desenho
No Modo A a app roda no browser: a chave de assinatura estaria no browser
junto. Não há com o que verificar. Quem verifica é o servidor, com o
tempest-fastapi-sdk, antes de a requisição chegar em qualquer lugar.
Um token com assinatura forjada decodifica normalmente aqui — de
propósito. Recusar alguns tokens sugeriria que os aceitos foram conferidos.
Não foram: qualquer pessoa entrega ao próprio browser um token dizendo
roles: ["admin"]. A única coisa que essa decisão muda é qual botão a tela
pinta.
O unverified_ no nome existe para aparecer em toda chamada, onde quem
revisa o código vê.
Isso está fixado por teste (test_a_forged_signature_still_decodes_on_purpose):
no dia em que alguém "consertar" adicionando verificação, o teste reprova e
explica por quê.
Token expirado reporta, não levanta¶
Expirar é estado comum, tratado com refresh — não é exceção. E um token sem
exp não expira: is_expired devolve False.
now é parâmetro, não relógio escondido
is_expired(now=...) recebe o tempo em vez de ler time.time() por dentro:
quem chama é dono da fonte de tempo, e um teste fixa a expiração sem congelar
relógio nenhum.
Servidor que nomeia os claims de outro jeito¶
from tempestweb.access import ClaimNames, unverified_access_from_token
claims = unverified_access_from_token(
token, claims=ClaimNames(roles="grupos", permissions="escopos")
)
O claim scope do OAuth 2.0 é sempre lido junto, separado por espaço, como
manda a especificação.
Quando o claim vem torto¶
Um claim com forma inesperada — número onde deveria ter lista, objeto aninhado,
null — não derruba a tela: contribui nada. O pior caso é um botão a menos,
que o usuário resolve recarregando; uma exceção na view é uma tela branca.
O mesmo vale para papel desconhecido:
O servidor pode ganhar um papel antes de a app modelá-lo, e app que quebra com
papel novo é pior que app que esconde um botão. Quem quiser notar tem
ACCESS.known_roles.
Deslogado¶
from tempestweb.access import NO_ACCESS
access = ACCESS.for_token(claims) if app.state.token else NO_ACCESS
NO_ACCESS responde False a tudo. É melhor default que None, que
levantaria AttributeError na primeira view que esquecesse de checar.
Modos A e B¶
O Modo C recusa este import
O Modo C transcreve o Python da sua app para JavaScript e serve um conjunto
fechado de módulos — tempest_core, tempestweb.components e
tempestweb.native. Uma app Modo C que importe tempestweb.access é
recusada no build, com erro nomeado:
app.py:5: import from 'tempestweb.access' is not supported
(only tempest_core, `tempestweb.components` and `tempestweb.native`)
Numa app Modo C, o servidor manda junto o que a tela pode desenhar — o que, aliás, é a forma mais honesta: a decisão vem de quem tem a chave.
Fora de escopo¶
- Verificar assinatura de JWT no cliente. No Modo A o segredo estaria no browser. Quem valida é o servidor.
- Papel dinâmico vindo de serviço externo. Isso é feature flag, e já existe
em
tempestweb.observability.
Recap¶
AccessControl(roles={...})guarda o mapa papel → permissão uma vez.for_roles/for_permissions/for_tokenresolvem;for_tokené a que a app usa, porque une papéis expandidos com permissões diretas.access.can(...),can_any(...),can_all(...)são o que aviewpergunta.users:*cobreusers:delete, não cobreaudit:readnemusers.unverified_access_from_tokennão verifica assinatura, e o nome diz isso em toda chamada.- Nada disto é autorização. O servidor decide; isto desenha.
- Modo A e Modo B. O Modo C recusa o import no build.