-
-
Notifications
You must be signed in to change notification settings - Fork 0
Permissions and access control
How Radd decides what an actor may do: the permission atom, its scope, and the two ways a plugin enforces it. Covers roles, the access-grant framework, and service accounts with scoped keys.
A permission atom is a string like item.read or project.manage. Every
check in Radd is a question about one atom: does this actor hold it, in this
scope?
An atom name is resource.verb. The verb is usually a CRUD action —
create, read, update, delete — or manage, the umbrella that implies
all four. A resource defines its own verb when none of those fit:
comment.read_internal, automation.act_as.
A plugin declares an atom on its manifest with PermissionSpec:
@dataclass(frozen=True)
class PermissionSpec:
key: str
#: One of auth.types.PermissionScope: "project" | "global" | "space".
scope: str
description: str = ""
#: Umbrella atoms that expand to this one.
implied_by: tuple[str, ...] = ()
#: The reverse edge: atoms a holder of THIS one also holds.
implies: tuple[str, ...] = ()— server/src/radd/kernel/specs.py
The items plugin declares its own atoms this way. item.update implies
attachment.create and the relation-qualified attachment.delete@own.
Anyone who may edit an issue may also attach a file to it, and remove their
own attachments — with no extra grant. item.delete is implied by
project.manage, the project umbrella:
permissions=(
PermissionSpec("item.read", "project", "See the project's work items."),
PermissionSpec("item.create", "project", "Create work items in the project."),
PermissionSpec(
"item.update", "project", "Edit the project's work items.",
implies=("attachment.create", "attachment.delete@own"),
),
PermissionSpec(
"item.delete", "project", "Hard-delete work items.",
implied_by=("project.manage",),
),
),— server/src/radd/modules/items/init.py
implied_by and implies are the same fact from two directions. Name
implied_by on the atom that is GRANTED by an umbrella (item.delete is
implied by project.manage). Name implies on the atom that CONFERS
something with no PermissionSpec of its own. A relation-qualified form
like attachment.delete@own is not a catalog atom, so it cannot declare an
implied_by back. auth.types.implied_map() reads both directions and
expands them transitively: holding project.manage resolves through
state.manage to state.create/state.update/state.delete in one pass.
A resource that needs the whole create/update/delete/manage set declares a
CrudResourceSpec instead of four separate atoms:
@dataclass(frozen=True)
class CrudResourceSpec:
key: str
scope: str
label: str
manage: str # the coarse verb whose holders get all of this resource's atoms
actions: tuple[str, ...] = ("create", "update", "delete")— server/src/radd/kernel/specs.py
actions defaults to create/update/delete — not read. Reads stay open
to project members by default. A <resource>.read atom is only added when
a catalog needs to be revocable. role.read, label.read, and four others
were added this way. Each is granted through the Baseline role, for
day-one parity:
CrudResourceSpec(
"role", "global", "roles", "global.manage",
actions=("create", "read", "update", "delete"),
),— server/src/radd/modules/auth/permissions.py
An atom you register is held by nobody until an admin grants it, through a role, a global role grant, or an access grant. It does not join the Baseline automatically — only auth's own seed data does that, and only for atoms everyone should hold on day one.
Relation-qualified atoms. An atom may carry a qualifier: item.read@own
means "read the items I reported", narrower than the unqualified
item.read. The qualifiers form a chain, widest first — any ⊃ team ⊃
own. An unqualified atom means @any.
holds_base(permissions, "item.read") returns true for item.read@own
too. A qualified atom counts as holding its base. That is correct for a
GATE — can this actor read some item here? It is the wrong question for a
count or a list of what belongs to the actor; see visible_projects below.
What a qualifier means for one resource's rows is declared once, in a
RelationSpec — a separate topic from scope.
Every atom is checked at one of three scopes:
| Scope | Checked against | Example |
|---|---|---|
project |
one project | item.read |
space |
one wiki space | page.read |
global |
the whole instance, no project | global.manage |
A grant at global scope satisfies a check at any scope it contains — the
containment ladder is global ⊃ {project | space}. A role granted to a user
instance-wide reaches every project; a role granted on one project does not
reach global checks or any other project.
Two functions resolve a permission set, and they answer different questions.
effective_permissions resolves ONE scope — a project, a space, or neither
(global). authz.require is built on it:
async def effective_permissions(
session: AsyncSession,
user: User,
*,
project: Project | None = None,
space_id: uuid.UUID | None = None,
) -> frozenset[Permission]:— server/src/radd/modules/auth/authz_core.py
Pass a project when the check is about one project you already have in
hand. Pass a space_id for a wiki space. Pass neither for a global check.
If you pass both, it raises ValueError — a check resolves against one
scope, never two at once.
permissions_for_projects resolves MANY projects in one batched pass — the
list-hydration path for a screen that renders a permission set beside every
row:
async def permissions_for_projects(
session: AsyncSession, user: User, projects: Sequence[Project]
) -> dict[uuid.UUID, frozenset[Permission]]:
"""Effective permissions for many projects in one batched pass (list hydration)."""— server/src/radd/modules/auth/authz_batch.py
Use effective_permissions (or authz.require) when you already know the
one scope you are checking. Use permissions_for_projects — or its
request-memoised wrapper project_permission_map — when you are about to
render N rows and would otherwise resolve permissions N times. Calling
effective_permissions in a loop over every project in a list is the same
bug as an N+1 query, because it is one.
This is the distinction that decides whether a scoped key or a service account can use your endpoint at all.
authz.require raises ForbiddenError unless the actor holds the atom in
ONE scope. Pass a project or a space, or pass neither for a global check:
async def require(
session: AsyncSession,
user: User,
permission: Permission,
*,
project: Project | None = None,
space_id: uuid.UUID | None = None,
) -> frozenset[Permission]:
"""Raise ForbiddenError (-> 403) unless the user holds `permission` in the scope."""— server/src/radd/modules/auth/authz_core.py
authz.require_anywhere asks a different question: in WHICH projects does
the actor hold this atom? It returns a map, not a boolean, and by default it
does not raise:
async def require_anywhere(
session: AsyncSession, user: User, permission: Permission, *, refuse_when_empty: bool = False
) -> dict[uuid.UUID, frozenset[Permission]]:
"""The per-project permission map for every project where `permission` holds.
Holding the permission in ANY project satisfies this gate; the caller
constrains its query to the returned project ids.
"""— server/src/radd/modules/auth/authz_batch.py
Why the difference matters. A spec-113 scoped API key can be narrowed to
one project. Its TokenScope.global_atoms is then empty — it holds nothing
at global scope, by design.
A cross-project surface — a projects list, an unscoped item search, an MCP
tool with no fixed project — might call authz.require(item.read) with no
project. That asks for the GLOBAL atom. A key scoped to one project never
holds it, so the surface refuses a caller who plainly has read access
somewhere. This is exactly what happened to GET /projects, unscoped item
listing, and the MCP search_items tool before require_anywhere existed.
Each was a real endpoint, refusing a real caller, because the check asked
the wrong question.
Getting it wrong the other way is just as real, and harder to see.
Before the Baseline role existed as editable data, roughly 28 endpoints
gated on item.read at GLOBAL scope. Each used it as a stand-in for "is
this an ordinary member of the instance?" The check could never fail,
because item.read sat in a hardcoded floor every active user held
everywhere. A permission check that passes vacuously teaches nobody
anything — and hides the day it stops passing:
Roughly 28 endpoints used to gate on
item.readat GLOBAL scope as a stand-in for membership. That check could not fail:item.readsat in the hardcodedMEMBER_FLOOR... The first admin to empty the Baseline turned every one of those gates into a hard 403 for people who were, in fact, members. — server/src/radd/modules/auth/authz_batch.py,readable_projects
One bug, two symptoms: a vacuous pass while the floor covered the atom, and a hard refusal of legitimate members the moment it did not. Both came from checking a GLOBAL scope to answer a cross-project question.
The rule. If your endpoint already operates on one known project or
space, call authz.require scoped to it. If it spans or aggregates across
projects instead — a list, a search, a bulk action, an MCP tool with no
fixed project — call authz.require_anywhere. Constrain your query to the
project ids it returns. Never call require with no project as a proxy
for "is this actor a member of anything".
require_anywhere does not raise by default. "There is nothing here for
you" and "you did something you may not do" are different answers — only
the second deserves an error. Pass refuse_when_empty=True for a caller
with no eyes on the result. The MCP tools do this: an agent handed
{"projects": []} may conclude the instance has none, rather than that it
may see none.
Two ready-made wrappers cover the common cases, so you rarely call
require_anywhere directly:
-
readable_projects/require_member— "is this actor an ordinary member of anything?" (the list form and the raising form of the same floor). -
visible_projects— which projects to OFFER as belonging to the actor. This is narrower thanrequire_anywhere(item.read): holdingitem.readthrough a relation qualifier (item.read@own) means "you may read your OWN items here", not "this project is yours".visible_projectsunions two sets. One is projects the actor is entitled to — an unqualified grant. The other is projects they are related to: a qualified grant, plus a real relationship. That relationship might be reporting an item there, a team owning one, or participating in one. Usevisible_projects, not a rawrequire_anywheremap, whenever you decide what to list as the actor's own.
Every user carries an instance_role: admin or member
(InstanceRole, server/src/radd/modules/auth/types.py). Admin means
user.active and instance_role == "admin" — the one admin predicate,
checked nowhere else. An admin holds every atom; nothing narrower is
resolved for them except a spec-113 key scope (see below).
A Role is a database row: a name, a description, and a permissions list
of raw atom strings. That list is not restricted to the builtin
Permission enum, so a role can grant a plugin's own atom. Five roles are
builtin (BuiltinRoleKey): baseline, admin, member, viewer,
requester. Only baseline's permission set is editable; the others are
fixed.
The Baseline role is what every active user holds on every project, without being granted anything:
BuiltinRole(
key=BuiltinRoleKey.BASELINE,
name="Baseline",
description=(
"What everyone with an account gets, on every project, without being "
"granted anything. Edit this to widen or narrow the floor."
),
permissions=(
"item.read@own",
"item.read@participant",
"comment.write@own",
"comment.write@participant",
"comment.delete@own",
"worklog.delete@own",
"attachment.delete@own",
Permission.LABEL_READ,
Permission.CYCLE_READ,
Permission.CANNED_READ,
Permission.TEAM_READ,
Permission.ROLE_READ,
Permission.CARD_PRESET_READ,
),
position=-1,
),— server/src/radd/modules/auth/types.py
An empty Baseline is a real, supported state — it fails closed, not open. Widen or narrow it in Settings → Roles; the change applies on the next request, with no redeploy.
A role reaches a user through project membership, a team the user is on, or a directory group. A global role grant is the fourth channel — it delivers a role with no project-membership row at all:
class GlobalRoleGrant(Base, TimestampMixin):
"""A SCOPEABLE role grant: a role held by a user, a team, or a directory
GROUP, either instance-wide (`project_id` NULL = global) or on one
project (`project_id` set). Exactly one of user_id/team_id/group_id.
Global grants apply at BOTH scopes (global checks union them in; every
project treats them as one more granted role). A project-scoped grant
applies only on that project.
"""— server/src/radd/modules/auth/models.py
Use a global role grant in two cases. One: someone needs a global-scope
atom (sla.create, team.update, an admin-only setting) without being an
instance admin. Two: a team should hold a role on some projects with no
project-membership row per project.
Roles answer "what atom does this actor hold". The access-grant framework answers a narrower question. A role cannot say "does this actor see THIS ONE record" — a specific custom field, a specific saved view. One table serves every adopter:
class AccessGrant(Base, TimestampMixin):
"""One access grant: a SUBJECT (user|team|role|group) is granted an
ACCESS (read|write|…) on a RESOURCE (resource_type + resource_id),
SCOPED to a project (NULL = every scope the resource applies in)."""
resource_type: Mapped[str]
resource_id: Mapped[str] # a uuid or a stable key — no FK (polymorphic)
subject_type: Mapped[str] # GrantSubject: user | team | role | group
subject_id: Mapped[uuid.UUID]
access: Mapped[str] # resource-declared: read/write, or viewer/editor/owner
effect: Mapped[str] # allow (default) | deny
project_id: Mapped["uuid.UUID | None"]— server/src/radd/modules/access/models.py
A module or plugin registers one ResourceSpec per resource type it wants
grantable. It gets the generic /grants API and the reusable
<AccessGrantsEditor> component, with no further code:
@dataclass(frozen=True)
class ResourceSpec:
resource_type: str
can_manage: CanManage # who may edit this resource's grants
accesses: tuple[str, ...] = (Access.READ.value, Access.WRITE.value)
default_open: bool = True # no grant on a resource -> is it open, or closed?
hierarchical: bool = False # ordered levels (viewer<editor<owner), or independent flags?
subjects: tuple[GrantSubject, ...] = (
GrantSubject.USER, GrantSubject.TEAM, GrantSubject.ROLE, GrantSubject.GROUP,
)
project_scoped: bool = True
implied_by: dict[str, tuple[str, ...]] = field(default_factory=dict)
label: str = ""— server/src/radd/modules/access/registry.py
Two real adopters show the two access models.
Custom fields use the flag model — a field is open until some grant
restricts it, and a write grant implies read:
_FIELD_SPEC = ResourceSpec(
resource_type=FIELD_RESOURCE,
can_manage=lambda session, actor, resource_id, project_id: _can_manage_field(
session, actor, resource_id
),
accesses=(Access.READ.value, Access.WRITE.value),
default_open=True,
implied_by={Access.READ.value: (Access.WRITE.value,)}, # a writer can see what they write
label="Custom field",
label_for=_field_labels,
)— server/src/radd/modules/fields/service.py
Views use the hierarchical model — a view is private until shared, and the actor's effective access is the HIGHEST level they hold:
_VIEW_SPEC = ResourceSpec(
resource_type=VIEW_RESOURCE,
can_manage=_can_manage_view,
accesses=(ShareLevel.VIEWER.value, ShareLevel.EDITOR.value, ShareLevel.OWNER.value),
default_open=False, # a view is private (owner-only) until shared
hierarchical=True, # viewer < editor < owner — the effective level is the highest held
subjects=(GrantSubject.USER, GrantSubject.TEAM, GrantSubject.GROUP),
project_scoped=False, # a view already belongs to one project
label="View",
label_for=_view_labels,
)
register_resource(_VIEW_SPEC)— server/src/radd/modules/views/service.py
Resolution is pure and lives in one place: has_access for the flag model,
effective_level for the hierarchical one. A custom field, a builtin
field, a view, and a plugin's own resource all resolve the same way:
def has_access(
grants: Sequence[_GrantLike], ctx: SubjectContext, access: str,
project_id: uuid.UUID | None, spec: ResourceSpec,
) -> bool: ...
def effective_level(
grants: Sequence[_GrantLike], ctx: SubjectContext,
project_id: uuid.UUID | None, spec: ResourceSpec,
) -> str | None: ...— server/src/radd/modules/access/resolution.py
A deny grant beats an allow at the same specificity. The narrower scope
wins across scopes too — a project-scoped grant always beats a global one,
regardless of effect. Most resources never write a deny row; the model
stays purely additive until one is needed.
Register a ResourceSpec for a plugin resource by adding it to
access_resources=(...) on your RaddPlugin manifest (see
Write a backend plugin). If the plugin is disabled, the resource type
withdraws from the registry, with everything else the plugin contributed.
A service account is a User row with source = "service" — a synthetic
email, no password hash, and no identity rows. It is a first-class actor
everywhere a person is (assignable, mentionable, audited), with one
difference: it cannot start a session.
async def create_session(session: AsyncSession, user: User) -> str:
# Spec 113: a service account authenticates by API key and nothing else.
# Refusing here covers local, TOTP, LDAP and OIDC at once, because every one
# of those paths mints its session through this function.
if user.source == UserSource.SERVICE:
raise UnauthorizedError("service accounts authenticate with an API key")— server/src/radd/modules/auth/service_sessions.py
A service account's authority is granted the same way a person's is — role
grants, project membership — and that grant is its ceiling. An API key
— personal, or a service account's — may narrow that ceiling further.
api_tokens.scopes is a JSON column carrying raw permission atoms, in the
same vocabulary the roles matrix uses:
{"global": ["item.read"], "projects": {"<project-id>": ["item.create", "item.update", "comment.write"]}}NULL means unscoped: the key carries the account's full authority. That
is what every personal token did before scopes existed, and still does by
default.
Enforcement is one intersection, applied where permissions resolve. The key's scope narrows whatever the account would otherwise hold:
def narrow(
self, permissions: frozenset[Permission], project_id: uuid.UUID | None
) -> frozenset[Permission]:
"""The intersection — a key can therefore never exceed its account."""— server/src/radd/modules/auth/scopes.py
State this plainly, because it is the security property the whole
mechanism exists for: a key can never exceed the account behind it.
Demote the account, and every key it holds narrows on the very next
request. No key edit, no re-issuance — the check is a live resolve. This
applies inside effective_permissions itself (_narrow_to_key_scope), so
it covers the REST API, the MCP endpoint, and any surface built on the same
seam, for free.
-
Name the atom. Use
resource.verb. Reuse an existing resource name if your check is about someone else's resource (item.update). Invent a new one for your own resource. -
Declare it. Add a
PermissionSpecto yourRaddPlugin.permissions, or aCrudResourceSpectocrud_resourcesif you need the full create/update/delete/manage set. Setimplied_byif an existing umbrella (project.manage,global.manage) should grant it automatically. -
Pick the scope.
"project"if the check is always about one project,"space"for a wiki space,"global"for an instance-wide action. -
Enforce it. In a hand-written router, call
authz.require(session, user, "your.atom", project=project)for a single-scope check, orauthz.require_anywherefor a cross-project surface — see the rule above. If you declared anEntitySpec, the generated CRUD router already callsauthz.requirefor you (see Write a backend plugin). - Leave it ungranted. A new atom starts held by nobody except an admin. Do not add it to the Baseline role — that file belongs to auth. An administrator grants it through Settings → Roles once your plugin ships.
-
If the check is about one record, not an action, register a
ResourceSpecinstead of, or beside, your CRUD atom. Reuse<AccessGrantsEditor>on your settings page. -
Check ownership.
tests/test_permission_ownership.pyasserts every atom is declared by exactly one plugin, with a consistent scope. Run it after adding an atom.
Mirrored from project.radd-hq.com on 2026-09-12. Documentation is written there; this copy is regenerated by scripts/publish_wiki.py and hand edits do not survive it.
-
Developer guide
- Architecture: the kernel and plugins
- Develop, test and deploy
- Events and consumers
- Permissions and access control
- The MCP server
- The query language for developers
- The REST API and authentication
- Write a backend plugin
- Write a page editor extension
- Write a plugin user interface
- Write an automation node
-
Release notes
- 0.36.4
- 0.36.3
- 0.36.2
- 0.36.1
- 0.36.0
- 0.35.0
- 0.34.0
- 0.33.0
- 0.32.0
- 0.31.1
- 0.31.0
- 0.30.0
- 0.29.0
- 0.28.0
- 0.27.0
- 0.26.0
- 0.25.1
- 0.25.0
- 0.24.1
- 0.24.0
- 0.23.1
- 0.23.0
- 0.22.0
- 0.21.0
- 0.20.0
- 0.19.0
- 0.18.1
- 0.18.0
- 0.17.2
- 0.17.1
- 0.17.0
- 0.16.0
- 0.15.0
- 0.14.1
- 0.14.0
- 0.13.1
- 0.13.0
- 0.12.0
- 0.11.0
- 0.10.0
- 0.9.2
- 0.9.1
- 0.9.0
- 0.8.1
- 0.8.0
- 0.7.1
- 0.7.0
- 0.6.6
- 0.6.5
- 0.6.4
- 0.6.3
- 0.6.2
- 0.6.1
- 0.6.0
- 0.5.0
- 0.4.1
- 0.4.0
- 0.3.2
- 0.3.0
- 0.2.0
- 0.1.0
-
User guide
- AI features
- Attachments
- Automations
- Cycles and releases
- Instance settings
- Intake forms and the portal
- Notifications and the inbox
- Personal settings
- Project settings
- Projects
- Reports and dashboards
- Search and the query language
- Start here
- The application window
- The card designer
- The roadmap
- The service desk
- The wiki
- Time logging and the timesheet
- Views
- Work items