Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# Changelog

## 8.0.3

### Added

- `Database::authorizeCreate()` and `Database::authorizeUpdate()` take an optional resolver,
`?callable $resolve = null`, so a write staged after other writes is judged as it will run, after them.
`$resolve(string $collection, string $id): ?Document` returns a related document as the earlier staged writes leave
it: the document with its permissions (whether or not the caller can read it), an empty `Document` when they delete
it, or `null` when they leave it as stored. Every related-document lookup of the checks uses it: a document staged
for deletion is new to the write and needs create on its collection, a staged update or create is judged by its
staged permissions and links, and a `null` answer reads the stored document as before. Without `$resolve` the
checks are unchanged, and they still write nothing.

## 8.0.2

8.0.1 reverted six deliberate 8.0.0 changes as if they were regressions. 8.0.2 brings them back; everything else in
Expand Down
6 changes: 6 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -855,6 +855,12 @@ method as a no-op, so a hook overrides only what it needs:
operations of a transaction another request commits, calls them at staging instead of repeating the checks.
`authorizeUpdate()` checks the update against `$old`, the stored document as the caller reads it, and always needs
update permission, where `updateDocument()` lets an update that changes nothing through with read permission.
Since 8.0.3 both take an optional `$resolve(string $collection, string $id): ?Document`, the related documents as
the writes staged earlier leave them: the document with its permissions, unfiltered by the caller's read, an
empty `Document` for one they delete, or `null` for one they leave as stored. Pass it when the staged write runs
after other staged writes, so a related document staged for deletion is judged as new (create on its collection)
and one staged for an update or create by its staged permissions and links, as the later write finds them. Pass
`$old` as the staged writes leave it too.
- `deleteDocuments()` applies `Cascade` and `Restrict` to every related document, also when its queries include a
select (7.x skipped the cascade and ignored `Restrict` then) and also to related documents the caller cannot read.
Each document a cascade reaches is deleted through `deleteDocument()`, so one the caller may not delete throws
Expand Down
100 changes: 76 additions & 24 deletions src/Database/Hook/Relationships.php
Original file line number Diff line number Diff line change
Expand Up @@ -3160,10 +3160,7 @@ private function authorizeLink(Document $collection, Document $related): void
{
$authorization = $this->database->getAuthorization();

if (! $authorization->isValid(new Input(PermissionType::Update, [
...$collection->getPermissionsByType(PermissionType::Update),
...($collection->getAttribute('documentSecurity', false) ? $related->getPermissionsByType(PermissionType::Update) : []),
]))) {
if (! $authorization->isValid(self::permissionInput($collection, $related, PermissionType::Update))) {
throw new AuthorizationException($authorization->getDescription());
}
}
Expand All @@ -3175,23 +3172,32 @@ private function authorizeLink(Document $collection, Document $related): void
* document, and never a stored document the caller cannot read. With $old, the stored document an update
* changes, a related document $old already links is not linked again, so only changing it needs update.
*
* With $resolve, every related document is judged as a later write would find it: $resolve(collection, ID)
* returns the document as it will be when the write runs, with its permissions and without the caller's read
* applied, an empty document when it will be gone, or null to judge the stored one.
*
* @param (callable(string, string): ?Document)|null $resolve
*
* @throws AuthorizationException When writing $document would be refused
* @throws RelationshipException When a relationship value is not a document, an ID or a list of them
*
* @internal
*/
public function authorize(Document $collection, Document $document, ?Document $old = null): void
public function authorize(Document $collection, Document $document, ?Document $old = null, ?callable $resolve = null): void
{
$this->authorizeRelated($collection, $document, $old, []);
$this->authorizeRelated($collection, $document, $old, [], $resolve === null
? null
: static fn (string $collection, string $id): ?Document => $resolve($collection, $id));
}

/**
* @param list<string> $writeStack The collections of the writes relating $document, innermost last
* @param (Closure(string, string): ?Document)|null $resolve
*
* @throws AuthorizationException
* @throws RelationshipException
*/
private function authorizeRelated(Document $collection, Document $document, ?Document $old, array $writeStack): void
private function authorizeRelated(Document $collection, Document $document, ?Document $old, array $writeStack, ?Closure $resolve): void
{
$depth = \count($writeStack);

Expand All @@ -3215,31 +3221,30 @@ private function authorizeRelated(Document $collection, Document $document, ?Doc

foreach (self::relatedValues($value) as $related) {
if (\is_string($related)) {
$this->authorizeLinkById($relatedCollection, $related, $linked);
$this->authorizeLinkById($relatedCollection, $related, $linked, $resolve);

continue;
}

$this->authorizeRelatedDocument($collection, $relatedCollection, $related, $linked, $writeStack);
$this->authorizeRelatedDocument($collection, $relatedCollection, $related, $linked, $writeStack, $resolve);
}
}
}

/**
* @param list<string> $linked
* @param (Closure(string, string): ?Document)|null $resolve
*
* @throws AuthorizationException
*/
private function authorizeLinkById(Document $relatedCollection, string $id, array $linked): void
private function authorizeLinkById(Document $relatedCollection, string $id, array $linked, ?Closure $resolve): void
{
if (\in_array($id, $linked, true)) {
return;
}

$stored = $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $id, [Query::select([Document::ID])]));
$stored = $this->viewRelated($relatedCollection, $id, PermissionType::Read, $resolve, [Query::select([Document::ID])]);
if ($stored->isEmpty()) {
$this->refuseUnreadable($relatedCollection, $id, PermissionType::Read);

return;
}

