Authorization¶
zeython.authorization answers a different question than
Authentication. require_auth() answers "is anyone
logged in" — authorize() answers "can this logged-in user do this
specific thing" (edit this post, not posts in general). Almost every
mutating endpoint in a real app needs the second question answered, and
there's nothing else in the framework that helps with it — without this,
that logic ends up as ad hoc if post.author_id != user.id: raise ...
scattered across controllers.
Defining abilities¶
Abilities are named checks registered on a Gate, typically in a
ServiceProvider.boot() (not register() — it needs the Gate that
AuthorizationServiceProvider binds, and every provider's register()
runs before any boot(), so this is safe regardless of registration
order):
from zeython import Gate, ServiceProvider
class PostPolicyServiceProvider(ServiceProvider):
def boot(self) -> None:
gate: Gate = self.container.make(Gate)
gate.define("delete-post", lambda user, post: post.author_id == user.id)
A check receives the current user plus whatever arguments you pass to
authorize(); it can be sync or async, and return anything truthy/falsy.
Checking abilities¶
from zeython.authorization import authorize
async def destroy(self, request):
post = await Post.find(int(request.path_params["id"]))
await authorize(request, "delete-post", post)
await post.delete()
authorize() requires a logged-in user first — an anonymous request gets
UnauthorizedException (401); only a logged-in user who fails the
ability check gets ForbiddenException (403). It returns the authenticated
user on success, so you can use it in place of a separate require_auth()
call.
Checking without raising — for conditionally showing/hiding something
rather than blocking a whole endpoint — use the Gate directly:
gate: Gate = request.app.state.container.make(Gate)
can_delete = await gate.allows(user, "delete-post", post)
Registering¶
from zeython import AuthorizationServiceProvider
app.register(AuthServiceProvider(app, user_model=User)) # authorize() needs a logged-in user
app.register(AuthorizationServiceProvider) # binds the Gate
app.register(PostPolicyServiceProvider(app)) # defines your abilities
zeython new wires all three, plus a real example: GET /posts is
public, but DELETE /posts/{id} (PostController.destroy) is
authorize()-gated with the "delete-post" ability defined in
app/Providers/post_policy_service_provider.py.
An undefined ability is a bug, not a silent deny¶
gate.allows(user, "some-typo", ...) raises KeyError rather than
returning False if "some-typo" was never define()-d. A silently-false
check for an ability that doesn't exist would hide the mistake behind a
403 that looks like correct behavior; raising surfaces it immediately,
in tests and in development, instead of in a security review months later.
Policies¶
A closure per ability works until a resource accumulates several of them
(view-post, update-post, delete-post, create-post...) and a provider's
boot() turns into a wall of gate.define(...) calls. A Policy groups
them: a plain class, one method per ability, registered once against the
model it authorizes. zeython make policy Post scaffolds one (with
view/create/update/delete stubs) in app/Policies/ -- see CLI
Reference:
class PostPolicy:
def update(self, user, post) -> bool:
return post.author_id == user.id
def delete(self, user, post) -> bool:
return post.author_id == user.id or user.is_admin
def create(self, user) -> bool:
# no instance exists yet -- see below
return user.is_verified
class PostPolicyServiceProvider(ServiceProvider):
def boot(self) -> None:
gate: Gate = self.container.make(Gate)
gate.policy(Post, PostPolicy)
authorize()/gate.allows() work exactly the same — an ability not
covered by gate.define(...) falls back to the policy registered for
type(args[0]):
For an ability checked before an instance exists (create), pass the model
class itself instead of an instance — it's used to pick the policy but
isn't forwarded as an argument, matching the create(self, user) signature
above:
A policy method named before runs ahead of every other method on that
policy; returning non-None short-circuits the specific check —
the policy-scoped equivalent of gate.before() below:
class PostPolicy:
def before(self, user, ability):
return True if user.is_admin else None # admins bypass every PostPolicy check
def update(self, user, post) -> bool:
...
gate.define(...) still takes precedence over a policy for the same
ability name — plain closures and policies compose, they're not either/or.
A global bypass: gate.before()¶
For a rule that should short-circuit every ability, not just one policy's, register a hook once instead of repeating it in every policy/closure:
Runs before any define()/policy() lookup, for every allows() call.
Returning None (the common case — "this hook only cares about admins")
defers to the normal check; returning True/False decides it outright.
Roles and permissions¶
For "does this user have role X" / "permission Y" checks — rather than
hand-rolling user.role == "admin" everywhere — mix HasRoles into your
user model. It's duck-typed against a conventional Role/Permission
many-to-many shape (a user has roles, a role has permissions), which is
just regular models and relationships — nothing new to import beyond what
Relationships already covers:
from zeython import Authenticatable, HasRoles, Model
from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
user_roles = Table(
"user_roles", Model.metadata,
Column("user_id", ForeignKey("users.id"), primary_key=True),
Column("role_id", ForeignKey("roles.id"), primary_key=True),
)
role_permissions = Table(
"role_permissions", Model.metadata,
Column("role_id", ForeignKey("roles.id"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id"), primary_key=True),
)
class User(Model, Authenticatable, HasRoles):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
roles: Mapped[list["Role"]] = relationship(secondary=user_roles, lazy="selectin")
class Role(Model):
__tablename__ = "roles"
name: Mapped[str] = mapped_column(String(50), unique=True)
permissions: Mapped[list["Permission"]] = relationship(secondary=role_permissions, lazy="selectin")
class Permission(Model):
__tablename__ = "permissions"
name: Mapped[str] = mapped_column(String(100), unique=True)
lazy="selectin" matters here, on both relationships: has_permission()
needs roles and each role's permissions loaded, two levels deep, which
find/all's include= can't do in one call (see
Relationships — it only loads one level per
name). Configuring selectin as the relationship's own default loader
strategy sidesteps that: it applies automatically, cascading through nested
relationships that are themselves lazy="selectin", without needing
include= at every call site. Without it, user.has_role(...) raises
MissingGreenlet the first time it touches an unloaded relationship outside
an active session — async SQLAlchemy has no synchronous fallback for a
lazy load the way sync SQLAlchemy does.
HasRoles then gives you user.has_role("editor"),
user.has_any_role("editor", "admin"), user.has_all_roles(...), and
user.has_permission("posts.delete") (checked across all of the user's
roles' permissions) — plus Gate.role(...)/Gate.permission(...) sugar for
defining an ability gated on one:
gate.define("manage-users", Gate.role("admin"))
gate.define("delete-post", Gate.permission("posts.delete"))
Assign roles/permissions at creation time (Role.create(name="admin",
permissions=[...])), not as a later assignment on an already-saved object.
The same MissingGreenlet risk applies once more: SQLAlchemy's default
expire_on_commit means a collection relationship on a row that's already
been committed needs a lazy load just to compute what a new assignment
would change, and — same as any other lazy load in an async session —
there's no synchronous fallback for that:
role = await Role.create(name="admin")
role.permissions = [permission] # MissingGreenlet -- role was already committed
await role.save()
role = await Role.create(name="admin", permissions=[permission]) # fine
Seed roles/permissions the same way as any other data — a Seeder
(Factories & Seeders) is the natural place for a
fixed set like admin/editor/viewer.
What this isn't¶
No auto-discovered policies (register each with gate.policy(...)), no
can()/cannot() sugar bolted onto every model instance, no schema the
framework owns for roles/permissions — HasRoles is a mixin over
relationships you define yourself, the same way every other model in a
Zeython app is. If an app's needs go beyond a Gate + Policys + roles
(row-level multi-tenancy, attribute-based access control, an admin UI for
managing permissions), that's a deliberate scope boundary, not an
oversight — build it on top of the primitives here rather than expecting
the framework to grow into it.