Zum Inhalt

Der Berechtigungskatalog

Alles, was einem Mitglied erlaubt werden kann, wie weit jede Berechtigung reicht, und wie sich die eingebauten Rollen daraus zusammensetzen.

Siehe Berechtigungen für die Erklärung und dafür, wie die vier Schichten zusammenwirken; diese Seite ist die generierte Referenz und bleibt deshalb englisch — sie wird zur Build-Zeit aus den Docstrings der Quelle gelesen.

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.

channel_identity_id class-attribute instance-attribute

channel_identity_id = None

The chat account that asked, when the asker is not a person.

Beside user_id rather than instead of it, because in a group chat the two are different answers: the turn runs as the binding's creator, and this is who typed it. It decides nothing - no permission reads it - and travels here only because every consumer that opens a run already holds this object (#639).

subject_is_publisher_fallback class-attribute instance-attribute

subject_is_publisher_fallback = False

Whether user_id is the publisher standing in for an unidentified asker.

On a hosted or embedded surface there is no signed-in person, so publisher_context runs the turn as the agent's publisher: user_id is the owner, not whoever is actually chatting. This is how a consumer tells that apart from a real subject (web chat, API, a linked member), where user_id is the asker. Like channel_identity_id it decides no permission - it exists so a person store can refuse to attribute a stranger's note to the owner rather than leak it into the owner's store (#788).

Invariant: set True in exactly one place - publisher_context, the sole constructor that runs a turn as someone other than the asker. Any future stand-in constructor must set it too, or a person-store write silently lands in the owner's store as if it were the visitor's. test_publisher_standing.py pins the one assignment site by grep, the way AuthContext.anonymous is the one subject-less constructor.

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: an authenticated path that reached this far has a person, and letting the absence travel writes an entry naming nobody - indistinguishable from the two writers that legitimately name nobody, and by then the request has half happened. A caller that genuinely has no session reads :attr:user_id instead, deliberately.

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.

assignable_roles

assignable_roles(role)

The roles a member holding role may hand out.

A role may only assign one whose authority it strictly exceeds: every permission the offered role holds, held at least as widely by the assigner, and something the assigner holds that the offered role does not. So nobody assigns their own level - promoting a peer to it is an ownership decision, not a management one - and nobody at all assigns owner, since no role outranks it. Ownership moves through :meth:app.services.member.MemberService.transfer_ownership, which demotes the outgoing owner in the same breath.

Derived from the catalog rather than from a role name, which is the whole point: the ceiling this replaced compared against the literal "admin", so a custom role (Phase 2) holding roles:manage passed it unseen and could mint a second owner (#672). An unknown role holds nothing and so may assign nothing.

Zugriff auf eine einzelne Zeile auflösen

Nicht generiert. app/services/ ist ein implizites Namespace-Package - es hat kein __init__.py - deshalb kann der statische Collector nicht hineinlaufen, und eine Referenzseite, die stillschweigend die Hälfte ihrer Symbole weglässt, wäre schlechter als eine, die sagt, wo man nachschauen muss.

Die Formel und jede Ablehnung, die sie ausspricht, sind in Berechtigungen dokumentiert. Die Quelle ist app/services/access.py, die die Begründung in ihren Docstrings trägt.