Expand All @@ -3249,26 +3254,25 @@ private function authorizeLinkById(Document $relatedCollection, string $id, arra
/**
* @param list<string> $linked
* @param list<string> $writeStack
* @param (Closure(string, string): ?Document)|null $resolve
*
* @throws AuthorizationException
* @throws RelationshipException
*/
private function authorizeRelatedDocument(Document $collection, Document $relatedCollection, Document $related, array $linked, array $writeStack): void
private function authorizeRelatedDocument(Document $collection, Document $relatedCollection, Document $related, array $linked, array $writeStack, ?Closure $resolve): void
{
$id = $related->getId();
$stored = $id === ''
? new Document()
: $this->database->skipRelationships(fn () => $this->database->getDocument($relatedCollection->getId(), $id));
: $this->viewRelated($relatedCollection, $id, PermissionType::Update, $resolve);

if ($stored->isEmpty()) {
$this->refuseUnreadable($relatedCollection, $id, PermissionType::Update);

$authorization = $this->database->getAuthorization();
if (! $authorization->isValid(new Input(PermissionType::Create, $relatedCollection->getPermissionsByType(PermissionType::Create)))) {
throw new AuthorizationException($authorization->getDescription());
}

$this->authorizeRelated($relatedCollection, $related, null, [...$writeStack, $collection->getId()]);
$this->authorizeRelated($relatedCollection, $related, null, [...$writeStack, $collection->getId()], $resolve);

return;
}
Expand All @@ -3278,7 +3282,36 @@ private function authorizeRelatedDocument(Document $collection, Document $relate
$this->authorizeLink($relatedCollection, $stored);
}

$this->authorizeRelated($relatedCollection, $related, $stored, [...$writeStack, $collection->getId()]);
$this->authorizeRelated($relatedCollection, $related, $stored, [...$writeStack, $collection->getId()], $resolve);
}

/**
* The related document $id as a write would find it: the document $resolve gives for it, else the stored one,
* either empty when there is none. One the caller cannot read is refused for the reason of $check, or of read
* when the caller passes $check.
*
* @param (Closure(string, string): ?Document)|null $resolve
* @param array<Query> $queries The queries reading the stored document
*
* @throws AuthorizationException
*/
private function viewRelated(Document $collection, string $id, PermissionType $check, ?Closure $resolve, array $queries = []): Document
{
$staged = $resolve === null ? null : $resolve($collection->getId(), $id);
if ($staged === null) {
$stored = $this->database->skipRelationships(fn () => $this->database->getDocument($collection->getId(), $id, $queries));
if ($stored->isEmpty()) {
$this->refuseUnreadable($collection, $id, $check);
}

return $stored;
}

if (! $staged->isEmpty() && ! $this->database->getAuthorization()->isValid(self::permissionInput($collection, $staged, PermissionType::Read))) {
$this->refuseHidden($collection, $staged, $check);
}

return $staged;
}

/**
Expand Down Expand Up @@ -3354,17 +3387,36 @@ private function refuseUnreadable(Document $collection, string $id, PermissionTy
return;
}

$documentSecurity = $collection->getAttribute('documentSecurity', false);
$this->refuseHidden($collection, $stored, $check);
}

/**
* Refuse $hidden, a related document the caller cannot read, for the reason of $check, or of read when the
* caller passes $check.
*
* @throws AuthorizationException
*/
private function refuseHidden(Document $collection, Document $hidden, PermissionType $check): void
{
$authorization = $this->database->getAuthorization();
foreach ($check === PermissionType::Read ? [$check] : [$check, PermissionType::Read] as $type) {
if (! $authorization->isValid(new Input($type, [
...$collection->getPermissionsByType($type),
...($documentSecurity ? $stored->getPermissionsByType($type) : []),
]))) {
if (! $authorization->isValid(self::permissionInput($collection, $hidden, $type))) {
throw new AuthorizationException($authorization->getDescription());
}
}
}

/**
* The permissions granting $type on $document: its collection's, and its own under document security.
*/
private static function permissionInput(Document $collection, Document $document, PermissionType $type): Input
{
return new Input($type, [
...$collection->getPermissionsByType($type),
...($collection->getAttribute('documentSecurity', false) ? $document->getPermissionsByType($type) : []),
]);
}

private function notReferencing(string $twoWayKey, string $documentId): Query
{
return Query::or([
Expand Down
19 changes: 14 additions & 5 deletions src/Database/Trait/Documents.php
Original file line number Diff line number Diff line change
Expand Up @@ -1049,14 +1049,21 @@ public function applySelectFiltersToDocuments(array $documents, array $selectQue
* refused. For staging a write that runs later, such as an operation of a transaction committed by another
* request; createDocument() runs the same checks when it writes.
*
* Writes staged before the later one change what it finds. With $resolve, every related document is judged as
* the later write would find it: $resolve(collection, ID) is given the collection identifier and the document ID
* and returns the document as the staged writes leave it, with its permissions and whether or not the caller can
* read it, an empty document when they delete it, or null when they leave it as stored. A related document the
* staged writes delete is new to the later write, so it needs create rather than update.
*
* @param string $collection The collection identifier
* @param Document $document The document a later createDocument() would be given; it is not changed
* @param (callable(string, string): ?Document)|null $resolve The staged view of a related document; null judges every one as stored
*
* @throws AuthorizationException When createDocument() would refuse the caller
* @throws NotFoundException When the collection does not exist
* @throws RelationshipException When a relationship value is not a document, an ID or a list of them
*/
public function authorizeCreate(string $collection, Document $document): void
public function authorizeCreate(string $collection, Document $document, ?callable $resolve = null): void
{
$definition = $this->silent(fn () => $this->getCollection($collection));

Expand All @@ -1065,7 +1072,7 @@ public function authorizeCreate(string $collection, Document $document): void
}

if ($this->relationshipHook?->isEnabled()) {
$this->relationshipHook->authorize($definition, $document);
$this->relationshipHook->authorize($definition, $document, resolve: $resolve);
}
}

Expand All @@ -1074,17 +1081,19 @@ public function authorizeCreate(string $collection, Document $document): void
* collection, or on $old under document security) and, for every related document $document carries, what
* authorizeCreate() checks for it. A related document $old already links needs no link check, only update when
* it is sent as a document with attributes to change. Unlike updateDocument(), which lets an update that changes
* nothing through with read permission, this always needs update.
* nothing through with read permission, this always needs update. $resolve gives the staged view of related
* documents as for authorizeCreate(); $old is the document as the staged writes leave it.
*
* @param string $collection The collection identifier
* @param Document $old The stored document the update changes, as the caller reads it
* @param Document $document The changes a later updateDocument() would be given; it is not changed
* @param (callable(string, string): ?Document)|null $resolve The staged view of a related document; null judges every one as stored
*
* @throws AuthorizationException When updateDocument() would refuse the caller
* @throws NotFoundException When the collection does not exist
* @throws RelationshipException When a relationship value is not a document, an ID or a list of them
*/
public function authorizeUpdate(string $collection, Document $old, Document $document): void
public function authorizeUpdate(string $collection, Document $old, Document $document, ?callable $resolve = null): void
{
$definition = $this->silent(fn () => $this->getCollection($collection));

Expand All @@ -1096,7 +1105,7 @@ public function authorizeUpdate(string $collection, Document $old, Document $doc
}

if ($this->relationshipHook?->isEnabled()) {
$this->relationshipHook->authorize($definition, $document, $old);
$this->relationshipHook->authorize($definition, $document, $old, $resolve);
}
}

Expand Down
Loading
Loading