From 4acbe214cc34686940d9ff205ee34f8c6ecdf861 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2026 18:55:23 +0100 Subject: [PATCH 1/3] docs(developer): add documentation for the resource permissions API Document the experimental NCU\Resource API added in Nextcloud 36: declaring permissions and resource types, checking and enforcing the permissions left for an actor, passing access entries, writing a restriction provider, and how the results are combined. Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- developer_manual/digging_deeper/apis.rst | 1 + .../digging_deeper/resource_permissions.rst | 474 ++++++++++++++++++ developer_manual/release_notes/new.rst | 12 + 3 files changed, 487 insertions(+) create mode 100644 developer_manual/digging_deeper/resource_permissions.rst diff --git a/developer_manual/digging_deeper/apis.rst b/developer_manual/digging_deeper/apis.rst index de35bba1a4f..e8958990269 100644 --- a/developer_manual/digging_deeper/apis.rst +++ b/developer_manual/digging_deeper/apis.rst @@ -14,5 +14,6 @@ APIs & Integration email notifications publicpage + resource_permissions talk web_host_metadata diff --git a/developer_manual/digging_deeper/resource_permissions.rst b/developer_manual/digging_deeper/resource_permissions.rst new file mode 100644 index 00000000000..9e9b4c14911 --- /dev/null +++ b/developer_manual/digging_deeper/resource_permissions.rst @@ -0,0 +1,474 @@ +.. _resource_permissions: + +==================== +Resource permissions +==================== + +.. versionadded:: 36 + +.. warning:: + + This API is experimental. It lives in the ``NCU`` namespace and its PHP + signatures may still change. Permission IDs and resource type IDs are stable: + once released they never change. + +The resource permission API lets one app restrict what another app allows on +its resources, without knowing what those resources or permissions are. + +Three kinds of apps take part: + +* The **owning app** owns resources, for example tables or mails. It declares + its resource types and permissions, asks which permissions are left for an + actor, and enforces the answer. +* A **restricting app** restricts permissions, for example based on a label + attached to a resource or on the role a user has in a team. It never needs + code specific to the owning app. +* The **server** holds the declarations, asks every restricting app and + combines the answers. + +The API only restricts. Whether an actor has access to a resource at all, as +owner or through a share, is decided by the owning app. + +Concepts +-------- + +Permissions +^^^^^^^^^^^ + +A permission is something that is either allowed or restricted on a resource. +It has a stable ID in the format ``appid:name``, a translated display name and +an optional hint. A permission has the same meaning wherever it is used. + +The server ships five core permissions, available as cases of +``\NCU\Resource\Permission\CorePermission``: + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - ID + - Enum case + * - ``core:read`` + - ``CorePermission::Read`` + * - ``core:update`` + - ``CorePermission::Update`` + * - ``core:create`` + - ``CorePermission::Create`` + * - ``core:delete`` + - ``CorePermission::Delete`` + * - ``core:share`` + - ``CorePermission::Share`` + +The core permissions are independent of each other. A file drop, for example, +allows to create without allowing to read. If your resources need a +dependency, such as "nothing works without read", apply it yourself when you +enforce the result. + +Every other permission is a **kind of** exactly one core permission. Whenever a +core permission is restricted, all permissions that are a kind of it are +restricted as well. This is how a restricting app can restrict permissions it +has never heard of: a role that only allows reading restricts ``core:share`` +and with it, for example, a ``mail:forward`` permission of the Mail app. + +The server also ships ``core:download`` (``CorePermission::DOWNLOAD``), which +is a kind of ``core:read``. Restricting download does not restrict read, but +restricting read restricts download. + +Prefer the core permissions. Register an own permission only if your app has +an action whose meaning is not covered by them. + +Resources +^^^^^^^^^ + +A resource is identified by a ``\NCU\Resource\ResourceReference``: the ID of its +resource type (``appid:type``), the ID of the resource within that type and, +optionally, its owner as an ``\OCP\Actor\Principal``. Only the owning app knows +what the ID refers to. + +A resource type lists the permissions that apply to its resources. An app can +register several types, for example one for tables and one for rows. There is +no hierarchy between resources: a restriction on a table does not apply to its +rows unless the restricting app returns it for the rows as well. + +Actors +^^^^^^ + +Every check is made for an ``\OCP\Actor\Actor``, see :ref:`actor_api`. +For a logged-in user: + +.. code-block:: php + + use OCP\Actor\Actor; + use OCP\Actor\Principal; + + $actor = Actor::of(Principal::user($user->getUID())); + +For a visitor without an account, build an anonymous principal from your own +token, for example the token of a public link. + +Access entries +^^^^^^^^^^^^^^ + +Some restrictions only apply to access gained in a certain way. A user who is +a visitor in a team should only be able to read what is shared with that team, +but keeps full access to a resource that is also shared with them directly. + +To make this possible, the owning app can pass the shares that give the actor +access, as it stores them. Each ``\NCU\Resource\Permission\AccessEntry`` names +the recipient of the share and the permissions it grants. The recipient is a +kind and an ID: + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - Kind + - ID + * - ``AccessEntry::RECIPIENT_USER`` + - The user ID + * - ``AccessEntry::RECIPIENT_GROUP`` + - The group ID + * - ``AccessEntry::RECIPIENT_TEAM`` + - The team ID + * - ``appid:kind`` + - Defined by your app, for example a link token + +You do not need to resolve group or team memberships or roles. Pass the entry +as it is stored; the restricting app knows what to do with it. + +For owning apps +--------------- + +Registering permissions +^^^^^^^^^^^^^^^^^^^^^^^ + +If the core permissions are not enough, extend +``\NCU\Resource\Permission\APermission``. The ID has to start with the ID of +your app. + +.. code-block:: php + + l10n->t('Export'); + } + + public function getKindOf(): CorePermission { + // Exporting hands the data to someone else + return CorePermission::Share; + } + } + +Registering resource types +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Implement ``\NCU\Resource\IResourceType`` for every type of resource your app +owns and list the permissions that apply to it. + +.. code-block:: php + + l10n->t('Table'); + } + + public function getPermissionIds(): array { + return [ + CorePermission::Read->value, + CorePermission::Update->value, + CorePermission::Delete->value, + CorePermission::Share->value, + ExportPermission::ID, + ]; + } + } + +Register both in the ``register`` method of your ``Application`` class: + +.. code-block:: php + + public function register(IRegistrationContext $context): void { + $context->registerResourcePermission(ExportPermission::class); + $context->registerResourceType(TableResourceType::class); + } + +Registrations with an invalid ID, an ID that is already taken or an ID that +does not start with the ID of your app are logged and skipped. + +Checking a resource +^^^^^^^^^^^^^^^^^^^ + +Ask ``\NCU\Resource\Permission\IPermissionManager`` which permissions are left +for the actor. The simplest form passes only the resource: + +.. code-block:: php + + use NCU\Resource\Permission\CorePermission; + use NCU\Resource\Permission\IPermissionManager; + use NCU\Resource\ResourceReference; + use OCP\Actor\Principal; + + $resource = new ResourceReference( + TableResourceType::ID, + (string)$table->getId(), + Principal::user($table->getOwnership()), + ); + + $permissions = $this->permissionManager->check($actor, $resource); + if (!$permissions->isAllowed(CorePermission::Update)) { + // Not allowed by another app + } + +Without access entries the result starts from all permissions that apply to +the resource type. It tells you what other apps restrict, not what the actor +may do: intersect it with the access your app grants. + +With access entries the result is what is left of the permissions granted by +the entries, so it can be used directly: + +.. code-block:: php + + use NCU\Resource\Permission\AccessEntry; + + $entries = []; + foreach ($this->shareMapper->findSharesFor($table, $userId) as $share) { + $entries[] = new AccessEntry( + $share->getReceiverType() === 'circle' + ? AccessEntry::RECIPIENT_TEAM + : AccessEntry::RECIPIENT_USER, + $share->getReceiver(), + $this->toPermissionIds($share), + ); + } + + $permissions = $this->permissionManager->check($actor, $resource, $entries); + +Pass an entry for every way the actor has access, including ownership if your +app treats the owner like a recipient. Restrictions that only apply to one +recipient, such as a team role, can only take effect if you pass entries. + +Permissions in an entry that do not apply to the resource type are ignored +and logged. + +Checking many resources +^^^^^^^^^^^^^^^^^^^^^^^ + +For listings use ``checkAll``. The resources can be of different types, the +actor is the same for all of them. The result is keyed by +``ResourceReference::key()``. + +.. code-block:: php + + use NCU\Resource\Permission\ResourceAccess; + + $accesses = []; + foreach ($rows as $row) { + $accesses[] = new ResourceAccess( + new ResourceReference(RowResourceType::ID, (string)$row->getId()), + ); + } + + $results = $this->permissionManager->checkAll($actor, $accesses); + foreach ($accesses as $access) { + $canDelete = $results[$access->resource->key()] + ->isAllowed(CorePermission::Delete); + } + +``check`` and ``checkAll`` throw an ``InvalidArgumentException`` if a resource +type is not registered. ``checkAll`` also throws if the same resource is +passed more than once. + +Enforcing the result +^^^^^^^^^^^^^^^^^^^^ + +The server does not enforce anything for you. Use the result in both places: + +* when you show a resource, to hide or disable what is not allowed, and to + include the effective permissions in your API responses; +* when an action happens, to refuse it on the server side. + +Results are remembered while the process runs, so checking again when the +action happens is cheap. + +Rules that depend on who receives something, such as "must not be shared with +external recipients", are not covered by this API. They are handled by the +``\OCP\Interaction\RestrictInteractionEvent``. + +For restricting apps +-------------------- + +Implement ``\NCU\Resource\Permission\IRestrictionProvider``. The provider +receives the actor and all resources of one check, and returns the permissions +it restricts. + +.. code-block:: php + + resource; + if ($resource->owner?->equals($actor->principal)) { + continue; + } + + $label = $this->labelService->getLabel($resource->type, $resource->id); + if ($label?->forbidsDownload()) { + yield Restriction::forResource($resource, [CorePermission::DOWNLOAD]); + } + } + } + } + +Register it in the ``register`` method of your ``Application`` class: + +.. code-block:: php + + $context->registerResourceRestrictionProvider(LabelRestrictionProvider::class); + +Things to know: + +* The provider is called for resources of every type. Return nothing for + resources you have no rules for. +* A restriction is a list of permission IDs. Restricting a core permission + also restricts every permission that is a kind of it. Unknown IDs are + ignored. +* To allow only some core permissions, restrict the others. + ``CorePermission::cases()`` gives you all of them. +* Load your data for the whole batch at once where possible, a listing can + contain hundreds of resources. +* Whether the owner of a resource, administrators or system actors are exempt + is up to you. +* Results are remembered while the process runs. Call + ``IPermissionManager::clearCache()`` whenever your restrictions change, for + example when a label is assigned or a role is changed, so that later checks + in the same process see the change. + +Restricting one recipient +^^^^^^^^^^^^^^^^^^^^^^^^^ + +``Restriction::forResource`` restricts permissions however the actor has +access. ``Restriction::forRecipient`` restricts only the access the actor has +through one recipient, which the owning app passed as an access entry: + +.. code-block:: php + + use NCU\Resource\Permission\AccessEntry; + + foreach ($accesses as $access) { + foreach ($access->entries as $entry) { + if ($entry->recipientKind !== AccessEntry::RECIPIENT_TEAM) { + continue; + } + if ($this->isVisitor($actor, $entry->recipientId)) { + yield Restriction::forRecipient( + $access->resource, + $entry->recipientKind, + $entry->recipientId, + [ + CorePermission::Update->value, + CorePermission::Create->value, + CorePermission::Delete->value, + CorePermission::Share->value, + ], + ); + } + } + } + +If the same resource is also shared with the user directly, that entry is not +restricted and the user keeps the permissions it grants. + +Listing permissions +^^^^^^^^^^^^^^^^^^^ + +To let users configure restrictions, for example in an editor for labels or +roles, read the registered permissions and resource types from the manager: + +.. code-block:: php + + foreach ($this->permissionManager->getResourceTypes() as $resourceType) { + foreach ($resourceType->getPermissionIds() as $permissionId) { + $permission = $this->permissionManager->getPermission($permissionId); + // $permission->getDisplayName(), $permission->getHint(), $permission->getKindOf() + } + } + +Store the permission IDs, never class names. + +How results are combined +------------------------ + +For every resource the server + +#. takes the permissions that apply to the resource type, +#. without access entries starts from all of them; with access entries takes, + for each entry, the granted permissions without those restricted for the + recipient of the entry, and merges the entries, +#. removes the permissions restricted for the resource as a whole. + +A permission restricted by one restricting app is restricted, no matter what +other restricting apps say. + +If a restricting app is disabled, its restrictions no longer apply. If a +restriction provider throws an exception during a check or cannot be loaded, +every permission on all resources of that check is restricted and the error +is logged. diff --git a/developer_manual/release_notes/new.rst b/developer_manual/release_notes/new.rst index be2c4be5a77..31eb30f5681 100644 --- a/developer_manual/release_notes/new.rst +++ b/developer_manual/release_notes/new.rst @@ -45,3 +45,15 @@ a local or federated user, an anonymous user such as a public share visitor, a b An ``\OCP\Actor\Actor`` holds the principal whose authority is claimed, and optionally a delegate acting on its behalf. See :ref:`actor_api` for details. + +Resource permissions API +------------------------ + +The new experimental ``\NCU\Resource`` namespace lets an app restrict what another app allows on its resources, +without code specific to that app. Apps that own resources declare their resource types and permissions, +ask ``\NCU\Resource\Permission\IPermissionManager`` which permissions are left for an ``\OCP\Actor\Actor``, +and enforce the result. +Apps that restrict permissions, for example based on a label or a team role, +implement ``\NCU\Resource\Permission\IRestrictionProvider``. + +See :ref:`resource_permissions` for details. From 92659221bf2d639d21b15942aaefb27fb8b3578b Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2026 19:14:02 +0100 Subject: [PATCH 2/3] docs(developer): add diagrams to the resource permissions API page Show the check flow between the owning app, the server and the restricting apps, the "kind of" relation between permissions, and an example of how access entries and restrictions are combined. Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- .../digging_deeper/resource_permissions.rst | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/developer_manual/digging_deeper/resource_permissions.rst b/developer_manual/digging_deeper/resource_permissions.rst index 9e9b4c14911..3ac4fa06895 100644 --- a/developer_manual/digging_deeper/resource_permissions.rst +++ b/developer_manual/digging_deeper/resource_permissions.rst @@ -29,6 +29,27 @@ Three kinds of apps take part: The API only restricts. Whether an actor has access to a resource at all, as owner or through a share, is decided by the owning app. +A check involves all three: + +.. mermaid:: + + sequenceDiagram + participant O as Owning app + participant S as Server + participant A as Restricting app A + participant B as Restricting app B + + O->>S: check(actor, resource, access entries) + activate S + S->>A: getRestrictions(actor, accesses) + A-->>S: restricted permission IDs + S->>B: getRestrictions(actor, accesses) + B-->>S: restricted permission IDs + S->>S: combine the answers + S-->>O: EffectivePermissions + deactivate S + O->>O: enforce the result + Concepts -------- @@ -74,6 +95,22 @@ The server also ships ``core:download`` (``CorePermission::DOWNLOAD``), which is a kind of ``core:read``. Restricting download does not restrict read, but restricting read restricts download. +The following diagram shows the permissions shipped by the server together +with two permissions registered by apps. Restricting a permission also +restricts every permission pointing to it. + +.. mermaid:: + + flowchart BT + read["core:read"] + update["core:update"] + create["core:create"] + delete["core:delete"] + share["core:share"] + download["core:download"] -- kind of --> read + export["tables:export"] -- kind of --> share + forward["mail:forward"] -- kind of --> share + Prefer the core permissions. Register an own permission only if your app has an action whose meaning is not covered by them. @@ -468,6 +505,28 @@ For every resource the server A permission restricted by one restricting app is restricted, no matter what other restricting apps say. +In the following example a resource is shared with the actor directly and +with a team the actor is a member of. One restricting app restricts what the +actor can do through the team, another one restricts sharing the resource. + +.. mermaid:: + + flowchart TD + type["Permissions of the resource type
read, update, delete, share"] + type --> user + type --> team + + subgraph entries [Access entries] + user["Recipient: user
grants read, update, share"] + team["Recipient: team
grants read, update, delete"] + user -- "no restriction for this recipient" --> userLeft["read, update, share"] + team -- "restricted for this recipient:
update, delete, share" --> teamLeft["read"] + end + + userLeft --> merged["Merged
read, update, share"] + teamLeft --> merged + merged -- "restricted for the resource:
share" --> result["Result
read, update"] + If a restricting app is disabled, its restrictions no longer apply. If a restriction provider throws an exception during a check or cannot be loaded, every permission on all resources of that check is restricted and the error From bd759c891f052e2f84b6d4209cef0724fc663930 Mon Sep 17 00:00:00 2001 From: Ferdinand Thiessen Date: Fri, 13 Nov 2026 19:18:19 +0100 Subject: [PATCH 3/3] docs(developer): use a generic app in the restriction provider example Assisted-by: ClaudeCode:claude-fable-5-2 Signed-off-by: Ferdinand Thiessen --- developer_manual/digging_deeper/resource_permissions.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/developer_manual/digging_deeper/resource_permissions.rst b/developer_manual/digging_deeper/resource_permissions.rst index 3ac4fa06895..d8812b3c0e0 100644 --- a/developer_manual/digging_deeper/resource_permissions.rst +++ b/developer_manual/digging_deeper/resource_permissions.rst @@ -386,12 +386,12 @@ it restricts. declare(strict_types=1); - namespace OCA\Governance\Resource; + namespace OCA\MyApp\Resource; use NCU\Resource\Permission\CorePermission; use NCU\Resource\Permission\IRestrictionProvider; use NCU\Resource\Permission\Restriction; - use OCA\Governance\Service\LabelService; + use OCA\MyApp\Service\LabelService; use OCP\Actor\Actor; class LabelRestrictionProvider implements IRestrictionProvider {