Skip to content

The permission catalog

Everything a member can be allowed to do, how far each permission reaches, and how the built-in roles are composed from them.

See Permissions for the explanation and for how the four layers combine; this page is the generated reference.

app.core.permissions

Permission catalog - the single source of truth for what a member may do.

The rule the rest of the codebase follows: permissions are defined in code, roles are composed from them. Call sites check permissions, never role names, so adding or re-shaping a role never means editing an endpoint.

Two kinds of permission, and they behave differently:

Global permissions are binary and org-wide - you may manage members, or you may not. Resource permissions carry a :class:Scope answering the second question a role cannot: not "may this role touch agents?" but "which agents?" A Member creates their own agents and sees only those plus what was shared with them; a Builder sees every agent in the org but may only edit the shared ones.

Effective access to one resource is max(role scope, grant on that resource) - see :func:app.services.access.resolve_access. A grant can widen what a role allows for a single row; it never narrows it.

Perm

Bases: StrEnum

Everything a member can be allowed to do.

Clients cannot invent permissions - a custom role (Phase 2) may only recombine the values listed here.

Scope

Bases: StrEnum

How much of a resource type a permission reaches.

Ordered: NONE < OWN < SHARED < TEAM < ALL. Comparison is what makes "effective access = max(role, grant)" expressible, so use the operators rather than comparing the string values.

OrgRoleName

Bases: StrEnum

The role names a membership row may hold.

Nothing seeds these and there is no roles table: a role is a string on organization_members, and what it means is ROLE_PERMS below. Adding a role is an edit here, not a migration - which is the point of composing roles from permissions rather than storing them.

The column carries no CHECK constraint, unlike resource_grants.level. What keeps an invented role out is a field_validator on the member and invitation schemas; if one ever got through, ROLE_PERMS.get would answer with no permissions rather than with somebody else's.

Not user-editable. Custom roles are Phase 2, and may only ever recombine the permissions in :class:Perm.

AuthContext dataclass

Who is asking, in which organization, and what that lets them do.

Built once per request by :func:app.api.deps.get_auth_context and passed to whatever needs to make a decision, so a route never re-derives it.

user_id is optional, and that is a statement rather than a convenience. Every run on this platform has a subject - budgets, resource grants, the audit trail and the approval gate all key on one, and :mod:app.services.channels.mentions refuses an unlinked chat identity for exactly that reason. A surface open to anonymous visitors breaks the invariant, and the honest answer is to make its absence visible in the type so every consumer has to handle it, rather than to invent a fallback user whose runs nobody could be held to. What such a run is accountable to is the exposure that admitted it.

is_anonymous property

is_anonymous

Whether there is a person behind this context.

subject_id property

subject_id

The person this context is, for work that cannot be done by nobody.

Most of what a service does keys on a person: an audit entry names an actor, an approval names who decided it, and a listing of "mine plus what was shared with me" is meaningless without a me. Those call sites read this instead of :attr:user_id, which keeps their typing honest and - more usefully - makes "this needs a person" something the code says rather than assumes.

Raises:

Type Description
AuthorizationError

If there is no subject. Loudly, and here: the audit actor column is NOT NULL, so letting the absence travel surfaces several layers down as an IntegrityError naming a constraint, by which point the audit entry is lost and the request has half happened.

permissions property

permissions

Effective permissions from the role alone, before resource grants.

A context with no subject holds nothing, whatever its role says. The check is on the subject rather than on the role string because a role is just a string: a subject-less context built with "owner" would otherwise reach every row in the organization, and nothing structural would have stopped it.

A platform superadmin gets everything: they administer the deployment and already have database access - pretending otherwise would be security theatre, and the audit log is what actually holds them to it.

anonymous classmethod

anonymous(organization_id)

A context for a visitor nobody can name.

The single constructor for one, so "where can a subject-less context come from" is a grep rather than an audit. It holds no permission and reaches no row; whatever such a run is allowed to do comes from the exposure that admitted it, which was created by somebody who did have a role.

scope_for

scope_for(perm)

How far this permission reaches, or Scope.NONE if not held.

has

has(perm)

Whether the permission is held at all, at any scope.

For a resource permission this only says "may touch this kind of thing"; whether they may touch a given row is :func:app.services.access.resolve_access.

role_has

role_has(role, permission)

Whether a role holds a permission, ignoring resource scope.

For code that has a role string but no request - a service checking what the other party may do, a background job reasoning about a stored membership. Call sites check permissions rather than role names for the same reason endpoints do: adding a role should never mean editing an authorization check.

Resolving access to one row

Not generated. app/services/ is an implicit namespace package - it has no __init__.py - so the static collector cannot traverse into it, and a reference page that silently omitted half its symbols would be worse than one that says where to look.

The formula and every refusal it makes are documented in Permissions. The source is app/services/access.py, which carries the reasoning in its docstrings.