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..d8812b3c0e0 --- /dev/null +++ b/developer_manual/digging_deeper/resource_permissions.rst @@ -0,0 +1,533 @@ +.. _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. + +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 +-------- + +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. + +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. + +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. + +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 +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.