From 74cf8e1907d7c99a25f0022016d2785d1a8c1879 Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Wed, 14 Oct 2026 01:37:38 +1300 Subject: [PATCH] feat(authorization): add a grants-only pre-check for staged writes A caller that stages a write and pre-checks it before commit, such as a staged upsert, needs to report a related document granting a role the caller does not hold even when the caller also lacks permission on the parent. authorizeCreate() and authorizeUpdate() check the parent first, so the grant refusal never surfaced at staging. authorizeGrants() runs only the grant part of those pre-checks: the Hook\Nested preparation, the staged view through $resolve and the same Exception\Authorization and Exception\Structure refusals, with no create or update permission check and no write. The grant check never reads the stored parent, so it takes no $old and is the same for a create and an update. While grants are not checked it does nothing and reads nothing, matching the toggle being off everywhere else. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 + UPGRADE.md | 26 +- src/Database/Database.php | 4 +- src/Database/Hook/Nested.php | 8 +- src/Database/Trait/Documents.php | 32 ++ .../Relationships/AuthorizeGrantsTest.php | 503 ++++++++++++++++++ 6 files changed, 578 insertions(+), 10 deletions(-) create mode 100644 tests/unit/Relationships/AuthorizeGrantsTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index a4b382fd5..513070975 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 8.1.1 + +### Added + +- `Database::authorizeGrants(string $collection, Document $document, ?callable $resolve = null): void` runs only the + grant check of `authorizeCreate()` and `authorizeUpdate()`, and writes nothing. It refuses a related document given + as a document that adds a permission for a role the caller does not hold, with the same `Exception\Authorization` + (`Permissions must be one of: ()`), and malformed permissions with the same `Exception\Structure`. Related + documents are first prepared by the `Hook\Nested` hooks, and `$resolve` gives their staged view, as for + `authorizeCreate()`. It checks no create or update permission on the collection, the document or any related + collection or document, so a caller can report an unheld grant before those checks, such as for a staged upsert. + `$document` is the document or the changes a later write would be given. It does nothing, and reads nothing, unless + grants are checked (`setCheckGrants(true)`), authorization is enabled and the relationship hook is attached and + enabled; while they are, a missing collection throws `Exception\NotFound`. + ## 8.1.0 8.1.0 adds opt-in and additive APIs for work that callers had to do themselves. With no `Hook\Nested` hook registered diff --git a/UPGRADE.md b/UPGRADE.md index 3124798e9..612bb0e6f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -42,7 +42,7 @@ Register it with `$database->addHook($hook)`. Remove it with `removeHook($hook)` - **What it is given.** Each related document that `createDocument()`, `createDocuments()`, `updateDocument()`, `upsertDocument()`, `upsertDocuments()`, `authorizeCreate()` or `authorizeUpdate()` is given as a document (a `Document` or an associative array), at every depth the write relates. `$collection` is the collection the related - document belongs to. + document belongs to. Since 8.1.1, `authorizeGrants()` gives it the same documents, only while it checks grants. - **When it runs.** Before the write checks the document's grants and relates it, a parent before its children. Several hooks run in the order they were registered, each given the document the previous one returned. - **What it returns.** It is given a copy, never the caller's document. The write checks and relates the document it @@ -104,9 +104,9 @@ Opt-in. By default 8.1 never compares the permissions a write grants with the ro With `setCheckGrants(true)`, the check covers related documents written through a parent: every related document given as a document to `createDocument()`, `createDocuments()`, `updateDocument()`, `upsertDocument()`, -`upsertDocuments()`, `authorizeCreate()` or `authorizeUpdate()`, as deep as the write relates them, each as the -[`Hook\Nested` hooks](#preparing-nested-related-documents) return it. The document the write is given is not checked. -For each related document whose `$permissions` is not null: +`upsertDocuments()`, `authorizeCreate()`, `authorizeUpdate()` or `authorizeGrants()`, as deep as the write relates +them, each as the [`Hook\Nested` hooks](#preparing-nested-related-documents) return it. The document the write is given +is not checked. For each related document whose `$permissions` is not null: - Malformed permissions throw `Exception\Structure` with the `Validator\Permissions` description. - The permissions it adds must name roles the caller holds (`Authorization::hasRole()`). Otherwise the write throws @@ -133,6 +133,24 @@ A related document's current permissions are read whether or not the caller can - A related document whose `$id` a `Hook\Nested` hook assigned is new to this check: none of its permissions are read, locked or resolved, so all of them are checked. +Since 8.1.1, `authorizeGrants(string $collection, Document $document, ?callable $resolve = null): void` runs this check +alone, without writing anything. It judges the related documents of `$document` exactly as `authorizeCreate()` and +`authorizeUpdate()` do, after the `Hook\Nested` hooks and through `$resolve`, and throws the same exceptions, but it +checks no create or update permission. Call it first when an unheld grant must be reported even if the caller also +lacks permission on the parent, for example for a staged upsert that is not yet known to create or update: + +```php +$database->authorizeGrants('books', $book, $resolve); +if ($stored->isEmpty()) { + $database->authorizeCreate('books', $book, $resolve); +} else { + $database->authorizeUpdate('books', $stored, $book, $resolve); +} +``` + +It does nothing, and reads nothing, unless grants are checked, authorization is enabled and the relationship hook is +attached and enabled. + While grants are checked, `updateDocuments()` refuses a related document given as a document with `Exception\Relationship` (`Invalid relationship value. A bulk update takes related documents by ID while grants are checked.`), because it is not checked there. Related documents given by ID still link. diff --git a/src/Database/Database.php b/src/Database/Database.php index 056c8fce6..02b425500 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1165,8 +1165,8 @@ public function withPreserveSequence(bool $preserve, callable $callback): mixed /** * Set whether, while authorization is enabled, a related document given as a document to createDocument(), - * createDocuments(), updateDocument(), upsertDocument(), upsertDocuments(), authorizeCreate() or authorizeUpdate() - * may only add permissions for roles the caller holds, as deep as the write relates it. Permissions it already + * createDocuments(), updateDocument(), upsertDocument(), upsertDocuments(), authorizeCreate(), authorizeUpdate() + * or authorizeGrants() may only add permissions for roles the caller holds, as deep as the write relates it. Permissions it already * has may be sent back, and permissions it inherits from its parent are never checked. Malformed permissions * throw a structure exception and an unheld role an authorization exception, before anything is written. * While it is on, updateDocuments() refuses a related document given as a document with a relationship diff --git a/src/Database/Hook/Nested.php b/src/Database/Hook/Nested.php index 27a3bdf41..f70fa34fe 100644 --- a/src/Database/Hook/Nested.php +++ b/src/Database/Hook/Nested.php @@ -9,10 +9,10 @@ * Hook for preparing each nested related document of a write before the write relates it. * * It is given each related document that createDocument(), createDocuments(), updateDocument(), upsertDocument(), - * upsertDocuments(), authorizeCreate() or authorizeUpdate() is given as a document (a {@see Document} or an - * associative array), at every depth the write relates. It runs before the write checks the document's grants and - * relates it, a parent before its children. Hooks run in the order they were registered, each given the document - * the previous one returned. + * upsertDocuments(), authorizeCreate(), authorizeUpdate() or, while it checks grants, authorizeGrants() is given as a + * document (a {@see Document} or an associative array), at every depth the write relates. It runs before the write + * checks the document's grants and relates it, a parent before its children. Hooks run in the order they were + * registered, each given the document the previous one returned. * * It is given a copy, never the caller's document, and the document it returns is the one the write uses. In * createDocuments(), upsertDocument() and upsertDocuments() the prepared related documents replace the relationship diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index 9e36a53bc..84af3c9b6 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -1114,6 +1114,38 @@ public function authorizeUpdate(string $collection, Document $old, Document $doc } } + /** + * Check, without writing anything, only the grants of the related documents nested in $document: the part of + * authorizeCreate() and authorizeUpdate() that refuses a related document adding a permission for a role the + * caller does not hold. It checks neither create nor update on $collection, on $document or on any related + * collection or document, so it can run before them, as for a staged upsert that is not known to create or update + * yet. Related documents are first prepared by the registered {@see \Utopia\Database\Hook\Nested} hooks, and + * $resolve gives their staged view, both as for authorizeCreate(). The grants judged are the same whether the + * later write creates or updates the document that relates them. + * + * It does nothing, and reads nothing, unless grants are checked ({@see Database::setCheckGrants()}), authorization + * is enabled, and the relationship hook is attached and enabled. + * + * @param string $collection The collection identifier + * @param Document $document The document, or the changes, a later write 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 a related document adds a permission for a role the caller does not hold + * @throws NotFoundException When the collection does not exist + * @throws RelationshipException When a related document given as an array is not a valid document + * @throws StructureException When a related document's permissions are malformed + */ + public function authorizeGrants(string $collection, Document $document, ?callable $resolve = null): void + { + if (! $this->checksGrants() || ! $this->relationshipHook?->isEnabled()) { + return; + } + + $definition = $this->silent(fn () => $this->getCollection($collection)); + + $this->prepareNestedCopy($definition, $document, $this->nestedPreparation($resolve)); + } + /** * @param string $collection The collection identifier * @param Document $document The document to create diff --git a/tests/unit/Relationships/AuthorizeGrantsTest.php b/tests/unit/Relationships/AuthorizeGrantsTest.php new file mode 100644 index 000000000..f5186bdab --- /dev/null +++ b/tests/unit/Relationships/AuthorizeGrantsTest.php @@ -0,0 +1,503 @@ + The collection and ID of each related document the hook was given, in order + */ + private array $seen = []; + + /** + * @var list The collection and ID of each related document resolved, in order + */ + private array $resolved = []; + + /** + * @return iterable + */ + public static function types(): iterable + { + $adapters = [ + 'memory' => static fn (): Adapter => new Memory(), + 'sqlite' => static fn (): Adapter => new SQLite(new PDO('sqlite::memory:')), + ]; + + foreach ($adapters as $adapterName => $adapter) { + foreach (RelationshipType::cases() as $type) { + yield "{$adapterName}, {$type->value}" => [$adapter, $type]; + } + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testAnUnheldGrantIsReportedWhenTheParentPermissionIsMissing(Closure $adapter, RelationshipType $type): void + { + $this->build($adapter(), $type, [Permission::read(Role::any())], self::everything()); + $old = $this->storedParent(); + $this->database->setCheckGrants(true); + $related = new Document(['name' => 'new', Document::PERMISSIONS => [Permission::read(Role::user('u2'))]]); + $document = $this->parent($type, $related); + $changes = new Document([self::RELATED => self::value($type, $related)]); + + $this->assertSame(self::NO_CREATE, $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $document)), 'authorizeCreate() refuses the parent first'); + $this->assertSame(self::NO_UPDATE, $this->refusal(fn () => $this->database->authorizeUpdate(self::PARENTS, $old, $changes)), 'authorizeUpdate() refuses the parent first'); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document))); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $changes))); + $this->assertSame([], $this->relatedNames()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testItRefusesTheGrantsAuthorizeCreateAndUpdateRefuse(Closure $adapter, RelationshipType $type): void + { + $foreign = [Permission::read(Role::user('u2'))]; + $this->build($adapter(), $type, self::everything(), self::everything()); + $this->store(self::RELATED, self::STORED, $foreign); + $old = $this->storedParent(); + $this->database->setCheckGrants(true); + $malformed = new PermissionsValidator(); + $malformed->isValid(['malformed']); + + $cases = [ + 'a new document adding an unheld role' => [new Document(['name' => 'new', Document::PERMISSIONS => $foreign]), self::UNHELD], + 'a new document adding held roles' => [new Document(['name' => 'new', Document::PERMISSIONS => [Permission::read(Role::user(self::USER)), Permission::update(Role::users())]]), null], + 'a new document given as an array' => [['name' => 'new', Document::PERMISSIONS => $foreign], self::UNHELD], + 'a new document without permissions' => [new Document(['name' => 'new']), null], + 'a stored document sending its roles back' => [new Document([Document::ID => self::STORED, 'name' => 'Stored', Document::PERMISSIONS => $foreign]), null], + 'a stored document adding an unheld role' => [new Document([Document::ID => self::STORED, 'name' => 'Stored', Document::PERMISSIONS => [...$foreign, Permission::update(Role::user('u2'))]]), self::UNHELD], + 'a stored document dropping its roles' => [new Document([Document::ID => self::STORED, 'name' => 'Stored', Document::PERMISSIONS => []]), null], + 'malformed permissions' => [new Document(['name' => 'new', Document::PERMISSIONS => ['malformed']]), $malformed->getDescription()], + ]; + + foreach ($cases as $case => [$related, $expected]) { + $document = $this->parent($type, $related); + $changes = new Document([self::RELATED => self::value($type, $related)]); + $create = $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $document)); + $update = $this->refusal(fn () => $this->database->authorizeUpdate(self::PARENTS, $old, $changes)); + + $this->assertSame($expected, $create, "{$case}: authorizeCreate()"); + $this->assertSame($expected, $update, "{$case}: authorizeUpdate()"); + $this->assertSame($create, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document)), "{$case}: as authorizeCreate()"); + $this->assertSame($update, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $changes)), "{$case}: as authorizeUpdate()"); + } + + $this->expectException(StructureException::class); + $this->database->authorizeGrants(self::PARENTS, $this->parent($type, new Document([Document::PERMISSIONS => ['malformed']]))); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testItChecksNoCreateOrUpdatePermission(Closure $adapter, RelationshipType $type): void + { + $this->build($adapter(), $type, [], []); + $this->store(self::RELATED, self::STORED, []); + $this->database->setCheckGrants(true); + $held = [Permission::read(Role::user(self::USER))]; + $documents = [ + 'a new related document' => $this->parent($type, new Document(['name' => 'new', Document::PERMISSIONS => $held])), + 'a stored related document the caller cannot read' => $this->parent($type, new Document([Document::ID => self::STORED, 'name' => 'Changed', Document::PERMISSIONS => []])), + 'a related document given by ID' => $this->parent($type, self::STORED), + 'unheld roles on the parent itself' => new Document([Document::ID => self::PARENT, Document::PERMISSIONS => [Permission::read(Role::user('u2'))]]), + ]; + + foreach ($documents as $case => $document) { + $this->assertSame(self::NO_CREATE, $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $document)), $case); + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document)), $case); + } + + $this->assertSame([], $this->relatedNames()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testAStagedViewIsJudgedAsAuthorizeCreateJudgesIt(Closure $adapter, RelationshipType $type): void + { + $foreign = [Permission::read(Role::user('u2'))]; + $this->build($adapter(), $type, self::everything(), self::everything()); + $this->store(self::RELATED, self::STORED, $foreign); + $this->database->setCheckGrants(true); + $document = $this->parent($type, new Document([Document::ID => 'staged', 'name' => 'Staged', Document::PERMISSIONS => $foreign])); + $staged = function (string $collection, string $id) use ($foreign): ?Document { + $this->resolved[] = [$collection, $id]; + + return $id === 'staged' + ? new Document([Document::ID => 'staged', Document::COLLECTION => self::RELATED, 'name' => 'Staged', Document::PERMISSIONS => $foreign]) + : null; + }; + $recreated = $this->parent($type, new Document([Document::ID => self::STORED, 'name' => 'Recreated', Document::PERMISSIONS => $foreign])); + $deleted = static fn (string $collection, string $id): ?Document => $id === self::STORED ? new Document() : null; + + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document)), 'Judged as stored, the document is new'); + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document, $staged)), 'Judged as staged, the role is sent back unchanged'); + $this->assertSame([[self::RELATED, 'staged']], $this->resolved); + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $recreated)), 'Judged as stored, the role is sent back unchanged'); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $recreated, $deleted)), 'Staged for deletion, the document is new'); + + foreach ([[$document, null], [$document, $staged], [$recreated, null], [$recreated, $deleted]] as [$checked, $resolve]) { + $this->assertSame( + $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $checked, $resolve)), + $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $checked, $resolve)), + ); + } + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testNestedHooksPrepareEveryRelatedDocumentFirst(Closure $adapter, RelationshipType $type): void + { + $foreign = [Permission::read(Role::user('u2'))]; + $this->build($adapter(), $type, self::everything(), self::everything(), chained: true); + $this->store(self::RELATED, self::MARKER, $foreign); + $this->store(self::LEAVES, self::MARKER, $foreign); + $leaf = new Document([Document::ID => self::MARKER, 'name' => 'leaf', Document::PERMISSIONS => $foreign]); + $related = new Document([Document::ID => self::MARKER, 'name' => 'related', Document::PERMISSIONS => $foreign, self::NEXT => self::value($type, $leaf)]); + $document = $this->parent($type, $related); + $this->database->setCheckGrants(true); + + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document)), 'Judged as the stored marked documents, the roles are sent back unchanged'); + + $this->database->addHook($this->marker()); + + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document, $this->resolveAsStored(...))), 'Judged after the rewrite, the documents are new'); + $this->assertSame([[self::RELATED, self::MARKER]], $this->seen, 'The parent is prepared, then refused before its children'); + $this->assertSame([], $this->resolved, 'A rewritten ID is not resolved'); + $this->assertSame( + $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $document)), + $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $document)), + ); + + $this->seen = []; + $held = new Document([Document::ID => self::MARKER, 'name' => 'related', self::NEXT => self::value($type, $leaf)]); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $this->parent($type, $held))), 'A grant is checked as deep as the write relates'); + $this->assertSame([[self::RELATED, self::MARKER], [self::LEAVES, self::MARKER]], $this->seen); + + $this->seen = []; + $leafless = new Document([Document::ID => self::MARKER, 'name' => 'related', self::NEXT => self::value($type, new Document([Document::ID => self::MARKER, 'name' => 'leaf']))]); + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $this->parent($type, $leafless), $this->resolveAsStored(...)))); + $this->assertSame([[self::RELATED, self::MARKER], [self::LEAVES, self::MARKER]], $this->seen); + $this->assertSame(self::MARKER, $related->getId(), 'The document checked is not changed'); + $this->assertSame(self::MARKER, $leaf->getId()); + $this->assertSame([self::MARKER], self::ids($document->getAttribute(self::RELATED))); + $this->assertSame([], $this->relatedNames()); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testAnIdANestedHookAssignsIsJudgedNew(Closure $adapter, RelationshipType $type): void + { + $foreign = [Permission::read(Role::user('u2'))]; + $this->build($adapter(), $type, self::everything(), self::everything()); + $this->store(self::RELATED, self::STORED, $foreign); + $this->database->setCheckGrants(true)->addHook($this->assigning(self::STORED)); + $staged = static fn (string $collection, string $id): ?Document => $id === self::STORED + ? new Document([Document::ID => self::STORED, Document::COLLECTION => self::RELATED, 'name' => 'Stored', Document::PERMISSIONS => $foreign]) + : null; + $kept = $this->parent($type, new Document([Document::ID => self::STORED, 'name' => 'Stored', Document::PERMISSIONS => $foreign])); + $assigned = $this->parent($type, new Document([Document::ID => self::MARKER, 'name' => 'Stored', Document::PERMISSIONS => $foreign])); + + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $kept))); + $this->assertNull($this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $kept, $staged))); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $assigned))); + $this->assertSame(self::UNHELD, $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $assigned, $staged))); + $this->assertSame( + $this->refusal(fn () => $this->database->authorizeCreate(self::PARENTS, $assigned, $staged)), + $this->refusal(fn () => $this->database->authorizeGrants(self::PARENTS, $assigned, $staged)), + ); + } + + /** + * @param Closure(): Adapter $adapter + */ + #[DataProvider('types')] + public function testItDoesNothingUnlessGrantsAreChecked(Closure $adapter, RelationshipType $type): void + { + $this->build($adapter(), $type, self::everything(), self::everything()); + $this->database->addHook($this->marker()); + $document = $this->parent($type, new Document([Document::ID => self::MARKER, 'name' => 'new', Document::PERMISSIONS => [Permission::read(Role::user('u2'))]])); + $check = fn (string $collection = self::PARENTS) => $this->refusal(fn () => $this->database->authorizeGrants($collection, $document, $this->resolveAsStored(...))); + + $this->assertNull($check(), 'Grants are not checked by default'); + $this->assertNull($check('missing'), 'Nothing is read while grants are not checked'); + + $this->database->setCheckGrants(true); + + $this->assertNull($this->database->withCheckGrants(false, $check)); + $this->assertNull($this->authorization->skip($check), 'Nothing is checked while authorization is disabled'); + $this->assertNull($this->database->skipRelationships($check), 'Nothing is checked while the relationship hook is disabled'); + $this->assertSame([], $this->seen, 'No hook runs while nothing is checked'); + $this->assertSame([], $this->resolved); + $this->assertSame(self::UNHELD, $check()); + + $this->expectException(NotFoundException::class); + $check('missing'); + } + + /** + * @return list + */ + private static function everything(): array + { + return [Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any())]; + } + + /** + * Parents relate RELATED by RELATED; with $chained, RELATED relate LEAVES by NEXT, each related back by BACK. + * + * @param list $parentPermissions + * @param list $relatedPermissions The permissions of the RELATED and LEAVES collections + */ + private function build(Adapter $adapter, RelationshipType $type, array $parentPermissions, array $relatedPermissions, bool $chained = false): void + { + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::users()->toString()); + $this->authorization->addRole(Role::user(self::USER)->toString()); + $this->database = (new Database($adapter, new Cache(new None()))) + ->setAuthorization($this->authorization) + ->setDatabase('authorize_grants') + ->setNamespace('authorize_grants_'.\uniqid()); + $this->database->addHook(new Permissions()); + $this->database->addHook(new Relationships()); + + $this->authorization->skip(function () use ($type, $parentPermissions, $relatedPermissions, $chained): void { + $this->database->create(); + $this->database->createCollection(Collection::create( + self::PARENTS, + attributes: [Attribute::string('name', size: 64)], + permissions: $parentPermissions, + )); + foreach ($chained ? [self::RELATED, self::LEAVES] : [self::RELATED] as $collection) { + $this->database->createCollection(Collection::create( + $collection, + attributes: [Attribute::string('name', size: 64)], + permissions: $relatedPermissions, + documentSecurity: false, + )); + } + $this->database->createRelationship(self::PARENTS, self::relationship($type, self::RELATED, self::RELATED)); + if ($chained) { + $this->database->createRelationship(self::RELATED, self::relationship($type, self::LEAVES, self::NEXT)); + } + }); + } + + private static function relationship(RelationshipType $type, string $related, string $key): Relationship + { + return match ($type) { + RelationshipType::OneToOne => Relationship::oneToOne($related, $key, twoWay: true, twoWayKey: self::BACK, onDelete: RelationshipDeleteAction::SetNull), + RelationshipType::ManyToOne => Relationship::manyToOne($related, $key, twoWay: true, twoWayKey: self::BACK, onDelete: RelationshipDeleteAction::SetNull), + RelationshipType::OneToMany => Relationship::oneToMany($related, $key, twoWay: true, twoWayKey: self::BACK, onDelete: RelationshipDeleteAction::SetNull), + RelationshipType::ManyToMany => Relationship::manyToMany($related, $key, twoWay: true, twoWayKey: self::BACK, onDelete: RelationshipDeleteAction::SetNull), + }; + } + + /** + * @param list $permissions + */ + private function store(string $collection, string $id, array $permissions): void + { + $this->authorization->skip(fn () => $this->database->createDocument($collection, new Document([ + Document::ID => $id, + 'name' => 'Stored', + Document::PERMISSIONS => $permissions, + ]))); + } + + private function storedParent(): Document + { + return $this->authorization->skip(fn (): Document => $this->database->createDocument(self::PARENTS, new Document([ + Document::ID => self::PARENT, + Document::PERMISSIONS => [Permission::read(Role::any())], + ]))); + } + + /** + * @param Document|array|string $related + */ + private function parent(RelationshipType $type, Document|array|string $related): Document + { + return new Document([ + Document::ID => self::PARENT, + Document::PERMISSIONS => [Permission::read(Role::any())], + self::RELATED => self::value($type, $related), + ]); + } + + /** + * @param Document|array|string $related + * @return Document|array|string|list|string> + */ + private static function value(RelationshipType $type, Document|array|string $related): Document|array|string + { + return \in_array($type, [RelationshipType::OneToMany, RelationshipType::ManyToMany], true) ? [$related] : $related; + } + + /** + * A hook that records each related document it is given and replaces a MARKER ID with a generated one. + */ + private function marker(): Nested + { + return new class (self::MARKER, $this->record(...)) implements Nested { + /** + * @param Closure(string, string): void $record + */ + public function __construct( + private readonly string $marker, + private readonly Closure $record, + ) { + } + + #[\Override] + public function prepare(Document $collection, Document $document): Document + { + ($this->record)($collection->getId(), $document->getId()); + + return $document->getId() === $this->marker ? $document->setAttribute(Document::ID, Id::unique()) : $document; + } + }; + } + + /** + * A hook that replaces a MARKER ID with $id. + */ + private function assigning(string $id): Nested + { + return new class (self::MARKER, $id) implements Nested { + public function __construct( + private readonly string $marker, + private readonly string $id, + ) { + } + + #[\Override] + public function prepare(Document $collection, Document $document): Document + { + return $document->getId() === $this->marker ? $document->setAttribute(Document::ID, $this->id) : $document; + } + }; + } + + private function record(string $collection, string $id): void + { + $this->seen[] = [$collection, $id]; + } + + private function resolveAsStored(string $collection, string $id): null + { + $this->resolved[] = [$collection, $id]; + + return null; + } + + /** + * @return list + */ + private function relatedNames(): array + { + $names = $this->authorization->skip(fn (): array => \array_map( + static fn (Document $document): string => \is_string($name = $document->getAttribute('name')) ? $name : '', + $this->database->find(self::RELATED), + )); + + return \array_values(\array_filter($names, static fn (string $name): bool => $name !== 'Stored')); + } + + /** + * @return list + */ + private static function ids(mixed $value): array + { + $values = \is_array($value) ? $value : [$value]; + + return \array_values(\array_map(static fn (mixed $item): string => $item instanceof Document ? $item->getId() : (\is_string($item) ? $item : ''), $values)); + } + + /** + * The message of the authorization or structure exception $check throws, or null when it throws none. + */ + private function refusal(callable $check): ?string + { + try { + $check(); + } catch (AuthorizationException|StructureException $error) { + return $error->getMessage(); + } + + return null; + } +}