diff --git a/src/Database/Collection.php b/src/Database/Collection.php index 593c9a696..17e29d7f4 100644 --- a/src/Database/Collection.php +++ b/src/Database/Collection.php @@ -2,6 +2,7 @@ namespace Utopia\Database; +use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Index as IndexException; use Utopia\Database\Exception\Relationship as RelationshipException; use Utopia\Database\Exception\Structure as StructureException; @@ -16,7 +17,22 @@ class Collection extends Document public const string DOCUMENT_SECURITY = 'documentSecurity'; - private const array CORE_KEYS = [self::ATTRIBUTES, self::INDEXES, self::DOCUMENT_SECURITY]; + /** + * The collection's permissions as they were written, kept only when one of them names a column. + * + * $permissions holds the same set with every column dropped, because that is the form every + * read gate matches against -- the `_perms` side table, Postgres' JSONB containment, Mongo's + * `$in`. Keeping the column there would make a collection granted `read("any", "name")` + * invisible to `any`, since no gate would find the `read("any")` it looks for. + * + * So this field is the collection's permissions and $permissions is a derived index of them. + * Read them through getPermissions(); nothing outside a gate should read $permissions directly. + * Empty whenever no permission names a column, which is almost every collection, and then + * $permissions is already the verbatim set. + */ + public const string COLUMN_PERMISSIONS = 'columnPermissions'; + + private const array CORE_KEYS = [self::ATTRIBUTES, self::INDEXES, self::DOCUMENT_SECURITY, self::COLUMN_PERMISSIONS]; /** @var list|null */ private ?array $attributeModels = null; @@ -80,7 +96,7 @@ public static function create( ]; if ($permissions !== null) { - $data[self::PERMISSIONS] = $permissions; + $data = \array_merge($data, self::splitPermissions($permissions)); } return new self(\array_merge($data, $metadata)); @@ -124,6 +140,44 @@ public static function fromDocument(Document $document): self return $document instanceof self ? $document : self::fromArray($document->getArrayCopy()); } + /** + * Replace the permission set, writing the gate and the verbatim list together. + * + * The only place the two fields are set, so they cannot drift: a caller that writes + * $permissions alone would leave a stale verbatim list behind it, and getPermissions() + * would keep answering with the permissions the collection used to have. + * + * @param list $permissions + * + * @throws DatabaseException + */ + public function setPermissions(array $permissions): self + { + foreach (self::splitPermissions($permissions) as $key => $value) { + $this->setAttribute($key, $value); + } + + return $this; + } + + /** + * @param list $permissions + * @return array> + * + * @throws DatabaseException + */ + private static function splitPermissions(array $permissions): array + { + $scoped = Permission::hasColumns($permissions); + + return [ + self::PERMISSIONS => $scoped ? Permission::withoutColumns($permissions) : $permissions, + // Cleared rather than left alone, so dropping the last column-scoped grant from a + // collection also drops the list that used to describe it. + self::COLUMN_PERMISSIONS => $scoped ? $permissions : [], + ]; + } + public function toDocument(): Document { $document = new Document(); @@ -265,11 +319,52 @@ public function documentSecurity(): bool } /** - * @return list|null + * The collection's permissions as they were written, columns included. + * + * getPermissions() answers with what is stored, which is the role-only projection of these: + * every read gate matches that form, so a column stored there would hide the collection from + * the role it was granted to. Ask here for the grants themselves -- validation, enforcement + * and anything reporting them to a caller -- and ask getPermissions() for what the gate sees. + * + * @return list|null null when the collection declares no permissions + * + * @throws StructureException when the stored grants are not a list of permission strings */ public function declaredPermissions(): ?array { - return $this->offsetExists(self::PERMISSIONS) ? $this->getPermissions() : null; + if (! $this->offsetExists(self::PERMISSIONS) && ! $this->offsetExists(self::COLUMN_PERMISSIONS)) { + return null; + } + + $verbatim = $this->getAttribute(self::COLUMN_PERMISSIONS, []); + + // A definition read straight from storage reaches here before the json filter has run. + if (\is_string($verbatim) && $verbatim !== '') { + $verbatim = \json_decode($verbatim, true); + } + + if ($verbatim === null || $verbatim === '' || $verbatim === []) { + return $this->getPermissions(); + } + + // Never fall back on a value that is present but unreadable. $permissions holds the same + // grants widened to the whole row, so answering with it would quietly report more access + // than the collection was given. + if (! \is_array($verbatim)) { + throw new StructureException('Collection '.self::COLUMN_PERMISSIONS.' must be a list of permission strings'); + } + + $grants = []; + + foreach ($verbatim as $permission) { + if (! \is_string($permission)) { + throw new StructureException('Collection '.self::COLUMN_PERMISSIONS.' must be a list of permission strings'); + } + + $grants[] = $permission; + } + + return $grants; } #[\Override] diff --git a/src/Database/Database.php b/src/Database/Database.php index 5f8d66de2..a7b40707d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -183,6 +183,8 @@ class Database private const string COLLECTION_DOCUMENT_SECURITY = Collection::DOCUMENT_SECURITY; + private const string COLLECTION_COLUMN_PERMISSIONS = Collection::COLUMN_PERMISSIONS; + private const string INDEX_ATTRIBUTES = 'attributes'; /** @@ -200,6 +202,7 @@ class Database self::COLLECTION_ATTRIBUTES => true, self::COLLECTION_INDEXES => true, self::COLLECTION_DOCUMENT_SECURITY => true, + self::COLLECTION_COLUMN_PERMISSIONS => true, ]; protected Adapter $adapter; @@ -268,6 +271,9 @@ class Database /** @var Value|null */ private ?Value $validation = null; + /** @var Value|null */ + private ?Value $columnPermissions = null; + /** @var Value|null */ private ?Value $datePreservation = null; @@ -1350,6 +1356,29 @@ public function withValidation(bool $validation, callable $callback): mixed return $this->validation()->with($validation, $callback); } + /** + * Run the callback accepting collection permissions that name a column. + * + * Off by default, and deliberately so until the column is enforced. A grant naming a column + * is recorded, and the role it names is admitted to the row -- which is what every gate + * matches -- but nothing yet limits that role to the column. So a grant written to narrow + * access currently widens it, and the only safe thing to do with a feature in that state is + * to make it unreachable by accident. + * + * Scoped to the callback rather than stored, because this gates writing such a grant, not + * holding one: a collection created through here keeps its grant, and keeps widening, until + * masking and query refusal land and the gate comes off. + * + * @template T + * + * @param callable(): T $callback + * @return T + */ + public function withColumnPermissions(callable $callback): mixed + { + return $this->columnPermissions()->with(true, $callback); + } + /** * Run the callback without validation for the calling coroutine and the coroutines it starts. * @@ -1635,6 +1664,7 @@ public function snapshot(): Snapshot preserveDates: $this->datePreservation()->get(), preserveSequence: $this->sequencePreservation()->get(), ignoreDuplicates: $this->ignoringDuplicates()->get(), + columnPermissions: $this->columnPermissions()->get(), requestTimestamp: $this->requestTimestamp()->get(), checkGrants: $this->grantChecking()->get(), rewriting: $this->rewriting()->get(), @@ -1681,7 +1711,8 @@ private function withToggles(Snapshot $snapshot, callable $callback): mixed $rewritten = fn (): mixed => $this->rewriting()->with($snapshot->rewriting, $callback); $checked = fn (): mixed => $this->grantChecking()->with($snapshot->checkGrants, $rewritten); $timestamped = fn (): mixed => $this->requestTimestamp()->with($snapshot->requestTimestamp, $checked); - $deduplicated = fn (): mixed => $this->ignoringDuplicates()->with($snapshot->ignoreDuplicates, $timestamped); + $scoped = fn (): mixed => $this->columnPermissions()->with($snapshot->columnPermissions, $timestamped); + $deduplicated = fn (): mixed => $this->ignoringDuplicates()->with($snapshot->ignoreDuplicates, $scoped); $sequenced = fn (): mixed => $this->sequencePreservation()->with($snapshot->preserveSequence, $deduplicated); $dated = fn (): mixed => $this->datePreservation()->with($snapshot->preserveDates, $sequenced); $validated = fn (): mixed => $this->validation()->with($snapshot->validation, $dated); @@ -1735,6 +1766,22 @@ private function validation(): Value return $this->validation ??= new Value(true); } + /** + * @return Value + */ + private function columnPermissions(): Value + { + return $this->columnPermissions ??= new Value(false); + } + + /** + * Whether a collection's permissions may name one of its columns. + */ + public function hasColumnPermissions(): bool + { + return $this->columnPermissions()->get(); + } + /** * @return Value */ @@ -2657,6 +2704,7 @@ private static function sharedDefinition(): Collection Attribute::string(self::COLLECTION_ATTRIBUTES, 1_000_000, filters: [Filter::Json]), Attribute::string(self::COLLECTION_INDEXES, 1_000_000, filters: [Filter::Json]), Attribute::boolean(self::COLLECTION_DOCUMENT_SECURITY, required: true), + Attribute::string(self::COLLECTION_COLUMN_PERMISSIONS, 1_000_000, default: [], filters: [Filter::Json]), ], documentSecurity: false, metadata: [Document::COLLECTION => self::METADATA], diff --git a/src/Database/Document.php b/src/Database/Document.php index c8e479327..1084f776d 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -299,7 +299,18 @@ private function parsePermissions(): array continue; } $type = \trim(\substr($permission, 0, $open)); - $roles[$type][] = \str_replace([')', '"', ' '], '', \substr($permission, $open + 1)); + $role = \substr($permission, $open + 1); + + // A column-scoped permission is read("role", "column"). Only the role belongs here: + // this list answers who holds a permission, and the column narrows what that + // permission reaches, not who holds it. Leaving the column in would produce a role + // of "any,name" that matches nobody. + $separator = \strpos($role, '", "'); + if ($separator !== false) { + $role = \substr($role, 0, $separator + 1); + } + + $roles[$type][] = \str_replace([')', '"', ' '], '', $role); } return $this->parsedPermissions = [ diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 5a180b90a..7ca438fc6 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -516,6 +516,20 @@ public function withValidation(bool $validation, callable $callback): mixed return parent::withValidation($validation, fn (): mixed => $this->source->withValidation($validation, $scoped)); } + /** + * Opened on the mirror, its source and its destination alike: a collection the mirror + * replicates is written to both, and the second write would be refused if only the first + * had asked for column-scoped permissions. + */ + #[\Override] + public function withColumnPermissions(callable $callback): mixed + { + $destination = $this->destination; + $scoped = $destination === null ? $callback : fn (): mixed => $destination->withColumnPermissions($callback); + + return parent::withColumnPermissions(fn (): mixed => $this->source->withColumnPermissions($scoped)); + } + /** * Opens the scope on the mirror, its source and its destination, so the writes the mirror replicates before * returning use the tenant too; the replications it queues carry it (see replicate()). diff --git a/src/Database/Permission.php b/src/Database/Permission.php index 8ded25a20..237d4a741 100644 --- a/src/Database/Permission.php +++ b/src/Database/Permission.php @@ -10,6 +10,15 @@ */ class Permission { + /** + * A permission naming no column covers every column. + * + * The empty string rather than null: a permission is parsed from and rendered back to + * a string, and an absent column has to round-trip through that without becoming the + * word "null" or a column of that name. + */ + public const string ALL_COLUMNS = ''; + private Role $role; /** @@ -28,12 +37,14 @@ class Permission * @param string $role The role name * @param string $identifier The role identifier * @param string $dimension The role dimension + * @param string $column The column this permission is scoped to, or ALL_COLUMNS for the whole row */ public function __construct( private string $permission, string $role, string $identifier = '', string $dimension = '', + private string $column = self::ALL_COLUMNS, ) { $this->role = new Role($role, $identifier, $dimension); } @@ -45,7 +56,27 @@ public function __construct( */ public function toString(): string { - return $this->permission.'("'.$this->role->toString().'")'; + if ($this->column === self::ALL_COLUMNS) { + return $this->permission.'("'.$this->role->toString().'")'; + } + + return $this->permission.'("'.$this->role->toString().'", "'.$this->column.'")'; + } + + /** + * The column this permission is scoped to, or ALL_COLUMNS when it covers the whole row. + */ + public function getColumn(): string + { + return $this->column; + } + + /** + * Does this permission cover every column rather than naming one? + */ + public function isForAllColumns(): bool + { + return $this->column === self::ALL_COLUMNS; } /** @@ -109,6 +140,44 @@ public static function parse(string $permission): self throw new DatabaseException('Invalid permission type: "'.$permission.'".'); } $fullRole = \str_replace('")', '', $permissionParts[1]); + + // A column-scoped permission is read("role", "column"). Split the column off here so + // every branch below parses a bare role exactly as it did before -- the role grammar + // (identifier, dimension) is unchanged by the column, and keeping them separate stops + // the two from having to know about each other. + // + // The role ends at its closing quote, whatever spacing follows the comma. Matching the + // canonical '", "' alone would read read("any","name") as the role any","name, which is + // a role that matches nobody -- and these strings come from callers, not only from + // toString(). + $column = self::ALL_COLUMNS; + $quote = \strpos($fullRole, '"'); + + if ($quote !== false) { + $rest = \ltrim(\substr($fullRole, $quote + 1)); + + if (! \str_starts_with($rest, ',')) { + throw new DatabaseException('Invalid permission: "'.$permission.'".'); + } + + $rest = \ltrim(\substr($rest, 1)); + + if (! \str_starts_with($rest, '"')) { + throw new DatabaseException('Invalid permission: "'.$permission.'".'); + } + + if ($rest === '"') { + throw new DatabaseException('Column must not be empty. Omit the argument to grant every column.'); + } + + if ($rest === '"*') { + throw new DatabaseException('Wildcard column "*" is not supported. Omit the argument to grant every column.'); + } + + $column = \substr($rest, 1); + $fullRole = \substr($fullRole, 0, $quote); + } + $roleParts = \explode(':', $fullRole); $role = $roleParts[0]; @@ -116,13 +185,13 @@ public static function parse(string $permission): self $hasDimension = \str_contains($fullRole, '/'); if (! $hasIdentifier && ! $hasDimension) { - return new self($permission, $role); + return new self($permission, $role, column: $column); } if ($hasIdentifier && ! $hasDimension) { $identifier = $roleParts[1]; - return new self($permission, $role, $identifier); + return new self($permission, $role, $identifier, column: $column); } if (! $hasIdentifier) { @@ -138,7 +207,7 @@ public static function parse(string $permission): self throw new DatabaseException('Dimension must not be empty'); } - return new self($permission, $role, '', $dimension); + return new self($permission, $role, '', $dimension, $column); } // Has both identifier and dimension @@ -154,7 +223,7 @@ public static function parse(string $permission): self throw new DatabaseException('Dimension must not be empty'); } - return new self($permission, $role, $identifier, $dimension); + return new self($permission, $role, $identifier, $dimension, $column); } /** @@ -196,7 +265,8 @@ public static function aggregate(?array $permissions, array $allowed = [Permissi $subType, $permission->getRole(), $permission->getIdentifier(), - $permission->getDimension() + $permission->getDimension(), + $permission->getColumn() ))->toString(); } } @@ -205,19 +275,136 @@ public static function aggregate(?array $permissions, array $allowed = [Permissi return \array_values(\array_unique($mutated)); } + /** + * Does any of these permissions name a column? + * + * @param list $permissions + * + * @throws DatabaseException + */ + public static function hasColumns(array $permissions): bool + { + foreach ($permissions as $permission) { + if (! self::parse($permission)->isForAllColumns()) { + return true; + } + } + + return false; + } + + /** + * The same permissions with every grant on $old pointed at $new. + * + * A grant names a column by its key, so renaming the column would otherwise leave the grant + * naming one that no longer exists: it would reach nothing, and the collection would hold a + * grant createCollection() refuses. Following the rename keeps the grant on the column the + * caller granted, under whatever it is now called. + * + * @param list $permissions + * @return list + * + * @throws DatabaseException + */ + public static function renameColumn(array $permissions, string $old, string $new): array + { + $renamed = []; + + foreach ($permissions as $permission) { + $parsed = self::parse($permission); + + $renamed[] = $parsed->isForAllColumns() || $parsed->getColumn() !== $old + ? $permission + : (new self( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); + } + + return \array_values(\array_unique($renamed)); + } + + /** + * The same permissions without the grants naming $column. + * + * Dropping the column leaves nothing for a grant on it to reach, and a grant left behind + * would name a column the collection no longer has. Grants naming no column are untouched: + * they cover the whole row, which still exists. + * + * @param list $permissions + * @return list + * + * @throws DatabaseException + */ + public static function withoutColumn(array $permissions, string $column): array + { + $kept = []; + + foreach ($permissions as $permission) { + $parsed = self::parse($permission); + + if (! $parsed->isForAllColumns() && $parsed->getColumn() === $column) { + continue; + } + + $kept[] = $permission; + } + + return \array_values(\array_unique($kept)); + } + + /** + * The same permissions with every column dropped, deduplicated. + * + * Whether a caller may reach a row is a question about the role alone: a column narrows what + * a permission reaches, not who holds it. This is the form a read gate matches against -- + * a side table of roles, a JSONB containment, a Mongo `$in` -- so the column is kept only + * where the scope is enforced and never where access is decided. A gate handed the column + * would be comparing `read("any", "name")` against `read("any")` and finding nothing. + * + * @param list $permissions + * @return list + * + * @throws DatabaseException + */ + public static function withoutColumns(array $permissions): array + { + $stripped = []; + + foreach ($permissions as $permission) { + $parsed = self::parse($permission); + + $stripped[] = $parsed->isForAllColumns() + ? $permission + : (new self( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension() + ))->toString(); + } + + return \array_values(\array_unique($stripped)); + } + /** * Create a read permission string from the given Role. * * @param Role $role The role to grant read permission to + * @param string $column Scope the permission to one column, or ALL_COLUMNS for the whole row * @return string The formatted permission string */ - public static function read(Role $role): string + public static function read(Role $role, string $column = self::ALL_COLUMNS): string { $permission = new self( PermissionType::Read->value, $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); @@ -227,15 +414,17 @@ public static function read(Role $role): string * Create a create permission string from the given Role. * * @param Role $role The role to grant create permission to + * @param string $column Scope the permission to one column, or ALL_COLUMNS for the whole row * @return string The formatted permission string */ - public static function create(Role $role): string + public static function create(Role $role, string $column = self::ALL_COLUMNS): string { $permission = new self( PermissionType::Create->value, $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); @@ -245,15 +434,17 @@ public static function create(Role $role): string * Create an update permission string from the given Role. * * @param Role $role The role to grant update permission to + * @param string $column Scope the permission to one column, or ALL_COLUMNS for the whole row * @return string The formatted permission string */ - public static function update(Role $role): string + public static function update(Role $role, string $column = self::ALL_COLUMNS): string { $permission = new self( PermissionType::Update->value, $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); @@ -263,15 +454,17 @@ public static function update(Role $role): string * Create a delete permission string from the given Role. * * @param Role $role The role to grant delete permission to + * @param string $column Scope the permission to one column, or ALL_COLUMNS for the whole row * @return string The formatted permission string */ - public static function delete(Role $role): string + public static function delete(Role $role, string $column = self::ALL_COLUMNS): string { $permission = new self( PermissionType::Delete->value, $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); @@ -281,15 +474,17 @@ public static function delete(Role $role): string * Create a write permission string from the given Role. * * @param Role $role The role to grant write permission to + * @param string $column Scope the permission to one column, or ALL_COLUMNS for the whole row * @return string The formatted permission string */ - public static function write(Role $role): string + public static function write(Role $role, string $column = self::ALL_COLUMNS): string { $permission = new self( PermissionType::Write->value, $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); diff --git a/src/Database/State/Snapshot.php b/src/Database/State/Snapshot.php index 818fd3eb3..6f689c8ce 100644 --- a/src/Database/State/Snapshot.php +++ b/src/Database/State/Snapshot.php @@ -26,6 +26,7 @@ * @param bool $preserveDates Whether writes keep the dates they are given * @param bool $preserveSequence Whether writes keep the sequences they are given * @param bool $ignoreDuplicates Whether creating a document that exists is skipped instead of failing + * @param bool $columnPermissions Whether a collection's permissions may name one of its columns * @param DateTime|null $requestTimestamp The time an update conflicts after * @param bool $checkGrants Whether a related document may only be given permissions for roles the caller holds * @param bool $rewriting Whether {@see \Utopia\Database\Hook\Queries} hooks rewrite the queries of reads @@ -45,6 +46,7 @@ public function __construct( public bool $preserveDates, public bool $preserveSequence, public bool $ignoreDuplicates, + public bool $columnPermissions, public ?DateTime $requestTimestamp, public bool $checkGrants = false, public bool $rewriting = true, diff --git a/src/Database/Trait/Attributes.php b/src/Database/Trait/Attributes.php index 3b27fd3ca..f82a9cc58 100644 --- a/src/Database/Trait/Attributes.php +++ b/src/Database/Trait/Attributes.php @@ -23,6 +23,7 @@ use Utopia\Database\Exception\Refused as RefusedException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Schema; use Utopia\Database\SetType; use Utopia\Database\Validator\AttributeDefinition; @@ -233,6 +234,11 @@ public function updateAttribute(string $collection, string $key, Update $update) $this->writeAttributes($definition, $attributes); if ($renaming) { $this->writeIndexes($definition, $indexes); + + // A grant names its column by key, so it follows the rename as the indexes above + // do. Left alone it would name a key nothing has: it would reach nothing, and the + // collection would hold a grant createCollection() refuses. + $definition->setPermissions(Permission::renameColumn($definition->declaredPermissions() ?? [], $key, $newKey)); } if ( @@ -376,6 +382,10 @@ public function deleteAttribute(string $collection, string $key): void $this->writeAttributes($definition, \array_values($attributes)); $this->writeIndexes($definition, self::withoutIndexedAttribute($indexes, $key)); + // Nothing is left for a grant on this column to reach, and one left behind would name + // a column the collection no longer has -- which createCollection() refuses outright. + $definition->setPermissions(Permission::withoutColumn($definition->declaredPermissions() ?? [], $key)); + $deletedInSchema = $this->deleteAttributeFromSchema($definition->getId(), $key); $this->updateMetadata( @@ -449,6 +459,10 @@ public function renameAttribute(string $collection, string $old, string $new): v $this->writeAttributes($definition, $attributes); $this->writeIndexes($definition, self::renameIndexedAttribute($indexes, $old, $new)); + // A grant names its column by key, so it follows the rename with it. setPermissions() + // writes the gate and the verbatim list together, so the two cannot drift. + $definition->setPermissions(Permission::renameColumn($definition->declaredPermissions() ?? [], $old, $new)); + $this->updateMetadata( collection: $definition, rollbackOperation: fn () => $this->adapter->renameAttribute($definition->getId(), $new, $old), diff --git a/src/Database/Trait/Collections.php b/src/Database/Trait/Collections.php index 261880cb2..b72cb62c9 100644 --- a/src/Database/Trait/Collections.php +++ b/src/Database/Trait/Collections.php @@ -54,10 +54,17 @@ public function createCollection(Collection $collection): Collection $typeValidator->checkType($attribute); } + // Off until the column is enforced: the role a scoped grant names is admitted to the + // row, and nothing yet holds it to the column, so such a grant widens access instead of + // narrowing it. Refused rather than recorded, so no one meets that by accident. + if (! $this->hasColumnPermissions() && Permission::hasColumns($permissions)) { + throw new DatabaseException('Column-scoped permissions are not enabled'); + } + if ($this->validation()->get()) { - $validator = new Permissions(); + $validator = new Permissions(columns: \array_map(fn (Attribute $attribute) => $attribute->key, $attributes)); if (! $validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); + throw new StructureException($validator->getDescription()); } } @@ -160,21 +167,28 @@ public function createCollection(Collection $collection): Collection */ public function updateCollection(string $collection, Update $update): Collection { + $definition = $this->silent(fn () => $this->getOwnCollection($collection)); + + // After the read, not before: a column-scoped permission is judged against this + // collection's columns, so there is nothing to judge it with until the definition + // is in hand. + if ($update->permissions !== null && ! $this->hasColumnPermissions() && Permission::hasColumns($update->permissions)) { + throw new DatabaseException('Column-scoped permissions are not enabled'); + } + if ($update->permissions !== null && $this->validation()->get()) { - $validator = new Permissions(); + $validator = new Permissions(columns: \array_map(fn (Attribute $attribute) => $attribute->key, $definition->attributes())); if (! $validator->isValid($update->permissions)) { - throw new DatabaseException($validator->getDescription()); + throw new StructureException($validator->getDescription()); } } - $definition = $this->silent(fn () => $this->getOwnCollection($collection)); - if ($update->permissions === null && $update->documentSecurity === null) { return $definition; } if ($update->permissions !== null) { - $definition->setAttribute(Document::PERMISSIONS, $update->permissions); + $definition->setPermissions($update->permissions); } if ($update->documentSecurity !== null) { diff --git a/src/Database/Trait/Documents.php b/src/Database/Trait/Documents.php index bf9c7e138..1b2d0b528 100644 --- a/src/Database/Trait/Documents.php +++ b/src/Database/Trait/Documents.php @@ -46,10 +46,12 @@ use Utopia\Database\Operator; use Utopia\Database\OperatorType; use Utopia\Database\ParsedQuery; +use Utopia\Database\Permission; use Utopia\Database\PermissionType; use Utopia\Database\Query; use Utopia\Database\RelationshipSide; use Utopia\Database\RelationshipType; +use Utopia\Database\Role; use Utopia\Database\Rounding; use Utopia\Database\Validator\Authorization\Input; use Utopia\Database\Validator\BigInt; @@ -598,7 +600,11 @@ public function getDocument(string $collection, string $id, array $queries = [], ? self::sharedDefinition() : $this->silent(fn () => $this->getCollection($collection)); - return $this->readDocument($definition, $id, $this->rewriteQueries(Event::DocumentRead, $definition, $queries), $forUpdate); + $document = $this->readDocument($definition, $id, $this->rewriteQueries(Event::DocumentRead, $definition, $queries), $forUpdate); + + $this->maskColumns($definition, $document); + + return $document; } /** @@ -1345,6 +1351,8 @@ public function createDocument(string $collection, Document $document): Document $this->dispatch(new Event\Document\Created($collection->getId(), $document), $listeners); } + $this->maskColumns($collection, $document); + return $document; } @@ -1488,10 +1496,332 @@ private function assertCreateTenancy(string $collection): void } /** + * The columns the caller may reach on this collection, or null when nothing narrows them. + * + * Grants only ever add: a grant naming no column reaches every column, one naming a column + * reaches that column alone, and a caller's reach is the union over the grants whose role + * they hold. Nothing is restricted by default and nothing a grant says takes access away -- + * a column is simply unreachable until something grants it. + * + * Null means "do not narrow", which covers the ordinary collection where no grant names a + * column at all, and the caller who holds a whole-row grant. A document's own permissions + * never name a column, so a row-level grant is whole-row and reaches everything. + * + * @return list|null + * + * @throws DatabaseException + * @throws StructureException + */ + private function grantedColumns(Document $collection, PermissionType $type): ?array + { + $definition = Collection::fromDocument($collection); + + // The overwhelming majority of collections name no column anywhere, and pay one + // array check rather than a parse of every permission on every read. + if ($definition->getAttribute(Collection::COLUMN_PERMISSIONS, []) === []) { + return null; + } + + $columns = []; + $narrowed = false; + + foreach ($definition->declaredPermissions() ?? [] as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->getPermission() !== $type->value) { + continue; + } + + $role = (new Role($parsed->getRole(), $parsed->getIdentifier(), $parsed->getDimension()))->toString(); + + if (! $this->authorization->isValid(new Input($type, [$role]))) { + continue; + } + + if ($parsed->isForAllColumns()) { + return null; + } + + $narrowed = true; + $columns[] = $parsed->getColumn(); + } + + return $narrowed ? \array_values(\array_unique($columns)) : null; + } + + /** + * Refuse a join while a column restriction applies to either side of it. + * + * A join names columns in a second collection under an alias, and returns them beside the + * base row as "alias.column". Neither this gate nor maskColumns() resolves that alias: the + * gate would read the joined names against the base collection's grants, and the mask only + * knows the base collection, so the joined columns would come back unshaped. That cuts both + * ways -- a restricted base must not reach into an open collection, and an open base must + * not be used to read a restricted one -- so the join is refused if either side narrows for + * this role. A role holding a whole-row grant on both sides is unrestricted and joins as it + * always did. + * + * @param array $queries + * @param array|null $joinedCollections The collection each join names, by its id + * + * @throws QueryException + * @throws DatabaseException + * @throws StructureException + */ + private function rejectRestrictedJoins(Document $collection, array $queries, ?array &$joinedCollections): void + { + $joined = self::joinedBy($queries); + + if ($joined === []) { + return; + } + + // An adapter that cannot join at all refuses the join further down, for a better + // reason than this one. Stepping aside leaves that error to it, and a join that can + // never run leaks nothing in the meantime. + if (! $this->adapter->supports(Capability::Joins)) { + return; + } + + $message = 'Join queries are not supported while a column-scoped permission applies'; + + // Before resolving anything: a restricted base refuses the join whatever it joins. + if ($this->grantedColumns($collection, PermissionType::Read) !== null) { + throw new QueryException($message); + } + + // The read's own map, filled in here when this gate is the first to need it and reused + // by the rest of the read, so a joined collection is still resolved once per read. + $joinedCollections ??= $this->resolveJoinedCollections($queries); + + foreach ($joined as $id) { + // Absent from the map only for a nested join, which it does not walk. A join naming + // no such collection is validation's to report, not this gate's. + $definition = $joinedCollections[$id] ?? $this->silent(fn (): ?Collection => $this->findCollection($id)); + + if ($definition !== null && $this->grantedColumns($definition, PermissionType::Read) !== null) { + throw new QueryException($message); + } + } + } + + /** + * Every collection a set of queries joins, nested joins included. + * + * @param array $queries + * @return list + */ + private static function joinedBy(array $queries): array + { + $ids = []; + + foreach ($queries as $query) { + if (! $query instanceof Query) { + continue; + } + + if ($query->getMethod()->isJoin() && $query->getAttribute() !== '') { + $ids[] = $query->getAttribute(); + } + + // A nested join is a join among its parent's values. + foreach (self::joinedBy($query->getValues()) as $id) { + $ids[] = $id; + } + } + + return \array_values(\array_unique($ids)); + } + + /** + * Remove the columns the caller was not granted from a document on its way back. + * + * The other half of the gate: a query naming an ungranted column is refused, and a read + * naming nothing is answered with what the caller may see. Asking for everything -- a bare + * read, or select('*') -- is not refusing, so it is shaped rather than thrown. + * + * The key is removed rather than nulled: null would be indistinguishable from a column that + * genuinely holds null, which tells the caller something untrue. System attributes are left + * alone; they are not columns and the row gate already decided them. + * + * @throws DatabaseException + * @throws StructureException + */ + private function maskColumns(Document $collection, Document $document): void + { + if (! $this->authorization->getStatus() || $document->isEmpty()) { + return; + } + + $granted = $this->grantedColumns($collection, PermissionType::Read); + + if ($granted === null) { + return; + } + + foreach (\array_keys($document->getArrayCopy()) as $key) { + if (\str_starts_with($key, '$') || \in_array($key, $granted, true)) { + continue; + } + + $document->removeAttribute($key); + } + } + + /** + * Refuse a write that touches a column the caller may not change. + * + * The mirror of the read gate, and the simpler half: there is no query surface, only the + * columns the document carries. A caller granted one column may change that column and no + * other, and the write is refused whole rather than applied in part -- a half-applied update + * is harder to reason about than one that did not happen. + * + * @throws AuthorizationException + * @throws DatabaseException + * @throws StructureException + */ + private function assertWritableColumns(Document $collection, Document $document): void + { + if (! $this->authorization->getStatus()) { + return; + } + + $granted = $this->grantedColumns($collection, PermissionType::Update); + + if ($granted === null) { + return; + } + + // The row's own permissions are not a column, and rewriting them is a whole-row act: + // a caller who may change one column could otherwise grant itself delete and destroy + // the row, turning a grant over one column into power over all of it. The other system + // attributes are not writable this way, so skipping them stays correct. + if ($document->offsetExists(Document::PERMISSIONS)) { + throw new AuthorizationException('Missing "update" permission for the row\'s permissions, which a column-scoped grant does not cover.'); + } + + foreach (\array_keys($document->getArrayCopy()) as $key) { + if (\str_starts_with($key, '$')) { + continue; + } + + if (! \in_array($key, $granted, true)) { + throw new AuthorizationException('Missing "update" permission for column "'.$key.'".'); + } + } + } + + /** + * Refuse a query that names a column the caller may not read. + * + * Masking the result is not enough on its own: a filter on a column answers questions about + * it whether or not the value comes back, and Query::greaterThan() turns that into a binary + * search. Ordering leaks the order, and an aggregate leaks the aggregate. So anything that + * names a column is settled here, before the adapter sees it. + * + * @param array $queries + * @param array|null $joinedCollections The collection each join names, by its id + * * @throws AuthorizationException * @throws DatabaseException * @throws StructureException */ + private function assertQueryColumns(Document $collection, array $queries, ?array &$joinedCollections = null): void + { + if (! $this->authorization->getStatus()) { + return; + } + + // Before the base collection's own grant is consulted: a join is refused for a + // restriction on either side, and an unrestricted base is exactly the case where one + // can be used to read a restricted collection. + $this->rejectRestrictedJoins($collection, $queries, $joinedCollections); + + $granted = $this->grantedColumns($collection, PermissionType::Read); + + if ($granted === null) { + return; + } + + foreach (self::columnsNamedBy($queries) as $column) { + if (! \in_array($column, $granted, true)) { + throw new AuthorizationException('Missing "read" permission for column "'.$column.'".'); + } + } + } + + /** + * Every column a set of queries names, nested ones included. + * + * @param array $queries + * @return list + */ + private static function columnsNamedBy(array $queries): array + { + $columns = []; + + foreach ($queries as $query) { + if (! $query instanceof Query) { + continue; + } + + if ($query->getMethod() === Method::Select) { + foreach ($query->getValues() as $value) { + // '*' names nothing in particular -- it asks for whatever the caller is + // entitled to, which is what a read with no select asks for too. Naming a + // column is what gets refused; asking for the lot is not. + if (\is_string($value) && $value !== '' && $value !== '*' && ! \str_starts_with($value, '$')) { + $columns[] = $value; + } + } + + continue; + } + + $attribute = $query->getAttribute(); + + if ($attribute !== '' && ! \str_starts_with($attribute, '$')) { + $columns[] = $attribute; + } + + // and/or carry their operands as values + foreach (self::columnsNamedBy($query->getValues()) as $column) { + $columns[] = $column; + } + } + + return \array_values(\array_unique($columns)); + } + + /** + * A row's permissions may not name a column. + * + * No column list on purpose, and no exemption: a column-scoped permission belongs to a + * collection, and a row naming one would be stored where nothing enforces it. It is also + * what every read gate matches against -- the `_perms` side table, Postgres' JSONB + * containment, Mongo's `$in` -- so a column stored here hides the row from the role it was + * granted to, while getDocument() still returns it. + * + * Every path that writes permissions calls this. It used to live inline in + * prepareDocument(), which only createDocument() reaches, so the rule held on one entry + * point out of six and a batch create, an update or an upsert stored the grant happily. + * + * @throws DatabaseException + * @throws StructureException + */ + private function assertRowPermissions(Document $document): void + { + if (! $this->validation()->get()) { + return; + } + + $validator = new Permissions(columns: null); + + if (! $validator->isValid($document->getPermissions())) { + throw new DatabaseException($validator->getDescription()); + } + } + private function prepareDocument(Document $collection, Document $document): Document { if ($collection->getId() !== self::METADATA) { @@ -1530,15 +1860,40 @@ private function prepareDocument(Document $collection, Document $document): Docu } } - $document = $this->encode($collection, $document); + // Before encode(), which turns both of these into JSON strings. + // + // A collection definition records the grants it was given, columns and all, beside the + // role-only projection of them that goes in $permissions. The rule below never sees them + // -- by the time they reach a row they are not in $permissions any more -- so they are + // judged here, against the columns the definition itself declares. Without this, a write + // that reaches past createCollection() could record a grant on a column that does not + // exist, and nothing would ever say so. + if ($this->validation()->get() && $collection->getId() === Database::METADATA) { + $declared = $document->getAttribute(Collection::ATTRIBUTES, []); + $columns = []; + + foreach (\is_array($declared) ? $declared : [] as $attribute) { + $key = match (true) { + $attribute instanceof Document => $attribute->getAttribute('key'), + \is_array($attribute) => $attribute['key'] ?? null, + default => null, + }; + + if (\is_string($key) && $key !== '') { + $columns[] = $key; + } + } - if ($this->validation()->get()) { - $validator = new Permissions(); - if (! $validator->isValid($document->getPermissions())) { + $validator = new Permissions(columns: $columns); + if (! $validator->isValid($document->getAttribute(Collection::COLUMN_PERMISSIONS, []))) { throw new DatabaseException($validator->getDescription()); } } + $document = $this->encode($collection, $document); + + $this->assertRowPermissions($document); + if ($this->validation()->get()) { $structure = new Structure( collection: $collection, @@ -1600,6 +1955,10 @@ public function createDocuments( } } + foreach ($documents as $document) { + $this->assertRowPermissions($document); + } + $time = DateTime::now(); $modified = 0; $hasRelationships = self::relationshipAttributes($collection) !== []; @@ -1701,6 +2060,7 @@ function () use ($collection, $chunk): array { foreach ($batch as $document) { if ($onNext !== null) { + $this->maskColumns($collection, $document); $onNext($document, null); } @@ -1734,7 +2094,12 @@ public function updateDocument(string $collection, string $id, Document $documen throw new DatabaseException('Must define $id attribute'); } + $this->assertRowPermissions($document); + $collection = $this->silent(fn () => $this->getCollection($collection)); + + $this->assertWritableColumns($collection, $document); + $preparation = $this->nestedPreparation(lock: true); $newUpdatedAt = $document->getUpdatedAt(); $hasOperators = false; @@ -2021,6 +2386,8 @@ public function updateDocument(string $collection, string $id, Document $documen $this->dispatch(new Event\Document\Updated($collection->getId(), $document), $listeners); } + $this->maskColumns($collection, $document); + return $document; } @@ -2058,6 +2425,9 @@ public function updateDocuments( $this->rejectJoins($queries, 'Join queries are not supported for bulk updates'); + $this->assertRowPermissions($updates); + $this->assertWritableColumns($this->silent(fn () => $this->getCollection($collection)), $updates); + if ($updates->isEmpty()) { return 0; } @@ -2271,6 +2641,8 @@ public function updateDocuments( foreach ($batch as $index => $doc) { if ($onNext !== null) { + $this->maskColumns($collection, $doc); + $this->maskColumns($collection, $old[$index]); $onNext($doc, $old[$index]); } $modified++; @@ -2391,6 +2763,10 @@ private function upsert( throw new DatabaseException('Adapter does not support upserts'); } + foreach ($documents as $document) { + $this->assertRowPermissions($document); + } + if ( $this->adapter->hasSharedTables() && ! $this->adapter->isTenantPerDocument() @@ -2432,6 +2808,15 @@ private function upsert( $old = $existing[$this->upsertKey($document)] ?? new Document(); $document = $this->removeUnknownAttributes($collection, $document); + + // An upsert onto a stored row is an update, and is gated as updateDocument() is. + // Before keepStored() merges the row in, so the columns judged are the ones the + // caller wrote rather than every column the row happens to hold. An upsert that + // creates carries no stored row and is covered by the create grant instead. + if (! $old->isEmpty()) { + $this->assertWritableColumns($collection, $document); + } + $unchanged = $this->roundOperators($collection, $document); if (! $old->isEmpty()) { self::keepStored($document, $old, $unchanged); @@ -2693,9 +3078,11 @@ function () use ($collection, $increase, $chunk): array { if (! $old->isEmpty()) { $old = $this->castAfterDocument($collection, $old); + $this->maskColumns($collection, $old); } if ($onNext !== null) { + $this->maskColumns($collection, $doc); $onNext($doc, $old->isEmpty() ? null : $old); } } @@ -2796,6 +3183,9 @@ public function increaseDocumentAttribute( $this->assertPositiveChange($value); $collection = $this->silent(fn () => $this->getCollection($collection)); + + // The one column the counter changes, judged as any other write of it would be. + $this->assertWritableColumns($collection, new Document([$attribute => null])); $numericAttribute = $this->numericAttribute($collection, $attribute); $declared = $numericAttribute ?? $this->declaredAttribute($collection, $attribute); @@ -2891,6 +3281,8 @@ public function increaseDocumentAttribute( $this->dispatch(new Event\Document\Increased($collection->getId(), $document, $attribute), $listeners); } + $this->maskColumns($collection, $document); + return $document; } @@ -2933,6 +3325,9 @@ public function decreaseDocumentAttribute( $collection = $this->silent(fn () => $this->getCollection($collection)); + // The one column the counter changes, judged as any other write of it would be. + $this->assertWritableColumns($collection, new Document([$attribute => null])); + $numericAttribute = $this->numericAttribute($collection, $attribute); $declared = $numericAttribute ?? $this->declaredAttribute($collection, $attribute); @@ -3028,6 +3423,8 @@ public function decreaseDocumentAttribute( $this->dispatch(new Event\Document\Decreased($collection->getId(), $document, $attribute), $listeners); } + $this->maskColumns($collection, $document); + return $document; } @@ -3268,6 +3665,7 @@ public function deleteDocuments( foreach ($deleted as $document) { if ($onNext !== null) { + $this->maskColumns($collection, $document); $onNext($document, clone $document); } $modified++; @@ -4277,7 +4675,13 @@ public function find(string $collection, array $queries = [], PermissionType $fo { $definition = $this->silent(fn () => $this->getCollection($collection)); - return $this->fetch($definition, $this->rewriteQueries(Event::DocumentFind, $definition, $queries), $forPermission, false); + $documents = $this->fetch($definition, $this->rewriteQueries(Event::DocumentFind, $definition, $queries), $forPermission, false); + + foreach ($documents as $document) { + $this->maskColumns($definition, $document); + } + + return $documents; } /** @@ -4322,6 +4726,12 @@ private function fetch(Collection $collection, array $queries, PermissionType $f $relationships = self::relationshipAttributes($collection); + // Covers aggregate() too, which reads through here: min, max and the rest arrive as + // queries naming the column they aggregate, and an aggregate over a column you cannot + // read discloses it as surely as reading it. After checkQueryTypes() above, so a + // malformed query is still reported as malformed rather than reaching this. + $this->assertQueryColumns($collection, $queries, $joinedCollectionsById); + $parsed = Query::groupByType($queries); $filters = $parsed->filters; $selects = $parsed->selections; @@ -4630,6 +5040,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = $this->rewriteQueries(Event::DocumentCount, $collection, $queries); $this->checkQueryTypes($queries); + $queries = self::resolveOneToManyJoins($collection, $queries); $joinedCollections = null; @@ -4639,6 +5050,8 @@ public function count(string $collection, array $queries = [], ?int $max = null) $this->validateDocumentsQueries($collection, $queries, $joinedCollections); } + $this->assertQueryColumns($collection, $queries, $joinedCollections); + $documentSecurity = $collection->getAttribute('documentSecurity', false); $collectionGranted = $this->authorization->isValid(new Input(PermissionType::Read, $collection->getPermissionsByType(PermissionType::Read))); @@ -4851,6 +5264,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = $this->rewriteQueries(Event::DocumentSum, $collection, $queries); $this->checkQueryTypes($queries); + $queries = self::resolveOneToManyJoins($collection, $queries); $joinedCollections = null; @@ -4863,6 +5277,9 @@ public function sum(string $collection, string $attribute, array $queries = [], $this->validateSumAttribute($collection, $attribute, $queries, $joinedCollections); } + // The aggregated column is read too, so it is named alongside the queries. + $this->assertQueryColumns($collection, [...$queries, Query::sum($attribute)], $joinedCollections); + if (! \str_contains($attribute, '.') && ! $this->declaresSumAttribute($collection, $attribute)) { $joinedCollections ??= $this->resolveJoinedCollections($queries); $attribute = $this->resolveSumAttribute($collection, $attribute, $queries, $joinedCollections); @@ -5183,6 +5600,7 @@ private function pages( public function aggregate(string $collection, array $queries): array { $definition = $this->silent(fn () => $this->getCollection($collection)); + $queries = self::aliasAggregates($this->rewriteQueries(Event::DocumentAggregate, $definition, $queries)); $floating = self::floatingAggregates($queries); diff --git a/src/Database/Trait/Relationships.php b/src/Database/Trait/Relationships.php index 20296afaa..5c6c0efde 100644 --- a/src/Database/Trait/Relationships.php +++ b/src/Database/Trait/Relationships.php @@ -20,6 +20,7 @@ use Utopia\Database\Exception\Refused as RefusedException; use Utopia\Database\Exception\Structure as StructureException; use Utopia\Database\Index; +use Utopia\Database\Permission; use Utopia\Database\Relationship; use Utopia\Database\Relationship\Update as RelationshipUpdate; use Utopia\Database\RelationshipSide; @@ -493,6 +494,16 @@ public function deleteRelationship(string $collection, string $key): void $collection->setAttribute(self::COLLECTION_ATTRIBUTES, $collectionAttributes); $relatedCollection->setAttribute(self::COLLECTION_ATTRIBUTES, $relatedCollectionAttributes); + // A relationship is an attribute, so a permission may name it as its column. Both + // sides lose their key here, so both lose the grants that named it -- a grant left + // behind would name a column the collection no longer has, which createCollection() + // refuses outright. The same rule deleteAttribute() follows for an ordinary column. + $collection->setPermissions(Permission::withoutColumn($collection->declaredPermissions() ?? [], $key)); + + if ($twoWayKey !== '') { + $relatedCollection->setPermissions(Permission::withoutColumn($relatedCollection->declaredPermissions() ?? [], $twoWayKey)); + } + try { $shouldRollback = $this->deleteRelationshipFromSchema($collection->getId(), $relationship, $side); } catch (Throwable $error) { @@ -777,6 +788,14 @@ private function replaceAttribute(string $collection, string $key, Attribute $at } $definition->setAttribute(self::COLLECTION_ATTRIBUTES, \array_map(static fn (Attribute $stored): Document => $stored->toDocument(), $attributes)); + + // The one place a relationship's key changes on either side, so the one place a grant + // naming it has to follow. Left alone it would name a key nothing has -- unreachable, + // and a grant createCollection() refuses. deleteRelationship() drops them the same way + // when the key goes entirely. + if ($attribute->key !== $key) { + $definition->setPermissions(Permission::renameColumn($definition->declaredPermissions() ?? [], $key, $attribute->key)); + } $this->updateMetadata( collection: $definition, rollbackOperation: null, diff --git a/src/Database/Validator/Permissions.php b/src/Database/Validator/Permissions.php index bcb96f493..1381c51d6 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -20,14 +20,31 @@ class Permissions extends Roles #[\Override] protected int $length; + /** + * Columns a permission in this set is allowed to name. + * + * @var array|null + */ + private ?array $columns; + + private Key $key; + /** * @param int $length maximum amount of permissions. 0 means unlimited. * @param array $allowed allowed permissions. Defaults to all available. + * @param array|null $columns the collection's column keys. A permission may name one + * of these and no other. Null means this set may not name + * a column at all, which is the case for every permission + * outside a collection's own -- a document's permissions + * carry no column, and nothing else has a column list to + * judge one against. */ - public function __construct(int $length = 0, array $allowed = [PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete, PermissionType::Write]) + public function __construct(int $length = 0, array $allowed = [PermissionType::Create, PermissionType::Read, PermissionType::Update, PermissionType::Delete, PermissionType::Write], ?array $columns = null) { $this->length = $length; $this->allowed = \array_map(fn (PermissionType $p) => $p->value, $allowed); + $this->columns = $columns; + $this->key = new Key(); } #[\Override] @@ -98,6 +115,56 @@ public function isValid(mixed $value): bool if (! $this->isValidRole($role, $identifier, $dimension)) { return false; } + + if (! $permission->isForAllColumns() && ! $this->isValidColumn($permission)) { + return false; + } + } + + return true; + } + + /** + * A permission may name a column only where columns are meaningful, and only one that exists. + * + * Reported by the key the caller wrote. Nothing has translated it yet at this point, and an + * error naming something the caller did not type is worse than no error at all. + */ + private function isValidColumn(Permission $permission): bool + { + $type = $permission->getPermission(); + + // Before anything about where the permission lives. Only read and update answer a + // question about one column. A create brings a whole row into being, so narrowing it to + // one column would have to mean something about the columns it leaves unset, which is + // not a thing a grant can say; a delete removes the whole row, so narrowing it says + // nothing at all; and write aggregates to create, update and delete, so it inherits + // both objections. Refused rather than accepted and ignored: a grant that reads as a + // restriction and enforces none is worse than no grant. + if (\in_array($type, [PermissionType::Create->value, PermissionType::Delete->value, PermissionType::Write->value], true)) { + $this->message = 'Permission "'.$type.'" cannot be scoped to a column, it applies to the whole row.'; + + return false; + } + + if ($this->columns === null) { + $this->message = 'Permission "'.$permission->toString().'" is scoped to a column. Column-scoped permissions are set on the collection, not here.'; + + return false; + } + + // Judged before existence, so a column that could never be a key is told why rather + // than being reported as one the collection happens not to have. + if (! $this->key->isValid($permission->getColumn())) { + $this->message = 'Column "'.$permission->getColumn().'" is not a valid column key.'; + + return false; + } + + if (! \in_array($permission->getColumn(), $this->columns, true)) { + $this->message = 'Column "'.$permission->getColumn().'" does not exist.'; + + return false; } return true; diff --git a/tests/e2e/Adapter/Base.php b/tests/e2e/Adapter/Base.php index 7f7a1080f..22fc78fa3 100644 --- a/tests/e2e/Adapter/Base.php +++ b/tests/e2e/Adapter/Base.php @@ -7,6 +7,7 @@ use Tests\E2E\Adapter\Scopes\AggregationTests; use Tests\E2E\Adapter\Scopes\AttributeTests; use Tests\E2E\Adapter\Scopes\CollectionTests; +use Tests\E2E\Adapter\Scopes\ColumnPermissionTests; use Tests\E2E\Adapter\Scopes\CursorTests; use Tests\E2E\Adapter\Scopes\CustomDocumentTypeTests; use Tests\E2E\Adapter\Scopes\DatabaseTests; @@ -53,6 +54,7 @@ abstract class Base extends TestCase use AttributeTests; use CollectionTests; use CursorTests; + use ColumnPermissionTests; use CustomDocumentTypeTests; use DatabaseTests; use DocumentTests; diff --git a/tests/e2e/Adapter/MongoDBTest.php b/tests/e2e/Adapter/MongoDBTest.php index 15886f82d..f47eb13a6 100644 --- a/tests/e2e/Adapter/MongoDBTest.php +++ b/tests/e2e/Adapter/MongoDBTest.php @@ -217,10 +217,27 @@ public function testListCollectionsReturnsOnlyReadableDefinitions(): void $database->createCollection(Collection::create(id: 'adminDefinition', permissions: [Permission::read(Role::user('admin'))])); $database->createCollection(Collection::create(id: 'unlistedDefinition', permissions: [Permission::create(Role::any())])); - $listed = fn (): array => \array_values(\array_intersect( - $definitions, - \array_map(fn (Collection $collection) => $collection->getId(), $database->listCollections(100)), - )); + // Paged rather than a single page: the listing has no order, so it answers with the + // oldest collections first, and these three are created last in a database every + // other test has already added to. A first page would stop finding them the moment + // the suite grows past it, which says nothing about who may read them. + $listed = function () use ($database, $definitions): array { + $ids = []; + + for ($offset = 0; ; $offset += 100) { + $page = $database->listCollections(100, $offset); + + foreach ($page as $collection) { + $ids[] = $collection->getId(); + } + + if (\count($page) < 100) { + break; + } + } + + return \array_values(\array_intersect($definitions, $ids)); + }; $queries = [Query::equal('$id', $definitions)]; $this->actAs('bob', function () use ($database, $listed, $queries): void { diff --git a/tests/e2e/Adapter/Scopes/ColumnPermissionTests.php b/tests/e2e/Adapter/Scopes/ColumnPermissionTests.php new file mode 100644 index 000000000..513a33565 --- /dev/null +++ b/tests/e2e/Adapter/Scopes/ColumnPermissionTests.php @@ -0,0 +1,1710 @@ +getDatabase(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrants', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + Permission::create(Role::any()), + ], + ))); + + $stored = $database->getCollection('columnGrants'); + + $permissions = $stored->declaredPermissions(); + + $this->assertNotNull($permissions); + $this->assertContains('read("any", "name")', $permissions); + $this->assertContains('read("team:hr", "salary")', $permissions); + $this->assertContains('create("any")', $permissions); + } + + /** + * Regression: a column-scoped grant must not hide the collection from the role it names. + * + * Listing applies the permission gate, and two adapters answer it from the row's own + * permission list rather than a side table of roles -- Postgres by JSONB containment, + * Mongo by an exact `$in`. Both look for `read("any")`, so a stored `read("any", "name")` + * matched nothing and the collection vanished for the very role it was granted to, while + * getCollection() -- which carries no gate -- still returned it. + */ + public function testACollectionWithAColumnScopedGrantIsStillListed(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsListed', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any(), 'name')], + ))); + + $previous = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + // Paged: the listing has no order, so it answers with the oldest collections + // first and this one is created last. A single page would stop finding it once + // the suite grows past it, which says nothing about the grant under test. + $ids = []; + + for ($offset = 0; ; $offset += 100) { + $page = $database->listCollections(100, $offset); + + foreach ($page as $collection) { + $ids[] = $collection->getId(); + } + + if (\count($page) < 100) { + break; + } + } + } finally { + $authorization->cleanRoles(); + foreach ($previous as $role) { + $authorization->addRole($role); + } + } + + $this->assertContains('columnGrantsListed', $ids); + } + + /** + * The grants are stored as they were written; only the gate is narrowed to roles. + */ + public function testAColumnScopedGrantIsReadBackAsItWasWritten(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsVerbatim', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + Permission::create(Role::any()), + ], + ))); + + $stored = $database->getCollection('columnGrantsVerbatim'); + + $this->assertSame([ + 'read("any", "name")', + 'read("team:hr", "salary")', + 'create("any")', + ], $stored->declaredPermissions()); + + // The gate keeps the roles and drops the columns, which is what every adapter matches. + $this->assertSame(['any', 'team:hr'], $stored->getPermissionsByType(PermissionType::Read)); + + // And the column the gates read holds the projection, not the grants. Postgres matches + // it by JSONB containment and Mongo by an exact `$in`, so a column stored here would + // hide the collection from the role it names while getCollection() still returned it. + $this->assertSame([ + 'read("any")', + 'read("team:hr")', + 'create("any")', + ], $stored->getPermissions()); + } + + /** + * A collection naming no column stores what it has always stored: nothing is duplicated + * for the collections that do not use the feature. + */ + public function testACollectionWithoutColumnGrantsStoresNothingExtra(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsAbsent', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any()), Permission::create(Role::any())], + )); + + $stored = $database->getCollection('columnGrantsAbsent'); + + $this->assertSame(['read("any")', 'create("any")'], $stored->declaredPermissions()); + $this->assertSame([], $stored->getAttribute(Collection::COLUMN_PERMISSIONS)); + } + + /** + * Dropping the last column-scoped grant also drops the list that used to describe it, + * so a later read does not answer with permissions the collection no longer has. + */ + public function testRemovingTheLastColumnGrantClearsTheStoredGrants(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $collection = $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsCleared', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any(), 'name'), Permission::create(Role::any())], + ))); + + $this->assertSame(['read("any", "name")', 'create("any")'], $collection->declaredPermissions()); + $this->assertSame(['read("any")', 'create("any")'], $collection->getPermissions()); + + $collection = $database->updateCollection('columnGrantsCleared', new CollectionUpdate(permissions: [ + Permission::read(Role::any()), + Permission::create(Role::any()), + ])); + + $this->assertSame(['read("any")', 'create("any")'], $collection->declaredPermissions()); + $this->assertSame(['read("any")', 'create("any")'], $collection->getPermissions()); + + $stored = $database->getCollection('columnGrantsCleared'); + + $this->assertSame([], $stored->getAttribute(Collection::COLUMN_PERMISSIONS)); + $this->assertSame(['read("any")', 'create("any")'], $stored->declaredPermissions()); + } + + /** + * The feature is off until the column is enforced. + * + * A scoped grant admits its role to the row -- that is what every gate matches -- and + * nothing yet holds that role to the column, so the grant widens access rather than + * narrowing it. Refused by default so no one writes one by accident; withColumnPermissions() + * is the deliberate way in, and goes away once masking and query refusal land. + */ + public function testAColumnScopedGrantIsRefusedUnlessAskedFor(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->createCollection(Collection::create( + id: 'columnGrantsGated', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any(), 'name')], + )); + $this->fail('Expected createCollection() to refuse a column-scoped grant'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('Column-scoped permissions are not enabled', $e->getMessage()); + } + + $this->assertNull($database->findCollection('columnGrantsGated')); + + // An update may not introduce one either. + $database->createCollection(Collection::create( + id: 'columnGrantsGatedUpdate', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any())], + )); + + try { + $database->updateCollection('columnGrantsGatedUpdate', new CollectionUpdate(permissions: [ + Permission::read(Role::any(), 'name'), + ])); + $this->fail('Expected updateCollection() to refuse a column-scoped grant'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('Column-scoped permissions are not enabled', $e->getMessage()); + } + + $this->assertSame( + ['read("any")'], + $database->getCollection('columnGrantsGatedUpdate')->declaredPermissions(), + ); + } + + /** + * The toggle travels in a snapshot, like every other scoped flag. + * + * A snapshot captured inside the opt-in has to restore it, or a caller replaying that state + * elsewhere -- another coroutine, a queued job -- is refused despite having captured the + * permission to do exactly this. + */ + public function testTheColumnPermissionsToggleSurvivesASnapshot(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if ($database instanceof Mirror) { + // True of every toggle, not this one: replicate() propagates a snapshot explicitly + // where it needs to, so there is nothing here for this test to pin. + $this->markTestSkipped('Mirror does not override withSnapshot(), so a restored snapshot reaches the wrapper and not its source or destination.'); + } + + $snapshot = $database->withColumnPermissions(fn () => $database->snapshot()); + + // The scope has closed, so the toggle is off again... + $this->assertFalse($database->hasColumnPermissions()); + + // ...but the snapshot still carries it. + $database->withSnapshot($snapshot, fn () => $database->createCollection(Collection::create( + id: 'columnGrantsSnapshot', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any(), 'name')], + ))); + + $this->assertContains( + 'read("any", "name")', + $database->getCollection('columnGrantsSnapshot')->declaredPermissions() ?? [], + ); + $this->assertFalse($database->hasColumnPermissions()); + } + + /** + * Only read and update may name a column, and createCollection() enforces it. + * + * A create brings a whole row into being and a delete removes one, so neither is narrowed + * by naming a column, and write aggregates to all three. The rule is the validator's; this + * pins that the collection write actually runs it. + */ + public function testACollectionPermissionMayNotScopeCreateDeleteOrWrite(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + foreach (['create', 'delete', 'write'] as $type) { + try { + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsWholeRow'.$type, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::$type(Role::any(), 'name')], + ))); + $this->fail('Expected createCollection() to reject a column-scoped "'.$type.'"'); + } catch (DatabaseException $e) { + $this->assertStringContainsString( + 'Permission "'.$type.'" cannot be scoped to a column, it applies to the whole row.', + $e->getMessage(), + ); + } + } + } + + public function testACollectionPermissionMayNotNameAColumnThatDoesNotExist(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + try { + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsUnknown', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::read(Role::any(), 'nope')], + ))); + $this->fail('Expected createCollection() to reject a permission naming an unknown column'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('Column "nope" does not exist.', $e->getMessage()); + } + } + + public function testUpdatingACollectionJudgesAColumnGrantAgainstItsOwnColumns(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsUpdate', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [Permission::create(Role::any())], + )); + + $database->withColumnPermissions(fn () => $database->updateCollection('columnGrantsUpdate', new CollectionUpdate(permissions: [ + Permission::read(Role::any(), 'name'), + Permission::create(Role::any()), + ]))); + + $updated = $database->getCollection('columnGrantsUpdate')->declaredPermissions(); + + $this->assertNotNull($updated); + $this->assertContains('read("any", "name")', $updated); + + try { + $database->withColumnPermissions(fn () => $database->updateCollection('columnGrantsUpdate', new CollectionUpdate(permissions: [ + Permission::read(Role::any(), 'nope'), + ]))); + $this->fail('Expected updateCollection() to reject a permission naming an unknown column'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('Column "nope" does not exist.', $e->getMessage()); + } + + // The rejected update left the collection as it was. + $updated = $database->getCollection('columnGrantsUpdate')->declaredPermissions(); + + $this->assertNotNull($updated); + $this->assertContains('read("any", "name")', $updated); + } + + public function testADocumentPermissionMayNotNameAColumn(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsDocument', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + // Column permissions belong to the collection. A document saying otherwise is + // refused rather than stored and ignored, which would read as a restriction that + // silently does nothing. + try { + $database->createDocument('columnGrantsDocument', new Document([ + '$id' => 'row', + 'name' => 'Ada', + 'salary' => 120000, + '$permissions' => [Permission::read(Role::any(), 'salary')], + ])); + $this->fail('Expected createDocument() to reject a column-scoped permission'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('is scoped to a column', $e->getMessage()); + } + + // The rejected write left nothing behind. + $this->assertTrue($database->getDocument('columnGrantsDocument', 'row')->isEmpty()); + } + /** + * A row that records another collection's grants is refused like any other. + * + * Appwrite keeps one of these per table, and the grants it records name that table's + * columns rather than the row's. There is no waiver for it: a column stored in a row's + * permissions is enforced nowhere, and the read gate -- which matches roles, not grants -- + * would hide the row from the role it names. The caller splits them instead, keeping the + * verbatim grants in a column of their own and the roles in the permissions. + */ + public function testADocumentMayNotRecordAnotherCollectionsColumnPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsRecords', + attributes: [ + Attribute::string(key: 'tableId', size: 64), + Attribute::string(key: 'label', size: 64), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + try { + $database->createDocument('columnGrantsRecords', new Document([ + '$id' => 'row', + 'tableId' => 'employees', + 'label' => 'Employees', + '$permissions' => [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ])); + $this->fail('Expected createDocument() to reject columns belonging to another collection'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('is scoped to a column', $e->getMessage()); + } + + $this->assertTrue($database->getDocument('columnGrantsRecords', 'row')->isEmpty()); + } + + /** + * The shape a consumer keeping a list of its collections has to use. + * + * This mirrors Appwrite: a `database_N` collection whose rows each describe one table, + * carrying that table's grants so the console can show and edit them. + * Those grants name the table's columns, not the row's, so they cannot live in the row's + * permissions: the library refuses them there, and every read gate matches the role form + * anyway, so a column stored in a row's permissions would hide the row from the role it + * names. The consumer splits them -- roles gate the row, the grants ride in a column of + * their own -- which is what the library does for a collection definition, by hand. + */ + public function testAppwriteCollectionsSimulation(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + // The table itself, under the id Appwrite gives it. Its grants name its own columns. + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'database_1_collection_1', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ))); + + $grants = $database->getCollection('database_1_collection_1')->declaredPermissions(); + $this->assertNotNull($grants); + + // Appwrite's list of the database's tables, one row per table, shaped like the real + // thing: databaseId beside a columnPermissions column. Neither resembles the columns + // the grants it records name. + $database->createCollection(Collection::create( + id: 'database_1', + attributes: [ + Attribute::string(key: 'databaseId', size: Database::LENGTH_KEY), + Attribute::string(key: 'columnPermissions', size: 1_000_000, filters: [Filter::Json]), + ], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + // Putting them in the row's permissions is refused. + try { + $database->createDocument('database_1', new Document([ + '$id' => 'wrong', + 'databaseId' => 'database_1', + 'columnPermissions' => $grants, + '$permissions' => $grants, + ])); + $this->fail('Expected createDocument() to reject grants naming another collection\'s columns'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('is scoped to a column', $e->getMessage()); + } + + // Splitting them is not. + // The row's id is the table's id, as Appwrite writes it. + $database->createDocument('database_1', new Document([ + '$id' => 'database_1_collection_1', + 'databaseId' => 'database_1', + 'columnPermissions' => $grants, + '$permissions' => Permission::withoutColumns($grants), + ])); + + $stored = $database->getDocument('database_1', 'database_1_collection_1'); + + // The grants come back as the table has them, ready to report to a caller... + $this->assertSame($grants, $stored->getAttribute('columnPermissions')); + + // ...while the row answers the gate with roles alone. + $this->assertSame(['any', 'team:hr'], $stored->getPermissionsByType(PermissionType::Read)); + + // So a role holding nothing but a column-scoped grant still finds the row it describes. + $previous = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::team('hr')->toString()); + + try { + $ids = \array_map( + static fn (Document $document): string => $document->getId(), + $database->find('database_1'), + ); + } finally { + $authorization->cleanRoles(); + foreach ($previous as $role) { + $authorization->addRole($role); + } + } + + $this->assertSame(['database_1_collection_1'], $ids); + } + + /** + * Every write path refuses it, not just createDocument(). + * + * The rule lived inline in prepareDocument() at first, which only createDocument() reaches, + * so a batch create, an update or an upsert stored the grant happily -- and a row holding a + * column in its permissions is hidden from the role it names by the gates that match that + * field directly. One case per entry point, so the rule cannot quietly shrink back to one. + */ + public function testEveryWritePathRefusesAColumnScopedPermission(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsWrites', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + ], + documentSecurity: true, + )); + + $database->createDocument('columnGrantsWrites', new Document([ + '$id' => 'row', + 'name' => 'Ada', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + ])); + + $scoped = [Permission::read(Role::any(), 'name')]; + + $writes = [ + 'createDocument' => fn () => $database->createDocument('columnGrantsWrites', new Document([ + '$id' => 'created', 'name' => 'x', '$permissions' => $scoped, + ])), + 'createDocuments' => fn () => $database->createDocuments('columnGrantsWrites', [new Document([ + '$id' => 'batched', 'name' => 'x', '$permissions' => $scoped, + ])]), + 'updateDocument' => fn () => $database->updateDocument('columnGrantsWrites', 'row', new Document([ + '$permissions' => $scoped, + ])), + 'updateDocuments' => fn () => $database->updateDocuments('columnGrantsWrites', new Document([ + '$permissions' => $scoped, + ])), + ]; + + // The upsert paths share one guard, and an adapter without upserts refuses the call + // before reaching it. + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $writes['upsertDocument'] = fn () => $database->upsertDocument('columnGrantsWrites', new Document([ + '$id' => 'upserted', 'name' => 'x', '$permissions' => $scoped, + ])); + $writes['upsertDocuments'] = fn () => $database->upsertDocuments('columnGrantsWrites', [new Document([ + '$id' => 'upserted', 'name' => 'x', '$permissions' => $scoped, + ])]); + } + + foreach ($writes as $path => $write) { + try { + $write(); + $this->fail($path.'() stored a column-scoped permission on a row'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('is scoped to a column', $e->getMessage(), $path); + } + } + + // None of them left anything behind, and the row it tried to rewrite is untouched. + $this->assertSame( + ['read("any")', 'update("any")'], + $database->getDocument('columnGrantsWrites', 'row')->getPermissions(), + ); + $this->assertTrue($database->getDocument('columnGrantsWrites', 'created')->isEmpty()); + $this->assertTrue($database->getDocument('columnGrantsWrites', 'batched')->isEmpty()); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $this->assertTrue($database->getDocument('columnGrantsWrites', 'upserted')->isEmpty()); + } + } + + /** + * Asking for everything is not the same as naming a column you may not read. + * + * select(['*']) and a read with no select are the same request -- "whatever I am entitled + * to" -- and neither names a column, so neither is refused; the caller is given what their + * grants reach. Naming the column is what gets refused, even alongside one they may read, + * because a query that names a column answers questions about it whether or not the value + * comes back. + */ + public function testSelectingEverythingIsNotRefusedButNamingAnUngrantedColumnIs(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsSelect', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsSelect', new Document([ + '$id' => 'e1', + 'name' => 'Ada', + 'salary' => 120000, + ]))); + + $previous = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole(Role::any()->toString()); + + try { + // Asking for the lot, however it is spelled: not refused, but answered with only + // what the grant reaches. Refusing '*' would make a plain read unusable, so the + // answer is shaped instead -- the other half of the gate. + foreach ([[Query::select(['*'])], []] as $queries) { + $rows = $database->find('columnGrantsSelect', $queries); + + $this->assertCount(1, $rows); + $this->assertSame('Ada', $rows[0]->getAttribute('name')); + $this->assertFalse($rows[0]->offsetExists('salary'), 'select(*) returned an ungranted column'); + } + + // Naming only what the grant reaches. + $this->assertCount(1, $database->find('columnGrantsSelect', [Query::select(['name'])])); + + // Naming one it does not, even beside one it does. + foreach ([['salary'], ['name', 'salary']] as $selection) { + try { + $database->find('columnGrantsSelect', [Query::select($selection)]); + $this->fail('Expected select('.\implode(', ', $selection).') to be refused'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "read" permission for column "salary"', $e->getMessage()); + } + } + } finally { + $authorization->cleanRoles(); + foreach ($previous as $role) { + $authorization->addRole($role); + } + } + } + + /** + * A query naming a column the caller cannot read is refused, wherever it names it. + * + * Returning the rows would answer the question the filter asks whether or not the value + * comes back -- Query::greaterThan() turns that into a binary search -- and returning none + * is no better, since an empty result is a signal too and a wrong answer besides. Ordering + * leaks the order, and an aggregate leaks the aggregate. + */ + public function testAQueryNamingAnUngrantedColumnIsRefused(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsQueries', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsQueries', new Document([ + '$id' => 'e1', + 'name' => 'Ada', + 'salary' => 120000, + ]))); + + $refused = [ + 'filter' => fn () => $database->find('columnGrantsQueries', [Query::equal('salary', [120000])]), + 'range filter' => fn () => $database->find('columnGrantsQueries', [Query::greaterThan('salary', 1)]), + 'order' => fn () => $database->find('columnGrantsQueries', [Query::orderDesc('salary')]), + 'select' => fn () => $database->find('columnGrantsQueries', [Query::select(['salary'])]), + 'count' => fn () => $database->count('columnGrantsQueries', [Query::equal('salary', [120000])]), + 'sum' => fn () => $database->sum('columnGrantsQueries', 'salary'), + ]; + + // An adapter without aggregates rejects max() as a method before the column is reached, + // which is the right order: an unsupported query is not a permission question. + if ($database->getAdapter()->supports(Capability::Aggregations)) { + $refused['aggregate'] = fn () => $database->aggregate('columnGrantsQueries', [Query::max('salary')]); + } + + $this->asRole($authorization, Role::any()->toString(), function () use ($refused, $database): void { + foreach ($refused as $what => $run) { + try { + $run(); + $this->fail($what.' on an ungranted column was allowed'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "read" permission for column "salary"', $e->getMessage(), $what); + } + } + + // The column the grant does reach is queryable as usual. + $this->assertCount(1, $database->find('columnGrantsQueries', [Query::equal('name', ['Ada'])])); + }); + + // And it is per role, not per column: hr reaches salary and not name. + $this->asRole($authorization, Role::team('hr')->toString(), function () use ($database): void { + $this->assertSame(120000, $database->sum('columnGrantsQueries', 'salary')); + + try { + $database->find('columnGrantsQueries', [Query::equal('name', ['Ada'])]); + $this->fail('hr filtered on a column it was not granted'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "read" permission for column "name"', $e->getMessage()); + } + }); + } + + /** + * A write touching a column the caller may not change is refused, and refused whole. + * + * Applying the part they are allowed and dropping the rest would leave a document neither + * the caller nor the collection asked for. + */ + public function testAWriteTouchingAnUngrantedColumnIsRefused(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsWritesScoped', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any(), 'name'), + Permission::update(Role::team('hr'), 'salary'), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsWritesScoped', new Document([ + '$id' => 'e1', + 'name' => 'Ada', + 'salary' => 120000, + ]))); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + $this->assertSame('Grace', $database->updateDocument('columnGrantsWritesScoped', 'e1', new Document([ + 'name' => 'Grace', + ]))->getAttribute('name')); + + foreach (['salary alone' => ['salary' => 1], 'both at once' => ['name' => 'X', 'salary' => 2]] as $what => $data) { + try { + $database->updateDocument('columnGrantsWritesScoped', 'e1', new Document($data)); + $this->fail($what.' was allowed'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "update" permission for column "salary"', $e->getMessage(), $what); + } + } + }); + + // Refused whole: the granted half of "both at once" was not applied either. + $stored = $authorization->skip(fn () => $database->getDocument('columnGrantsWritesScoped', 'e1')); + $this->assertSame('Grace', $stored->getAttribute('name')); + $this->assertSame(120000, $stored->getAttribute('salary')); + } + + /** + * Run the callback holding one role and nothing else. + */ + private function asRole(Authorization $authorization, string $role, callable $callback): void + { + $previous = $authorization->getRoles(); + $authorization->cleanRoles(); + $authorization->addRole($role); + + try { + $callback(); + } finally { + $authorization->cleanRoles(); + foreach ($previous as $held) { + $authorization->addRole($held); + } + } + } + + /** + * A read that names nothing comes back holding what the caller was granted. + * + * The mirror of the refusal: naming an ungranted column throws, asking for everything does + * not, so the answer is shaped instead. The key is removed rather than nulled, since null + * would be indistinguishable from a column that genuinely holds null. + */ + public function testAReadComesBackHoldingOnlyTheGrantedColumns(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsMasked', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + Permission::read(Role::team('exec')), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsMasked', new Document([ + '$id' => 'e1', + 'name' => 'Ada', + 'salary' => 120000, + ]))); + + $columns = static fn (Document $document): array => \array_keys(\array_intersect_key( + $document->getArrayCopy(), + ['name' => true, 'salary' => true], + )); + + // Granted one column: the other is gone, not nulled. + $this->asRole($authorization, Role::any()->toString(), function () use ($database, $columns): void { + $stored = $database->getDocument('columnGrantsMasked', 'e1'); + + $this->assertSame(['name'], $columns($stored)); + $this->assertSame('Ada', $stored->getAttribute('name')); + $this->assertFalse($stored->offsetExists('salary'), 'salary came back for a role granted only name'); + + // find() and select('*') shape the same way, and the system attributes survive. + $this->assertSame(['name'], $columns($database->find('columnGrantsMasked')[0])); + $this->assertSame(['name'], $columns($database->find('columnGrantsMasked', [Query::select(['*'])])[0])); + $this->assertSame('e1', $database->find('columnGrantsMasked')[0]->getId()); + }); + + // A different role, a different column. + $this->asRole($authorization, Role::team('hr')->toString(), function () use ($database, $columns): void { + $this->assertSame(['salary'], $columns($database->getDocument('columnGrantsMasked', 'e1'))); + }); + + // A whole-row grant names no column, so nothing is withheld from it. + $this->asRole($authorization, Role::team('exec')->toString(), function () use ($database, $columns): void { + $this->assertSame(['name', 'salary'], $columns($database->getDocument('columnGrantsMasked', 'e1'))); + }); + + // Internal reads are not shaped, or the library would lose columns it needs to write. + $this->assertSame( + ['name', 'salary'], + $columns($authorization->skip(fn () => $database->getDocument('columnGrantsMasked', 'e1'))), + ); + } + + /** + * A write hands its result back the same way a read does. + * + * An update reads the row to merge the patch onto it, so its response carries every column + * the row holds -- including the ones the caller may not read. The write itself is judged + * separately: a role may be allowed to change a column it cannot see, and then the change + * lands while the answer still withholds it. + */ + public function testAWriteComesBackHoldingOnlyTheGrantedColumns(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsWritten', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::update(Role::team('exec')), + Permission::read(Role::team('exec')), + ], + ))); + + $columns = static fn (Document $document): array => \array_keys(\array_intersect_key( + $document->getArrayCopy(), + ['name' => true, 'salary' => true], + )); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database, $columns): void { + $created = $database->createDocument('columnGrantsWritten', new Document([ + '$id' => 'w1', + 'name' => 'Ada', + 'salary' => 120000, + ])); + $this->assertSame(['name'], $columns($created), 'createDocument() handed back an ungranted column'); + + $updated = $database->updateDocument('columnGrantsWritten', 'w1', new Document([ + 'name' => 'Ada Lovelace', + ])); + $this->assertSame(['name'], $columns($updated), 'updateDocument() handed back an ungranted column'); + $this->assertSame('Ada Lovelace', $updated->getAttribute('name')); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $upserted = $database->upsertDocument('columnGrantsWritten', new Document([ + '$id' => 'w1', + 'name' => 'Ada King', + ])); + $this->assertSame(['name'], $columns($upserted), 'upsertDocument() handed back an ungranted column'); + } + + // A counter may be bumped by a role that cannot read it, and the answer still withholds it. + $increased = $database->increaseDocumentAttribute('columnGrantsWritten', 'w1', 'salary', 1000); + $this->assertSame(['name'], $columns($increased), 'increaseDocumentAttribute() handed back an ungranted column'); + + $decreased = $database->decreaseDocumentAttribute('columnGrantsWritten', 'w1', 'salary', 500); + $this->assertSame(['name'], $columns($decreased), 'decreaseDocumentAttribute() handed back an ungranted column'); + }); + + // Shaping the answer is not editing the row: every write above landed in full. + $stored = $authorization->skip(fn () => $database->getDocument('columnGrantsWritten', 'w1')); + $this->assertSame(['name', 'salary'], $columns($stored)); + $this->assertSame(120500, $stored->getAttribute('salary')); + + // A whole-row grant names no column, so nothing is withheld from it. + $this->asRole($authorization, Role::team('exec')->toString(), function () use ($database, $columns): void { + $this->assertSame( + ['name', 'salary'], + $columns($database->updateDocument('columnGrantsWritten', 'w1', new Document(['name' => 'Ada']))), + ); + }); + } + + /** + * A batch write reports each row through onNext, which is the same way out. + * + * Both documents it is handed are shaped: the one just written and the one it replaced, + * since the previous row is read from storage in full. + */ + public function testABatchWriteHandsBackOnlyTheGrantedColumns(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsBatched', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + Permission::read(Role::any(), 'name'), + ], + ))); + + $columns = static fn (Document $document): array => \array_keys(\array_intersect_key( + $document->getArrayCopy(), + ['name' => true, 'salary' => true], + )); + + // Each write gets its own collector, so what one reported cannot carry into the next. + $shapesFrom = static function (callable $write) use ($columns): array { + /** @var list> $seen */ + $seen = []; + + $write(static function (Document $document, ?Document $previous) use (&$seen, $columns): void { + $seen[] = $columns($document); + + if ($previous !== null) { + $seen[] = $columns($previous); + } + }); + + return $seen; + }; + + $this->asRole($authorization, Role::any()->toString(), function () use ($database, $shapesFrom): void { + $this->assertSame( + [['name'], ['name']], + $shapesFrom(static fn (callable $onNext) => $database->createDocuments('columnGrantsBatched', [ + new Document(['$id' => 'b1', 'name' => 'Ada', 'salary' => 120000]), + new Document(['$id' => 'b2', 'name' => 'Grace', 'salary' => 130000]), + ], onNext: $onNext)), + 'createDocuments() reported an ungranted column', + ); + + $this->assertSame( + [['name'], ['name'], ['name'], ['name']], + $shapesFrom(static fn (callable $onNext) => $database->updateDocuments( + 'columnGrantsBatched', + new Document(['name' => 'Redacted']), + onNext: $onNext, + )), + 'updateDocuments() reported an ungranted column', + ); + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $this->assertSame( + [['name'], ['name']], + $shapesFrom(static fn (callable $onNext) => $database->upsertDocuments('columnGrantsBatched', [ + new Document(['$id' => 'b1', 'name' => 'Ada', 'salary' => 1]), + ], onNext: $onNext)), + 'upsertDocuments() reported an ungranted column', + ); + } + + $this->assertSame( + [['name'], ['name'], ['name'], ['name']], + $shapesFrom(static fn (callable $onNext) => $database->deleteDocuments('columnGrantsBatched', onNext: $onNext)), + 'deleteDocuments() reported an ungranted column', + ); + }); + } + + /** + * A role under a column restriction may not join. + * + * A join names columns in a second collection under an alias, and returns them beside the + * base row. The gate reads those names against the base collection and the mask only knows + * the base collection, so a join would be judged against the wrong schema and its columns + * would come back unshaped. Refusing is the honest answer until both halves follow aliases. + * + * A whole-row grant is unrestricted and joins exactly as it always did. + */ + public function testARestrictedRoleMayNotJoin(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->supports(Capability::Joins)) { + $this->markTestSkipped('Adapter does not support joins'); + } + + $authorization = $database->getAuthorization(); + + $database->createCollection(Collection::create( + id: 'columnGrantsJoined', + attributes: [ + Attribute::string(key: 'mainId', size: 64), + Attribute::string(key: 'note', size: 64) + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + // asRole() replaces the role list, so exec holds nothing that "any" would cover. + Permission::read(Role::team('exec')), + ], + )); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsJoinBase', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('exec')), + ], + ))); + + $authorization->skip(function () use ($database): void { + $database->createDocument('columnGrantsJoinBase', new Document(['$id' => 'm1', 'name' => 'Ada', 'salary' => 120000])); + $database->createDocument('columnGrantsJoined', new Document(['$id' => 's1', 'mainId' => 'm1', 'note' => 'hi'])); + }); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + try { + $database->find('columnGrantsJoinBase', [ + Query::join('columnGrantsJoined', 'entry', [Query::on('$id', 'mainId')]), + ]); + $this->fail('a column-restricted role was allowed to join'); + } catch (QueryException $e) { + $this->assertStringContainsString('Join queries are not supported', $e->getMessage()); + } + + // The refusal does not mention a column, because the join names none of the base ones. + try { + $database->find('columnGrantsJoinBase', [ + Query::join('columnGrantsJoined', 'entry', [Query::on('$id', 'mainId')]), + Query::equal('entry.note', ['hi']), + ]); + $this->fail('a column-restricted role was allowed to join and filter the joined collection'); + } catch (QueryException $e) { + $this->assertStringNotContainsString('columnGrantsJoined', $e->getMessage()); + } + }); + + // A whole-row grant is not restricted, so joins keep working for it. + $this->asRole($authorization, Role::team('exec')->toString(), function () use ($database): void { + $rows = $database->find('columnGrantsJoinBase', [ + Query::join('columnGrantsJoined', 'entry', [Query::on('$id', 'mainId')]), + ]); + + $this->assertCount(1, $rows); + $this->assertSame('Ada', $rows[0]->getAttribute('name')); + $this->assertSame(120000, $rows[0]->getAttribute('salary')); + }); + + // The other direction: an open collection must not be used as a way into a restricted + // one. The base names no column, so nothing about it is refused -- the join is. + $database->createCollection(Collection::create( + id: 'columnGrantsJoinOpen', + attributes: [Attribute::string(key: 'baseId', size: 64), Attribute::string(key: 'label', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + + $authorization->skip(fn () => $database->createDocument('columnGrantsJoinOpen', new Document([ + '$id' => 'o1', + 'baseId' => 'm1', + 'label' => 'row', + ]))); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + try { + $database->find('columnGrantsJoinOpen', [ + Query::join('columnGrantsJoinBase', 'secret', [Query::on('baseId', '$id')]), + ]); + $this->fail('an open collection was joined into a column-restricted one'); + } catch (QueryException $e) { + $this->assertStringContainsString('Join queries are not supported', $e->getMessage()); + } + + // And filtering on the restricted column through the alias is refused with it. + try { + $database->find('columnGrantsJoinOpen', [ + Query::join('columnGrantsJoinBase', 'secret', [Query::on('baseId', '$id')]), + Query::equal('secret.salary', [120000]), + ]); + $this->fail('a restricted column was filtered through a join alias'); + } catch (QueryException $e) { + $this->assertStringContainsString('Join queries are not supported', $e->getMessage()); + } + + // Without the join the open collection reads as it always did. + $this->assertCount(1, $database->find('columnGrantsJoinOpen')); + }); + } + + /** + * A related document is shaped by its own collection's grants. + * + * Population reads the related collection through the same find() and getDocument() the + * caller would, so the mask reaches it and the grants that apply are the related + * collection's own, not the parent's. Pinned because a population that ever read rows + * directly from the adapter would hand back everything and nothing else would notice. + */ + public function testARelatedDocumentIsMaskedByItsOwnCollectionsGrants(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->markTestSkipped('Adapter does not support relationships'); + } + + $authorization = $database->getAuthorization(); + + // The parent names no column, so nothing of its own is withheld. + $database->createCollection(Collection::create( + id: 'columnGrantsParent', + attributes: [Attribute::string(key: 'label', size: 64)], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + // asRole() replaces the role list, so exec holds nothing that "any" would cover. + Permission::read(Role::team('exec')), + ], + )); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsRelated', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('exec')), + ], + ))); + + $database->createRelationship( + 'columnGrantsParent', + Relationship::oneToOne(relatedCollection: 'columnGrantsRelated', key: 'related'), + ); + + $authorization->skip(function () use ($database): void { + $database->createDocument('columnGrantsRelated', new Document([ + '$id' => 'r1', + 'name' => 'Ada', + 'salary' => 120000, + ])); + $database->createDocument('columnGrantsParent', new Document([ + '$id' => 'p1', + 'label' => 'row', + 'related' => 'r1', + ])); + }); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + foreach ([ + 'getDocument' => $database->getDocument('columnGrantsParent', 'p1'), + 'find' => $database->find('columnGrantsParent')[0], + ] as $what => $parent) { + // The parent is untouched: it carries no column grant of its own. + $this->assertSame('row', $parent->getAttribute('label'), $what); + + $related = $parent->getAttribute('related'); + $this->assertInstanceOf(Document::class, $related, $what); + $this->assertSame('Ada', $related->getAttribute('name'), $what); + $this->assertFalse( + $related->offsetExists('salary'), + $what.'() returned an ungranted column of the related collection', + ); + } + }); + + // A whole-row grant on the related collection withholds nothing from it. + $this->asRole($authorization, Role::team('exec')->toString(), function () use ($database): void { + $related = $database->getDocument('columnGrantsParent', 'p1')->getAttribute('related'); + + $this->assertInstanceOf(Document::class, $related); + $this->assertSame(120000, $related->getAttribute('salary')); + }); + } + + /** + * Every write path refuses a column the caller may not change, not just updateDocument(). + * + * An upsert onto a stored row is an update, and a counter changes one column like any other + * write of it, so each is judged the same way. Pinned per path because the gate is a call at + * each of them rather than one choke point: a new write path added without it would be a + * hole nothing else notices. + */ + public function testEveryWritePathRefusesAnUngrantedColumn(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsWriteGate', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any(), 'name'), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsWriteGate', new Document([ + '$id' => 'g1', + 'name' => 'Ada', + 'salary' => 100, + ]))); + + $refused = [ + 'updateDocument' => fn () => $database->updateDocument('columnGrantsWriteGate', 'g1', new Document(['salary' => 1])), + 'updateDocuments' => fn () => $database->updateDocuments('columnGrantsWriteGate', new Document(['salary' => 2])), + 'increaseDocumentAttribute' => fn () => $database->increaseDocumentAttribute('columnGrantsWriteGate', 'g1', 'salary', 7), + 'decreaseDocumentAttribute' => fn () => $database->decreaseDocumentAttribute('columnGrantsWriteGate', 'g1', 'salary', 7), + ]; + + if ($database->getAdapter()->hasFeature(Feature\Upserts::class)) { + $refused['upsertDocument'] = fn () => $database->upsertDocument('columnGrantsWriteGate', new Document(['$id' => 'g1', 'salary' => 5])); + $refused['upsertDocuments'] = fn () => $database->upsertDocuments('columnGrantsWriteGate', [new Document(['$id' => 'g1', 'salary' => 6])]); + } + + $this->asRole($authorization, Role::any()->toString(), function () use ($refused, $database): void { + foreach ($refused as $what => $run) { + try { + $run(); + $this->fail($what.' changed a column the caller was not granted'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "update" permission for column "salary"', $e->getMessage(), $what); + } + } + + // The granted column is writable through the same paths. + $this->assertSame( + 'Ada Lovelace', + $database->updateDocument('columnGrantsWriteGate', 'g1', new Document(['name' => 'Ada Lovelace']))->getAttribute('name'), + ); + }); + + // Nothing the refusals touched was stored. + $stored = $authorization->skip(fn () => $database->getDocument('columnGrantsWriteGate', 'g1')); + + $this->assertSame(100, $stored->getAttribute('salary')); + $this->assertSame('Ada Lovelace', $stored->getAttribute('name')); + } + + /** + * A column-scoped updater may not rewrite the row's permissions. + * + * The row's permissions are not a column, and rewriting them is a whole-row act. Left + * open, a caller granted one column could grant itself delete through them and destroy the + * row, turning a grant over one column into power over all of it. The mask would still + * hold -- it reads the collection's grants, not the row's -- so the escalation is over the + * row rather than its columns, which makes it no less an escalation. + */ + public function testAColumnScopedUpdaterMayNotRewriteTheRowsPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsEscalation', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::update(Role::any(), 'name'), + Permission::read(Role::team('exec')), + Permission::update(Role::team('exec')), + ], + documentSecurity: true, + ))); + + $stored = [ + Permission::read(Role::any()), + Permission::update(Role::any()), + ]; + + $authorization->skip(fn () => $database->createDocument('columnGrantsEscalation', new Document([ + '$id' => 'row', + 'name' => 'Ada', + 'salary' => 120000, + '$permissions' => $stored, + ]))); + + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + // Granting itself delete is the escalation the refusal exists for. + try { + $database->updateDocument('columnGrantsEscalation', 'row', new Document([ + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + ])); + $this->fail('a column-scoped updater rewrote the row\'s permissions'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "update" permission for the row', $e->getMessage()); + } + + // Refused even alongside a column it is allowed to change, since the write is + // refused whole rather than applied in part. + try { + $database->updateDocument('columnGrantsEscalation', 'row', new Document([ + 'name' => 'Ada Lovelace', + '$permissions' => [Permission::read(Role::any())], + ])); + $this->fail('a column-scoped updater rewrote permissions beside a granted column'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('Missing "update" permission for the row', $e->getMessage()); + } + + // The granted column alone still updates. + $this->assertSame( + 'Ada L', + $database->updateDocument('columnGrantsEscalation', 'row', new Document(['name' => 'Ada L']))->getAttribute('name'), + ); + }); + + // Nothing was escalated: the row keeps the permissions it was created with. + $after = $authorization->skip(fn () => $database->getDocument('columnGrantsEscalation', 'row')); + + $this->assertSame($stored, $after->getPermissions()); + + // A whole-row grant is not column-scoped, so it rewrites permissions as it always did. + $this->asRole($authorization, Role::team('exec')->toString(), function () use ($database): void { + $updated = $database->updateDocument('columnGrantsEscalation', 'row', new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::team('exec'))], + ])); + + $this->assertContains('update("team:exec")', $updated->getPermissions()); + }); + } + + /** + * A grant follows the column it names, and goes when the column goes. + * + * A grant names its column by key, so a rename would otherwise leave it pointing at a key + * nothing has and a delete would leave it naming a column the collection no longer holds. + * Either way the collection ends up with a grant createCollection() refuses outright, and + * the role silently loses the access it was given. Both are kept consistent instead. + */ + public function testAColumnGrantFollowsARenameAndGoesWithADelete(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + $authorization = $database->getAuthorization(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsRenamed', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::update(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ))); + + $authorization->skip(fn () => $database->createDocument('columnGrantsRenamed', new Document([ + '$id' => 'r1', + 'name' => 'Ada', + 'salary' => 120000, + ]))); + + $database->renameAttribute('columnGrantsRenamed', 'name', 'fullName'); + + $declared = $database->getCollection('columnGrantsRenamed')->declaredPermissions() ?? []; + + $this->assertContains('read("any", "fullName")', $declared, 'the read grant did not follow the rename'); + $this->assertContains('update("any", "fullName")', $declared, 'the update grant did not follow the rename'); + $this->assertNotContains('read("any", "name")', $declared); + + // A grant on another column is untouched, and so is one naming no column. + $this->assertContains('read("team:hr", "salary")', $declared); + $this->assertContains('create("any")', $declared); + + // The grant still reaches the column it was given, under its new name. + $this->asRole($authorization, Role::any()->toString(), function () use ($database): void { + $stored = $database->getDocument('columnGrantsRenamed', 'r1'); + + $this->assertSame('Ada', $stored->getAttribute('fullName')); + $this->assertFalse($stored->offsetExists('salary')); + + // And it is writable, which a grant naming a missing column could not be. + $this->assertSame( + 'Ada L', + $database->updateDocument('columnGrantsRenamed', 'r1', new Document(['fullName' => 'Ada L']))->getAttribute('fullName'), + ); + }); + + // Deleting the column takes its grants with it, leaving the rest alone. + $database->deleteAttribute('columnGrantsRenamed', 'fullName'); + + $declared = $database->getCollection('columnGrantsRenamed')->declaredPermissions() ?? []; + + $this->assertNotContains('read("any", "fullName")', $declared, 'a grant outlived the column it named'); + $this->assertNotContains('update("any", "fullName")', $declared, 'a grant outlived the column it named'); + $this->assertContains('read("team:hr", "salary")', $declared); + $this->assertContains('create("any")', $declared); + } + + /** + * A relationship is an attribute, so a grant may name it -- and goes with it. + * + * Both sides lose their key when the relationship is deleted, so both lose the grants that + * named it. Left behind, such a grant would name a column the collection no longer has, + * which createCollection() refuses outright, and it would reach nothing in the meantime. + */ + public function testAGrantOnARelationshipGoesWithIt(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->markTestSkipped('Adapter does not support relationships'); + } + + foreach (['columnGrantsRelLeft', 'columnGrantsRelRight'] as $id) { + $database->createCollection(Collection::create( + id: $id, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + + $database->createRelationship('columnGrantsRelLeft', Relationship::oneToOne( + relatedCollection: 'columnGrantsRelRight', + key: 'link', + twoWay: true, + twoWayKey: 'backlink', + )); + + $database->withColumnPermissions(function () use ($database): void { + $database->updateCollection('columnGrantsRelLeft', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'link'), + Permission::read(Role::any(), 'name'), + ])); + $database->updateCollection('columnGrantsRelRight', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'backlink'), + Permission::read(Role::any(), 'name'), + ])); + }); + + $this->assertContains('read("any", "link")', $database->getCollection('columnGrantsRelLeft')->declaredPermissions() ?? []); + $this->assertContains('read("any", "backlink")', $database->getCollection('columnGrantsRelRight')->declaredPermissions() ?? []); + + $database->deleteRelationship('columnGrantsRelLeft', 'link'); + + $left = $database->getCollection('columnGrantsRelLeft')->declaredPermissions() ?? []; + $right = $database->getCollection('columnGrantsRelRight')->declaredPermissions() ?? []; + + $this->assertNotContains('read("any", "link")', $left, 'a grant outlived the relationship it named'); + $this->assertNotContains('read("any", "backlink")', $right, 'a grant outlived the two-way key it named'); + + // Grants on the columns that remain are untouched, on both sides. + $this->assertContains('read("any", "name")', $left); + $this->assertContains('read("any", "name")', $right); + $this->assertContains('create("any")', $left); + } + + /** + * A grant follows a rename made through updateAttribute(), which is the rename a consumer + * reaches: renameAttribute() is the dedicated call, but an attribute update carrying a new + * key renames too, and that is the one an API exposes. + * + * Indexes already follow such a rename. A grant that did not would name a key nothing has: + * unreachable, and a grant createCollection() refuses. + */ + public function testAColumnGrantFollowsAnUpdateThatRenamesIt(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->withColumnPermissions(fn () => $database->createCollection(Collection::create( + id: 'columnGrantsUpdateRenamed', + attributes: [ + Attribute::string(key: 'name', size: 64), + Attribute::integer(key: 'salary'), + ], + permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'name'), + Permission::update(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + ], + ))); + + $database->updateAttribute('columnGrantsUpdateRenamed', 'name', new AttributeUpdate(key: 'fullName')); + + $declared = $database->getCollection('columnGrantsUpdateRenamed')->declaredPermissions() ?? []; + + $this->assertContains('read("any", "fullName")', $declared, 'the read grant did not follow the rename'); + $this->assertContains('update("any", "fullName")', $declared, 'the update grant did not follow the rename'); + $this->assertNotContains('read("any", "name")', $declared); + + // Grants on other columns, and grants naming none, are untouched. + $this->assertContains('read("team:hr", "salary")', $declared); + $this->assertContains('create("any")', $declared); + } + + /** + * And a grant on a relationship follows a rename of its key, on either side. + */ + public function testAGrantOnARelationshipFollowsARename(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (! $database->getAdapter()->hasFeature(Feature\Relationships::class)) { + $this->markTestSkipped('Adapter does not support relationships'); + } + + foreach (['columnGrantsRenRelLeft', 'columnGrantsRenRelRight'] as $id) { + $database->createCollection(Collection::create( + id: $id, + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any()), Permission::read(Role::any())], + )); + } + + $database->createRelationship('columnGrantsRenRelLeft', Relationship::oneToOne( + relatedCollection: 'columnGrantsRenRelRight', + key: 'link', + twoWay: true, + twoWayKey: 'backlink', + )); + + $database->withColumnPermissions(function () use ($database): void { + $database->updateCollection('columnGrantsRenRelLeft', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'link'), + ])); + $database->updateCollection('columnGrantsRenRelRight', new CollectionUpdate(permissions: [ + Permission::create(Role::any()), + Permission::read(Role::any(), 'backlink'), + ])); + }); + + $database->updateRelationship( + collection: 'columnGrantsRenRelLeft', + key: 'link', + update: new RelationshipUpdate(key: 'connection', twoWayKey: 'backConnection'), + ); + + $left = $database->getCollection('columnGrantsRenRelLeft')->declaredPermissions() ?? []; + $right = $database->getCollection('columnGrantsRenRelRight')->declaredPermissions() ?? []; + + $this->assertContains('read("any", "connection")', $left, 'the grant did not follow the relationship rename'); + $this->assertNotContains('read("any", "link")', $left); + + $this->assertContains('read("any", "backConnection")', $right, 'the grant did not follow the two-way key rename'); + $this->assertNotContains('read("any", "backlink")', $right); + } + + public function testADocumentPermissionNamingNoColumnIsUnaffected(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $database->createCollection(Collection::create( + id: 'columnGrantsPlain', + attributes: [Attribute::string(key: 'name', size: 64)], + permissions: [Permission::create(Role::any())], + documentSecurity: true, + )); + + $database->createDocument('columnGrantsPlain', new Document([ + '$id' => 'row', + 'name' => 'Ada', + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::user('alice')), + ], + ])); + + $stored = $database->getDocument('columnGrantsPlain', 'row'); + + $this->assertSame('Ada', $stored->getAttribute('name')); + $this->assertContains('read("any")', $stored->getPermissions()); + $this->assertContains('update("user:alice")', $stored->getPermissions()); + } +} diff --git a/tests/unit/Attributes/CheckAttributeTest.php b/tests/unit/Attributes/CheckAttributeTest.php index 91940f6a5..0b0496286 100644 --- a/tests/unit/Attributes/CheckAttributeTest.php +++ b/tests/unit/Attributes/CheckAttributeTest.php @@ -18,18 +18,41 @@ final class CheckAttributeTest extends TestCase { public function testAnAttributeWithinTheLimitPasses(): void { - $database = $this->database([Attribute::string('first', 16), Attribute::string('second', 16), Attribute::string('third', 16)]); + $database = $this->database($this->attributes(self::limit() - 1)); - $this->assertTrue($database->checkAttribute('items', Attribute::string('fourth', 16))); + $this->assertTrue($database->checkAttribute('items', Attribute::string('last', 16))); } public function testAnAttributeOverTheLimitIsRefused(): void { - $database = $this->database([Attribute::string('first', 16), Attribute::string('second', 16), Attribute::string('third', 16), Attribute::string('fourth', 16)]); + $database = $this->database($this->attributes(self::limit())); $this->expectException(LimitException::class); - $database->checkAttribute('items', Attribute::string('fifth', 16)); + $database->checkAttribute('items', Attribute::string('overTheLimit', 16)); + } + + /** + * How many attributes the adapter below allows, which it derives from the metadata + * definition -- so a fixture counting them by hand goes stale the moment that grows. + */ + private static function limit(): int + { + return \count(Database::collectionDefinition()->attributes()); + } + + /** + * @return list + */ + private function attributes(int $count): array + { + $attributes = []; + + for ($i = 0; $i < $count; $i++) { + $attributes[] = Attribute::string('attribute'.$i, 16); + } + + return $attributes; } public function testCheckingDoesNotStoreTheAttribute(): void diff --git a/tests/unit/CollectionDefinitionTest.php b/tests/unit/CollectionDefinitionTest.php index 615b11ea5..53eb1b4f1 100644 --- a/tests/unit/CollectionDefinitionTest.php +++ b/tests/unit/CollectionDefinitionTest.php @@ -30,7 +30,7 @@ public function testCollectionDefinitionKeys(): void $this->assertSame(Database::METADATA, $definition->getId()); $this->assertSame(Database::METADATA, $definition->getAttribute(Document::COLLECTION)); $this->assertSame('collections', $definition->name()); - $this->assertSame(['name', 'attributes', 'indexes', 'documentSecurity'], $this->keys($definition)); + $this->assertSame(['name', 'attributes', 'indexes', 'documentSecurity', 'columnPermissions'], $this->keys($definition)); } public function testCollectionDefinitionAttributeTypes(): void diff --git a/tests/unit/Collections/CollectionDefinitionTest.php b/tests/unit/Collections/CollectionDefinitionTest.php index 11f21d619..d2b5b9432 100644 --- a/tests/unit/Collections/CollectionDefinitionTest.php +++ b/tests/unit/Collections/CollectionDefinitionTest.php @@ -36,6 +36,7 @@ public function testTheMetadataAttributesKeepTheirStoredShape(): void 'attributes' => [ColumnType::String, 1_000_000, false, ['json']], 'indexes' => [ColumnType::String, 1_000_000, false, ['json']], 'documentSecurity' => [ColumnType::Boolean, null, true, []], + 'columnPermissions' => [ColumnType::String, 1_000_000, false, ['json']], ], $attributes); } @@ -48,7 +49,7 @@ public function testEveryCallReturnsAnIndependentCopy(): void $second = Database::collectionDefinition(); $this->assertSame('collections', $second->name()); - $this->assertCount(4, $second->attributes()); + $this->assertCount(5, $second->attributes()); } public function testGetCollectionReturnsTheDefinitionForTheMetadataCollection(): void diff --git a/tests/unit/DocumentTest.php b/tests/unit/DocumentTest.php index 705ca47a8..735a22629 100644 --- a/tests/unit/DocumentTest.php +++ b/tests/unit/DocumentTest.php @@ -541,6 +541,39 @@ public function testArrayAccessPermissionWriteRefreshesRoles(): void $this->assertSame([Permission::read(Role::any())], $document->getPermissions()); } + /** + * The role list answers who holds a permission. A column narrows what the permission + * reaches, not who holds it, so it is left out of the roles and the grant still admits + * its role to the row. + */ + public function testAColumnScopedPermissionContributesOnlyItsRole(): void + { + $document = new Document([Document::PERMISSIONS => [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::team('hr'), 'salary'), + Permission::update(Role::user('alice'), 'salary'), + ]]); + + $this->assertSame(['any', 'team:hr'], $document->getPermissionsByType(PermissionType::Read)); + $this->assertSame(['user:alice'], $document->getPermissionsByType(PermissionType::Update)); + + // The permissions themselves keep the column they were written with. + $this->assertContains('read("any", "name")', $document->getPermissions()); + } + + /** + * Two grants on the same role, each narrowed to a different column, name that role once. + */ + public function testTwoColumnGrantsOnOneRoleYieldOneRole(): void + { + $document = new Document([Document::PERMISSIONS => [ + Permission::read(Role::any(), 'name'), + Permission::read(Role::any(), 'salary'), + ]]); + + $this->assertSame(['any'], $document->getPermissionsByType(PermissionType::Read)); + } + public function testUnsetPermissionsClearsRoles(): void { $document = new Document([Document::PERMISSIONS => [Permission::read(Role::any())]]); diff --git a/tests/unit/Documents/ResolvedCollectionReadTest.php b/tests/unit/Documents/ResolvedCollectionReadTest.php index a4443e163..d07b115f0 100644 --- a/tests/unit/Documents/ResolvedCollectionReadTest.php +++ b/tests/unit/Documents/ResolvedCollectionReadTest.php @@ -292,7 +292,7 @@ public function testADecoratorMutatingTheMetadataDefinitionLeavesTheNextReadInta $definition = Database::collectionDefinition(); $this->assertSame('collections', $definition->getAttribute(Collection::NAME)); - $this->assertCount(4, $definition->attributes()); + $this->assertCount(5, $definition->attributes()); $this->assertSame(['name', 'count'], \array_map( static fn (Attribute $attribute): string => $attribute->key, $database->getCollection(self::COLLECTION)->attributes(), @@ -348,7 +348,7 @@ public function testADecoratorAddedDuringAMetadataReadLeavesTheSharedDefinitionI $this->assertSame(self::COLLECTION, $stored->getId()); $definition = Database::collectionDefinition(); $this->assertSame('collections', $definition->getAttribute(Collection::NAME), 'The decorator changed the shared metadata definition'); - $this->assertCount(4, $definition->attributes()); + $this->assertCount(5, $definition->attributes()); } private function metadataMutatingDecorator(): Decorator diff --git a/tests/unit/PermissionTest.php b/tests/unit/PermissionTest.php index 047f7fecf..33cbc450a 100644 --- a/tests/unit/PermissionTest.php +++ b/tests/unit/PermissionTest.php @@ -2,7 +2,9 @@ namespace Tests\Unit; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Id; use Utopia\Database\Permission; use Utopia\Database\PermissionType; @@ -329,4 +331,198 @@ public function test_aggregation(): void 'delete("user:123")', ], $parsed); } + + /** + * A permission may name one column, and then covers that column alone. Naming none + * covers every column, which is what every permission has always meant. + * + * @throws \Exception + */ + public function test_column_scope(): void + { + $permission = Permission::parse('read("any", "salary")'); + $this->assertEquals('read', $permission->getPermission()); + $this->assertEquals('any', $permission->getRole()); + $this->assertEquals('salary', $permission->getColumn()); + $this->assertFalse($permission->isForAllColumns()); + $this->assertEquals('read("any", "salary")', $permission->toString()); + + $permission = Permission::parse('read("any")'); + $this->assertEquals(Permission::ALL_COLUMNS, $permission->getColumn()); + $this->assertTrue($permission->isForAllColumns()); + + // The column does not disturb the role grammar either side of it. + $permission = Permission::parse('update("team:123/admin", "salary")'); + $this->assertEquals('team', $permission->getRole()); + $this->assertEquals('123', $permission->getIdentifier()); + $this->assertEquals('admin', $permission->getDimension()); + $this->assertEquals('salary', $permission->getColumn()); + $this->assertEquals('update("team:123/admin", "salary")', $permission->toString()); + + $permission = Permission::parse('read("user:123", "name")'); + $this->assertEquals('user', $permission->getRole()); + $this->assertEquals('123', $permission->getIdentifier()); + $this->assertEmpty($permission->getDimension()); + $this->assertEquals('name', $permission->getColumn()); + } + + /** + * The role ends at its closing quote, whatever spacing the caller put after the comma. + * + * These strings arrive from API clients, not only from toString(). Matching the canonical + * spacing alone read read("any","name") as the role any","name -- a role matching nobody, + * reported as "Role "any","name" is not allowed" for input that is perfectly reasonable. + * + * @throws \Exception + */ + #[DataProvider('spacings')] + public function test_column_tolerates_spacing(string $permission): void + { + $parsed = Permission::parse($permission); + + $this->assertEquals('any', $parsed->getRole()); + $this->assertEquals('name', $parsed->getColumn()); + } + + /** + * @return array + */ + public static function spacings(): array + { + return [ + 'canonical' => ['read("any", "name")'], + 'no space' => ['read("any","name")'], + 'space before the comma' => ['read("any" , "name")'], + 'two spaces after' => ['read("any", "name")'], + ]; + } + + /** + * A second argument that is not a quoted column is malformed, not a role with a quote in it. + * + * @throws \Exception + */ + public function test_a_malformed_second_argument_is_rejected(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Invalid permission'); + + Permission::parse('read("any" "name")'); + } + + /** + * @throws \Exception + */ + public function test_column_must_not_be_empty(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Column must not be empty'); + + Permission::parse('read("any", "")'); + } + + /** + * Every builder takes the column, so a caller never has to assemble the string by hand. + */ + public function test_column_builders(): void + { + $this->assertEquals('read("any", "name")', Permission::read(Role::any(), 'name')); + $this->assertEquals('update("any", "name")', Permission::update(Role::any(), 'name')); + + $this->assertEquals('read("any")', Permission::read(Role::any())); + + // create(), delete() and write() take the argument too -- a builder renders what it is + // handed -- but the validator refuses all three, because a column narrows neither a row + // brought into being nor one removed. + $this->assertEquals('create("any", "name")', Permission::create(Role::any(), 'name')); + $this->assertEquals('delete("any", "name")', Permission::delete(Role::any(), 'name')); + $this->assertEquals('write("any", "name")', Permission::write(Role::any(), 'name')); + } + + /** + * Expanding write() keeps the column: the aggregate is the same grant, narrowed the same way. + * + * @throws \Exception + */ + /** + * Expanding a grant keeps its column. Shown through write(), which is the only type that + * expands -- the validator refuses a scoped write, so this is about the transform, not + * about a set anyone can store. + * + * @throws \Exception + */ + public function test_aggregation_keeps_the_column(): void + { + $parsed = Permission::aggregate([Permission::write(Role::any(), 'salary')]); + + $this->assertEquals([ + 'create("any", "salary")', + 'update("any", "salary")', + 'delete("any", "salary")', + ], $parsed); + } + + /** + * @throws \Exception + */ + public function test_a_wildcard_column_is_rejected(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('Wildcard column "*" is not supported'); + + Permission::parse('read("any", "*")'); + } + + /** + * The form a read gate matches against: every grant with its column dropped. + * + * @throws \Exception + */ + public function test_without_columns(): void + { + $this->assertSame( + ['read("any")', 'read("team:hr")', 'create("any")'], + Permission::withoutColumns([ + 'read("any", "name")', + 'read("team:hr", "salary")', + 'create("any")', + ]), + ); + } + + /** + * Two grants narrowing the same role to different columns admit that role once. + * + * @throws \Exception + */ + public function test_without_columns_deduplicates(): void + { + $this->assertSame( + ['read("any")'], + Permission::withoutColumns(['read("any", "name")', 'read("any", "salary")']), + ); + } + + /** + * A set naming no column is its own projection, so a collection that does not use the + * feature stores exactly what it stores today. + * + * @throws \Exception + */ + public function test_without_columns_leaves_unscoped_permissions_alone(): void + { + $permissions = ['read("any")', 'update("user:alice")', 'create("users")']; + + $this->assertSame($permissions, Permission::withoutColumns($permissions)); + } + + /** + * @throws \Exception + */ + public function test_has_columns(): void + { + $this->assertTrue(Permission::hasColumns(['create("any")', 'read("any", "name")'])); + $this->assertFalse(Permission::hasColumns(['create("any")', 'read("any")'])); + $this->assertFalse(Permission::hasColumns([])); + } } diff --git a/tests/unit/State/CoroutineStateTest.php b/tests/unit/State/CoroutineStateTest.php index 5e89d68c8..938b41bee 100644 --- a/tests/unit/State/CoroutineStateTest.php +++ b/tests/unit/State/CoroutineStateTest.php @@ -257,6 +257,7 @@ private function snapshot(bool $population): Snapshot preserveDates: false, preserveSequence: false, ignoreDuplicates: false, + columnPermissions: false, requestTimestamp: null, ); } diff --git a/tests/unit/Validator/PermissionsTest.php b/tests/unit/Validator/PermissionsTest.php index c60d85c96..308d99c66 100644 --- a/tests/unit/Validator/PermissionsTest.php +++ b/tests/unit/Validator/PermissionsTest.php @@ -356,4 +356,96 @@ public function test_custom_type_rejected_as_in_7_4(): void $this->assertFalse($object->isValid(['execute("any")'])); $this->assertSame('Permission "execute("any")" is not allowed. Must be one of: create, read, update, delete, write.', $object->getDescription()); } + + /** + * A column list is the collection's own. Only then may a permission name a column, + * and only one that is on the list. + */ + public function test_column_scope_against_a_collection(): void + { + $object = new Permissions(columns: ['name', 'salary']); + + $this->assertTrue($object->isValid([Permission::read(Role::any(), 'name')])); + $this->assertTrue($object->isValid([Permission::read(Role::team('hr'), 'salary')])); + + // A permission naming no column still covers every column. + $this->assertTrue($object->isValid([Permission::read(Role::any())])); + + $this->assertFalse($object->isValid([Permission::read(Role::any(), 'nope')])); + $this->assertSame('Column "nope" does not exist.', $object->getDescription()); + } + + /** + * Without a column list there is nothing to judge a column against, so naming one is + * refused rather than stored and never enforced. This is every set but a collection's own. + */ + public function test_column_scope_refused_without_a_column_list(): void + { + $object = new Permissions(); + + $this->assertFalse($object->isValid([Permission::read(Role::any(), 'name')])); + $this->assertSame( + 'Permission "read("any", "name")" is scoped to a column. Column-scoped permissions are set on the collection, not here.', + $object->getDescription(), + ); + + $this->assertTrue($object->isValid([Permission::read(Role::any())])); + } + + /** + * An empty column list is a collection with no columns: no permission may name one, + * and that is not the same as having no list at all. + */ + public function test_column_scope_against_a_collection_without_columns(): void + { + $object = new Permissions(columns: []); + + $this->assertFalse($object->isValid([Permission::read(Role::any(), 'name')])); + $this->assertSame('Column "name" does not exist.', $object->getDescription()); + + $this->assertTrue($object->isValid([Permission::read(Role::any())])); + } + + /** + * Only read and update answer a question about one column. + * + * A create brings a whole row into being and a delete removes one, so neither is narrowed + * by naming a column, and write aggregates to all three. Refused rather than accepted and + * ignored, so a grant never reads as a restriction it does not enforce. + */ + public function test_column_scope_rejected_for_create_delete_and_write(): void + { + $object = new Permissions(columns: ['name']); + + foreach (['create', 'delete', 'write'] as $type) { + $this->assertFalse($object->isValid([Permission::$type(Role::any(), 'name')]), $type); + $this->assertSame( + 'Permission "'.$type.'" cannot be scoped to a column, it applies to the whole row.', + $object->getDescription(), + ); + } + + // The two that can be narrowed still are, and all three still grant the whole row. + $this->assertTrue($object->isValid([ + Permission::read(Role::any(), 'name'), + Permission::update(Role::any(), 'name'), + ]), $object->getDescription()); + + $this->assertTrue($object->isValid([ + Permission::create(Role::any()), + Permission::delete(Role::any()), + ]), $object->getDescription()); + } + + /** + * A column that could never be a key is told why, rather than reported as one the + * collection happens not to have. + */ + public function test_column_scope_rejects_an_invalid_key(): void + { + $object = new Permissions(columns: ['name']); + + $this->assertFalse($object->isValid(['read("any", "_internal")'])); + $this->assertSame('Column "_internal" is not a valid column key.', $object->getDescription()); + } }