Skip to content

Permissions and access control

Hussein Jarrar edited this page Sep 12, 2026 · 2 revisions

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.

The permission atom

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 — anyteamown. 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.

Scope: global and per-project

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.

authz.require and authz.require_anywhere

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.read at GLOBAL scope as a stand-in for membership. That check could not fail: item.read sat in the hardcoded MEMBER_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 than require_anywhere(item.read): holding item.read through a relation qualifier (item.read@own) means "you may read your OWN items here", not "this project is yours". visible_projects unions 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. Use visible_projects, not a raw require_anywhere map, whenever you decide what to list as the actor's own.

Roles, instance_role, and global role grants

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.

The access-grant framework

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.

Service accounts and scoped keys

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.

How to add a permission check to your plugin

  1. 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.
  2. Declare it. Add a PermissionSpec to your RaddPlugin.permissions, or a CrudResourceSpec to crud_resources if you need the full create/update/delete/manage set. Set implied_by if an existing umbrella (project.manage, global.manage) should grant it automatically.
  3. Pick the scope. "project" if the check is always about one project, "space" for a wiki space, "global" for an instance-wide action.
  4. Enforce it. In a hand-written router, call authz.require(session, user, "your.atom", project=project) for a single-scope check, or authz.require_anywhere for a cross-project surface — see the rule above. If you declared an EntitySpec, the generated CRUD router already calls authz.require for you (see Write a backend plugin).
  5. 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.
  6. If the check is about one record, not an action, register a ResourceSpec instead of, or beside, your CRUD atom. Reuse <AccessGrantsEditor> on your settings page.
  7. Check ownership. tests/test_permission_ownership.py asserts 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.

Clone this wiki locally