Permissions¶
Sparkth authorizes actions with a scoped role-based access control (RBAC) model. This guide explains the model and how to extend it. For exact signatures and behaviour of the classes and functions, see the generated permissions reference.
The model¶
Authorization data lives in six tables:
role— a named role (e.g.admin).role_permission— the permissions a role grants. A permission is a free-form dotted string (e.g.assignment.grade); a role grants many.role_assignment— grants a role to a user at a single scope.user_group— a named, flat set of users (see Groups).group_membership— a user's membership in a group.group_role_assignment— grants a role to every member of a group at a single scope.
A user is authorized when they hold, at the relevant scope, a role whose role_permission
rows include the permission — either through an active role_assignment of their own or
through an active group_role_assignment of a group they actively belong to. can()
resolves exactly this — it is the single authorization check.
Permissions and scopes are declared in code¶
The vocabulary the system draws on — which permission strings and which scope kinds exist — is declared in application code, not kept in a catalogue table.
- Permissions are declared with
Permission.create("assignment.grade"), which registers the permission on thePERMISSIONShook. Core permissions are declared this way insparkth.core.permissions; plugins declare their own at module level in theirplugin.py. - Scope kinds are declared with
PermissionScope.create("course", parent=...), which registers the scope on thePERMISSION_SCOPEShook. The rootglobalscope is declared this way insparkth.core.permissions.scopes; plugins declare their own at module level in theirplugin.py.
Each hook is a SingleNamedItemHook keyed by name: declaring two permissions or two scope
kinds with the same name raises ValueError, so a collision fails fast at import time.
Resolve a name back to its registered object with get_permission(name) /
get_permission_scope(name).
Always declare via .create(), never the bare constructor
Only .create() registers the object on its hook (the source of truth) and makes a
duplicate name fail fast. Bind each declaration to a constant and reference that one
instance wherever the permission or scope is used (e.g. EMAIL_WHITELIST_READ, GLOBAL).
The bare Permission(name) / PermissionScope(name) constructors are internal/test-only.
A bare-constructed scope is absent from the hook, so resolving it by name raises
PermissionScopeNotFound — exactly the no-op --scope assignment the CLI rejects.
Scopes: where, not what¶
A scope answers where a role applies. It is the pair of columns on role_assignment:
scope— the kind of boundary (e.g.global,course), one of the kinds declared throughPERMISSION_SCOPES. A free-form string, not a foreign key.scope_object_id— which specific entity of that kind. Polymorphic, so deliberately not a foreign key.
Scope is not a power level. Authorization is purely permission-based: can() authorizes
only when the user's role actually contains the checked permission. Assigning a role at
global grants only that role's permissions, applied platform-wide — it is not an admin or
superuser tier (there is no superuser bypass; is_superuser was removed). The admin role
is far-reaching because of the permissions it bundles, not the scope it is assigned at.
Every scope kind is either a PermissionScope — which names one object of its kind (a
non-NULL scope_object_id) — or an ObjectlessPermissionScope, a singleton naming no
object (scope_object_id = NULL). This pairing is enforced in application code (assign_role
calls the scope's validate_object_id, raising InvalidScopeObjectId on a mismatch), not a
database CHECK constraint.
can() and has_role() cascade a grant parent → child: a grant at an
ObjectlessPermissionScope ancestor (global, whitelist) satisfies a check at any of its
descendants. Object-bearing multi-level cascade (e.g. a grant at one org applying to its
courses) needs a materialized path and remains deferred
(#420 Phase 2).
Shipped with the app¶
| Kind | Names | Notes |
|---|---|---|
| Scopes | global, whitelist, role |
global is the objectless root (applies platform-wide). whitelist is an objectless singleton nested under global for the registration whitelist. role is object-bearing (nested under global) so role management can be delegated per-role by that role's id. A global grant cascades down to both. |
| Permissions | email.whitelist.{read,create,delete}; role.{create,read,update,delete}; group.{create,read,update,delete}; permission.read; analytics.read; audit.read; user.read |
Gate the registration email-whitelist endpoints, the role-management API, the group-management API, listing the assignable permission vocabulary, the analytics dashboards and their read API, querying and exporting the audit trail, and reading usernames outside user management (Explore's /analytics/query, which names each actor, and its saved /analytics/views; both also need analytics.read) respectively. |
| Roles | admin |
Grants the three email.whitelist.*, the four role.*, the four group.*, permission.read, analytics.read, audit.read, and user.read. A seed migration also assigned it at global to every account that was a superuser when the migration ran — a one-time backfill. |
Roles and their permission grants are managed at runtime through the role-management REST API
under /api/v1/permissions (gated by role.* / permission.read). See the REST API docs
(/docs) for the endpoints. Permissions and scope kinds themselves are not editable
there — they are declared in code.
Groups¶
A group is a flat, named set of users — the second assignee source for role grants.
Granting a role to a group at a scope is one group_role_assignment row, and every active
member holds that role there: "every math instructor gets the teacher role in course 42" is
one grant instead of one role_assignment per instructor, and membership churn (someone
joins or leaves the set) is a membership edit, not a grant edit.
- Groups are flat by design — no group-in-group nesting. Organizational hierarchy ("teachers belong to departments, which belong to universities") is a scope-hierarchy concern, answered by where in the scope tree a group's grant is placed, not by nesting groups (see #420 Phase 2 for object-bearing cascade).
can()/has_role()resolve group grants through the same scope chain as direct assignments — a group grant atglobalcascades to descendant scopes exactly like a direct one, andhas_rolecounting group grants means an admin role granted via a group is a real admin.- Membership and group-role assignments are soft-deleted: removing a member (or revoking a group's role) drops the inherited access immediately while keeping the history row for auditing.
- Membership rows carry a
sourcecolumn ("manual"today); rule-driven membership can later own its"rule"rows without touching manually managed ones.
Group structure is managed through the REST API under /api/v1/permissions/groups
(CRUD, gated by the group.* permissions; deleting a group is refused with 409 while it
still has active role assignments). Membership and group-role grants are CLI-managed,
mirroring how user-role assignment is CLI-only:
# add a user (by username or email) to a group, or remove one
make cli -- groups add-member john cs-staff
make cli -- groups remove-member john cs-staff
# grant a role to every member of a group, at the global scope — and take it back
make cli -- groups assign-role-to-group cs-staff grader
make cli -- groups revoke-role-from-group cs-staff grader
# or scoped to one object — same --scope/--scope-object-id rules as roles assign-role
make cli -- groups assign-role-to-group cs-staff grader --scope course --scope-object-id 42
From application code, use the façade (sparkth.lib.permissions): add_group_member,
remove_group_member, assign_role_to_group, revoke_role_from_group,
get_group_by_name. Group management authorizes at the global scope only for now — a
per-group delegation scope (like the per-role role scope) waits on object-bearing scope
cascade (#420 Phase 2).
Audit trail¶
Every change to access control is written to the audit trail, in the same transaction as
the change itself, so a change that fails or is rolled back leaves no record. The engine
functions record the events, so the REST API, the CLI, and application code are all covered;
CLI commands are attributed to the cli system actor.
| Event | Recorded by | Target | Snapshot |
|---|---|---|---|
role.created, role.updated, role.deleted |
create_role, update_role, delete_role |
role |
name and description (before and after for an update) |
role.permission_granted, role.permission_revoked |
add_role_permission, remove_role_permission |
role |
the permission |
role.assigned, role.unassigned |
assign_role, revoke_role |
user |
role, scope, and scope object id |
group.created, group.updated, group.deleted |
create_group, update_group, delete_group |
group |
name and description (before and after for an update) |
group.member_added, group.member_removed |
add_group_member, remove_group_member |
group |
the member's user id |
group.role_assigned, group.role_revoked |
assign_role_to_group, revoke_role_from_group |
group |
role, scope, and scope object id |
The grant, assign, and add-member functions are idempotent, and so are their revoke and remove counterparts: a call that changes nothing (the grant already exists, or there is nothing active to revoke) records nothing. Likewise an update that leaves a role's or group's name and description as they were records nothing.
Before adding one¶
A permission name is stored as a literal string in every role_permission row that grants
it, and can() matches it literally. Once a permission has been granted, renaming it revokes
it from every holder at once, and a rename or a removal leaves the old rows behind until a data
migration rewrites or deletes them. A scope kind is stored the same way, in the scope column of every assignment made at
it. Settle these before declaring one:
- Is the action already covered? If a declared permission already means exactly this action, gate on it. Reuse only on an exact match: gating a new action on an existing permission hands that action to everyone who already holds it.
- What does it gate? Name the routes or features that check it, and declare the permission in the same change as the code that checks it.
- One permission or several? A permission is the smallest thing an operator can grant. Actions behind one permission cannot later be granted apart without a data migration that rewrites every role holding it. Permissions that are always granted together add vocabulary with nothing to show for it.
- Where is it checked? At
global, at an existing scope kind, or at a new one. A new scope kind needs a parent and is either objectless or object-bearing, and a grant at an objectless ancestor satisfies the check at every descendant (see Scopes). - Who holds it after deploy? Nobody, unless a data migration grants it to a seeded role
(see Add a role and its permissions below). Granting it to
adminthat way gives it to every current admin as soon as the release is migrated. Left ungranted, an operator grants it at runtime through the role-management API. - Does the UI check it? The frontend checks a permission with
checkPermission, which accepts only names in thePermissionNameunion infrontend/lib/permissions/types.ts. That union mirrors the whole vocabulary by hand, so every new permission is added to it.
Extending the permission system¶
Declare a permission or scope kind — a plugin declares its own at module level in its
plugin.py, bound to a constant, never in the plugin class's __init__: the loader and the
plugin's own tests each construct the class, and a second declaration of a name raises
ValueError. Core declarations live in sparkth.core.permissions (permissions) and
sparkth.core.permissions.scopes (scope kinds). Import everything from
sparkth.lib.permissions — never from sparkth.core or the hook modules directly.
# sparkth/plugins/grader/plugin.py
from sparkth.lib.permissions import Permission
from sparkth.lib.permissions.scopes import GLOBAL, PermissionScope
ASSIGNMENT_GRADE = Permission.create("assignment.grade")
# a scope kind's parent must already be registered (the global root always is)
COURSE = PermissionScope.create("course", parent=GLOBAL)
Add a role and its permissions — seed them in a migration:
INSERT INTO role (name, description, created_at, updated_at)
VALUES ('grader', 'Grades submissions', now(), now());
INSERT INTO role_permission (role_id, permission, created_at, updated_at)
VALUES ((SELECT id FROM role WHERE name = 'grader'), 'assignment.grade', now(), now());
Assign a role to a user — for bootstrapping, use the CLI (it looks the user up by username or email):
# assign at the global scope
make cli -- roles assign-role john admin
# or at another objectless scope — no --scope-object-id, same as global
make cli -- roles assign-role jane "Whitelist Manager" --scope whitelist
# or scoped to one object — pass the scope kind and the object id
make cli -- roles assign-role john grader --scope course --scope-object-id 42
--scope must name a declared scope kind; an unknown kind is rejected rather than persisted
as a no-op. Whether --scope-object-id is required depends on the kind: an
ObjectlessPermissionScope must be assigned without one, a PermissionScope with one.
The CLI defers this to assign_role, which raises InvalidScopeObjectId on a mismatch.
From application code, call assign_role:
from sparkth.lib.permissions import assign_role
from sparkth.lib.permissions.scopes import GLOBAL
# GLOBAL names no object, so scope_object_id is None:
await assign_role(user_id, "grader", GLOBAL, None, session)
# Scoped to one course (COURSE is your PermissionScope.create("course", …) result):
await assign_role(user_id, "grader", COURSE, "42", session)
Gate an endpoint on a permission — call .require_in_global_scope() or
.require(scope, scope_param=None) on the permission itself. scope is a declared
PermissionScope object (e.g. GLOBAL, WHITELIST, your COURSE), not its name.
scope_param names the path parameter carrying the scope object id — omit it for an
objectless scope, supply it for an object-bearing one. Getting this wrong raises ValueError
at import time (a wiring error, never a silent 403).
from fastapi import Depends
from sparkth.lib.permissions import COURSE_EDIT, EMAIL_WHITELIST_READ, THING_READ
from sparkth.lib.permissions.scopes import COURSE, WHITELIST
# Global scope, as a route dependency:
@router.get("/things", dependencies=[Depends(THING_READ.require_in_global_scope())])
async def list_things(): ...
# Objectless scope — WHITELIST is a singleton, so no scope_param:
@router.get("/whitelist", dependencies=[Depends(EMAIL_WHITELIST_READ.require(WHITELIST))])
async def list_whitelist(): ...
# Object-bearing scope — pass the PermissionScope object and the path parameter carrying
# the object id (read from the URL on each request):
@router.patch(
"/courses/{course_id}",
dependencies=[Depends(COURSE_EDIT.require(COURSE, "course_id"))],
)
async def edit_course(course_id: int): ...
The returned dependency resolves the current user, checks the permission, and returns the
authenticated User. Put it in dependencies=[...] to gate a route, or inject it as a
parameter (user: User = Depends(...)) when the handler needs the authorized user — both
enforce the permission identically. A missing permission yields 403.
Every 403 from these dependencies is recorded in the audit trail as a permission.denied
event: the caller is the actor, the target is the permission checked, and error_detail
names the scope (e.g. scope=role:7). The row is committed in its own transaction before
the 403 is raised, so it survives the request's rollback; if the write fails, the request
fails rather than returning 403. Calling can() directly, or the /permissions/can probe
below, records nothing.
Check the current user's permission — a client (e.g. the web UI) can ask whether the
authenticated user holds a permission via GET /api/v1/permissions/can?permission=<name>
(optionally &scope=<kind>&scope_object_id=<id>), which returns {"allowed": true|false}.
It is backed by can() and is a convenience for gating UI — not a security boundary, since
every endpoint still enforces its own permission and returns 403.