diff --git a/README.md b/README.md index 309966b1d3..659d98496f 100644 --- a/README.md +++ b/README.md @@ -416,7 +416,8 @@ $database->updateCollection( Permission::update(Role::any()), Permission::delete(Role::any()) ], - documentSecurity: true + documentSecurity: true, + columnSecurity: false ); // Get Collection diff --git a/docker-compose.yml b/docker-compose.yml index bbd6976e5f..91734587b9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,15 @@ services: - ./docker-compose.yml:/usr/src/code/docker-compose.yml environment: PHP_IDE_CONFIG: serverName=tests + # Xdebug stays installed but idle. dev/xdebug.ini sets mode=develop,debug,profile + # with start_with_request=yes, so it engages on every request -- and it is a trap in + # two directions. It costs enough time to push the timeout-sensitive tests past + # their thresholds, and it inflates memory per allocation, so tests that assert a + # memory ceiling measure xdebug rather than the code. Either way the run fails + # locally and passes in CI, where the image is built without xdebug.so at all, and + # the failure looks like a real regression. Override per command to step-debug: + # docker compose exec -e XDEBUG_MODE=debug tests ... + XDEBUG_MODE: off depends_on: postgres: condition: service_healthy diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4d2f0ee38f..ab805d48bf 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -867,9 +867,10 @@ abstract public function deleteDocuments(string $collection, array $sequences, a * @param array $cursor * @param string $cursorDirection * @param string $forPermission + * @param array $columnPermissions columns that must be readable on the row * @return array */ - abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array; + abstract public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array; /** * Sum an attribute @@ -879,9 +880,10 @@ abstract public function find(Document $collection, array $queries = [], ?int $l * @param array $queries * @param int|null $max * + * @param array $columnPermissions columns that must be readable on the row * @return int|float */ - abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int; + abstract public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): float|int; /** * Count Documents @@ -890,9 +892,10 @@ abstract public function sum(Document $collection, string $attribute, array $que * @param array $queries * @param int|null $max * + * @param array $columnPermissions columns that must be readable on the row * @return int */ - abstract public function count(Document $collection, array $queries = [], ?int $max = null): int; + abstract public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int; /** * Get Collection Size of the raw data @@ -1011,6 +1014,23 @@ abstract public function getSupportForAttributes(): bool; */ abstract public function getSupportForSchemaAttributes(): bool; + /** + * Can a permission be scoped to a single column? + * + * @return bool + */ + abstract public function getSupportForColumnPermissions(): bool; + + + /** + * Drop every permission scoped to a column that no longer exists. + * + * @param Document $collection + * @param string $column + * @return int documents whose $permissions changed + */ + abstract public function deleteColumnPermissions(Document $collection, string $column): int; + /** * Are schema indexes supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6d2aac8ef7..df7c5f3c8b 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -189,24 +189,41 @@ public function createCollection(string $name, array $attributes = [], array $in $collection .= ")"; $collection = $this->trigger(Database::EVENT_COLLECTION_CREATE, $collection); + // _column scopes a permission to a single column. An empty string means + // every column, which is how every permission written before column-level + // permissions reads. It is NOT NULL on purpose: MySQL and MariaDB treat + // NULLs as distinct in a UNIQUE index, so a nullable _column would let + // duplicate permission rows slip past the unique index. + // + // Sized to MAX_UID_DEFAULT_LENGTH rather than the 255 the other string members + // use. The unique index holds four of those, and in utf8mb4 a fifth + // VARCHAR(255) member + // takes the key past InnoDB's 3072-byte limit -- MySQL refuses the CREATE with + // "Specified key was too long", though MariaDB allows it, so testing on one + // says nothing about the other. The Permissions validator already caps a + // scoped column at this same constant, so nothing storable is lost. $permissions = " CREATE TABLE {$this->getSQLTable($id . '_perms')} ( _id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, + _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, + _documentInternalId BIGINT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (_id), "; if ($this->sharedTables) { $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission), + UNIQUE INDEX " . static::PERMISSIONS_INDEX . " (_document, _tenant, _type, _permission, _column), + INDEX " . static::PERMISSIONS_INDEX_DOCUMENT . " (_documentInternalId, _tenant, _type, _permission, _column), INDEX _permission (_tenant, _permission, _type) "; } else { $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission), + UNIQUE INDEX " . static::PERMISSIONS_INDEX . " (_document, _type, _permission, _column), + INDEX " . static::PERMISSIONS_INDEX_DOCUMENT . " (_documentInternalId, _type, _permission, _column), INDEX _permission (_permission, _type) "; } @@ -894,13 +911,22 @@ public function createDocument(Document $collection, Document $document): Docume $attributeIndex++; } + // _column is always named. Every permissions table carries it -- new ones + // from CREATE TABLE, older ones from the column-permissions migration -- so + // there is nothing to make it conditional on. Writing it unconditionally also + // keeps _perms in step with the _permissions JSON on the row: a grant stored + // as read("role", "salary") lands as _column = 'salary' whatever the flag + // says, so the query gate and masking can never disagree about it. $permissions = []; + $permissionBinds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantBind = $this->sharedTables ? ", :_tenant" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid {$tenantBind})"; - $permissions[] = $permission; + $role = \str_replace('"', '', $permission['role']); + + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$tenantBind})"; } } @@ -909,7 +935,7 @@ public function createDocument(Document $collection, Document $document): Docume $permissions = \implode(', ', $permissions); $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) VALUES {$permissions}; "; @@ -918,6 +944,9 @@ public function createDocument(Document $collection, Document $document): Docume if ($this->sharedTables) { $stmtPermissions->bindValue(':_tenant', $document->getTenant()); } + foreach ($permissionBinds as $key => $value) { + $stmtPermissions->bindValue($key, $value); + } } $stmt->execute(); @@ -932,10 +961,14 @@ public function createDocument(Document $collection, Document $document): Docume try { $stmtPermissions->execute(); } catch (PDOException $e) { + // Compare the violated key exactly rather than searching the + // message for a substring: the index names are contained in + // plenty of other index names, and misreading one would run the + // cleanup below against permissions that were never orphaned. $isOrphanedPermission = $e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062 - && \str_contains($e->getMessage(), '_index1'); + && $this->isPermissionsIndex($this->getViolatedKey($e->getMessage())); if (!$isOrphanedPermission) { throw $e; @@ -1007,10 +1040,13 @@ public function updateDocument(Document $collection, string $id, Document $docum $values = []; $binds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})"; - $binds[":_add_{$type}_{$i}"] = $permission; + + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantPlaceholder})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + + $binds[":_add_{$type}_{$i}"] = $permission['role']; } } @@ -1018,7 +1054,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $tenantColumn = $this->sharedTables ? ', _tenant' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$tenantColumn}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1771,6 +1807,37 @@ public function getSupportForUpsertOnUniqueIndex(): bool return true; } + /** + * _permissions is MEDIUMTEXT under the table's utf8mb4 collation, which is case + * insensitive, so a plain comparison would treat read("user:hr") and + * read("user:HR") as the same permissions. Casting the bound value forces a + * byte-for-byte comparison, which is what the compare-and-set needs. + */ + protected function getJsonBind(string $placeholder): string + { + return "CAST({$placeholder} AS BINARY)"; + } + + public function getSupportForColumnPermissions(): bool + { + return true; + } + + /** + * Is this the unique index on a permissions table, under either name? + * + * A duplicate-key error has to be recognised on tables the column-permissions + * migration has reached and on ones it has not, so both spellings count. + * + * @param string|null $key + * @return bool + */ + protected function isPermissionsIndex(?string $key): bool + { + return $key === static::PERMISSIONS_INDEX || $key === static::PERMISSIONS_INDEX_LEGACY; + } + + public function getSupportForSchemaAttributes(): bool { return true; @@ -1896,7 +1963,7 @@ protected function processException(PDOException $e): \Exception // Duplicate row if ($e->getCode() === '23000' && isset($e->errorInfo[1]) && $e->errorInfo[1] === 1062) { $key = $this->getViolatedKey($e->getMessage()); - if ($key === '_index1') { + if ($this->isPermissionsIndex($key)) { return new DuplicateException('Duplicate permissions for document', $e->getCode(), $e); } if ($key !== null && $key !== '_uid' && $key !== 'PRIMARY') { diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5e126a7177..8d114a7a07 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -12,6 +12,7 @@ use Utopia\Database\Exception\NotFound as NotFoundException; use Utopia\Database\Exception\Operator as OperatorException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Helpers\Permission; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -1633,14 +1634,14 @@ public function deleteDocuments(string $collection, array $sequences, array $per return $count; } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, $forPermission, $columnPermissions); $rows = $this->applyOrdering($rows, $orderAttributes, $orderTypes, $cursorDirection); $rows = $this->applyCursor($rows, $orderAttributes, $orderTypes, $cursor, $cursorDirection); @@ -1664,14 +1665,14 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 return $results; } - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ, $columnPermissions); if (! is_null($max)) { // MariaDB applies LIMIT :max inside the COUNT subquery — LIMIT 0 @@ -1682,14 +1683,14 @@ public function count(Document $collection, array $queries = [], ?int $max = nul return \count($rows); } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): float|int { $key = $this->key($collection->getId()); if (! isset($this->data[$key])) { throw new NotFoundException('Collection not found'); } - $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ); + $rows = $this->fusedFilter($key, $collection->getId(), $queries, Database::PERMISSION_READ, $columnPermissions); if (! is_null($max)) { $rows = \array_slice($rows, 0, $max); @@ -2086,6 +2087,99 @@ public function getSchemaIndexes(string $collection): array return []; } + public function getSupportForColumnPermissions(): bool + { + return true; + } + + + public function deleteColumnPermissions(Document $collection, string $column): int + { + return $this->deleteColumnPermissionRows($collection, $column); + } + + /** + * Drop the permissions scoped to one column. + * + * Both the stored _permissions and this adapter's role index are rewritten. The + * index holds roles with the column stripped, so it cannot be left alone: dropping + * a row's last column-scoped grant takes the permission out of _permissions but + * would leave the role still indexed, and the row gate reads the index -- so the + * document would stay countable by a caller who can no longer read any of it. On + * the SQL adapters the _perms rows are the index and carry _column, so removing + * them settles both at once; here they are separate and both have to be told. + * + * @param Document $collection + * @param string $column + * @return int documents whose permissions changed + * @throws DatabaseException + */ + private function deleteColumnPermissionRows(Document $collection, string $column): int + { + $key = $this->key($collection->getId()); + $updated = 0; + + /** @var array> $touched */ + $touched = []; + + foreach ($this->data[$key]['documents'] ?? [] as $documentKey => $row) { + $permissions = $row['_permissions'] ?? []; + + if (!\is_array($permissions)) { + continue; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + if (Permission::parse($permission)->getColumn() === $column) { + $changed = true; + continue; + } + + $rewritten[] = $permission; + } + + if (!$changed) { + continue; + } + + $touched[$documentKey] = $permissions; + $rewritten = \array_values(\array_unique($rewritten)); + $this->data[$key]['documents'][$documentKey]['_permissions'] = $rewritten; + + $uid = $row['_uid'] ?? $documentKey; + $tenant = $row['_tenant'] ?? null; + + $this->removePermissionsForDocument($key, (string) $uid, $tenant, $this->sharedTables); + + $indexed = new Document(['$permissions' => $rewritten]); + foreach (Database::PERMISSIONS as $type) { + foreach ($indexed->getPermissionsByType($type) as $permission) { + $this->addPermissionEntry($key, (string) $uid, (string) $type, (string) $permission, $tenant); + } + } + + $updated++; + } + + // One inverse for the whole sweep, like renameAttribute() above. Without it a + // rollback would undo the rename or delete but keep the rewritten grants, and + // rowGrantsColumns() would then deny access on a column that still exists. + if (!empty($touched)) { + $this->journal(function () use ($key, $touched): void { + foreach ($touched as $documentKey => $permissions) { + if (isset($this->data[$key]['documents'][$documentKey])) { + $this->data[$key]['documents'][$documentKey]['_permissions'] = $permissions; + } + } + }); + } + + return $updated; + } + public function getTenantQuery(string $collection, string $alias = ''): string { return ''; @@ -2551,7 +2645,12 @@ protected function documentUniqueSignatures(string $key, Document $document): ar * @param array $queries * @return array> */ - protected function fusedFilter(string $key, string $collectionId, array $queries, string $forPermission): array + /** + * @param array $queries + * @param array $columnPermissions columns that must be readable on the row + * @return array> + */ + protected function fusedFilter(string $key, string $collectionId, array $queries, string $forPermission, array $columnPermissions = []): array { $documents = $this->data[$key]['documents'] ?? []; if (empty($documents)) { @@ -2588,6 +2687,12 @@ protected function fusedFilter(string $key, string $collectionId, array $queries continue; } + // The in-memory equivalent of the EXISTS the SQL adapters emit: a row must + // grant read on every column the query reaches, or it cannot match at all. + if (! empty($columnPermissions) && ! $this->rowGrantsColumns($row, $columnPermissions)) { + continue; + } + $matched = true; foreach ($effectiveQueries as $query) { if (! $this->matches($row, $query)) { @@ -2605,6 +2710,50 @@ protected function fusedFilter(string $key, string $collectionId, array $queries return $output; } + /** + * Does this row grant the current roles read access to every one of these columns? + * + * Only document permissions are consulted, which is correct by construction: + * Database only asks about columns the collection itself does not grant, so a + * collection-level grant can never be the thing that satisfies this. + * + * @param array $row + * @param array $columns + * @return bool + */ + protected function rowGrantsColumns(array $row, array $columns): bool + { + $permissions = $row['_permissions'] ?? []; + + if (! \is_array($permissions)) { + return false; + } + + $granted = []; + + $document = new Document(['$permissions' => $permissions]); + + foreach ($document->getPermissionsByTypeWithColumns(Database::PERMISSION_READ) as $permission) { + if (! $this->authorization->hasRole($permission['role'])) { + continue; + } + + if ($permission['column'] === Permission::COLUMN_ALL) { + return true; + } + + $granted[$permission['column']] = true; + } + + foreach ($columns as $column) { + if (! isset($granted[$column])) { + return false; + } + } + + return true; + } + /** * @param array $row */ diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 760e9e79c7..47f5a6c06f 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -23,6 +23,7 @@ use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Exception\Type as TypeException; use Utopia\Database\Exception\Unique as UniqueException; +use Utopia\Database\Helpers\Permission; use Utopia\Database\Operator; use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; @@ -2461,18 +2462,105 @@ protected function getInternalKeyForAttribute(string $attribute): string } /** + * Candidate permission strings to match against the inline _permissions array. + * + * Mongo keeps permissions as the assembled strings rather than splitting the role + * from the column the way the SQL adapters do, so an exact $in has to enumerate + * both shapes: the unscoped grant and one per column of the collection. Without + * the column-scoped variants a document whose only read grant is column-scoped + * matches nothing and disappears from find(), count() and sum(), even though + * getDocument() -- which carries no permission filter -- still returns it. + * + * @param string $type + * @param Document $collection * @return list */ - private function permissionStrings(string $type): array + private function permissionStrings(string $type, Document $collection): array { + // Identities, not keys. A column-scoped grant is stored against the + // attribute's $internalId so that renaming the column moves nothing, and this + // has to enumerate what the stored strings actually contain. + $columns = []; + + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $internalId = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? null; + + if (\is_string($internalId) && $internalId !== '') { + $columns[$internalId] = true; + } + } + $permissions = []; + foreach ($this->authorization->getRoles() as $role) { $permissions[] = $type . '("' . $role . '")'; + + foreach (\array_keys($columns) as $column) { + $permissions[] = $type . '("' . $role . '", "' . $column . '")'; + } } return $permissions; } + /** + * Candidates that grant one specific column: the unscoped form, and that column. + * + * The row filter enumerates every column, because a grant on any one of them + * makes the row visible. That is the wrong test for a query that reads a column's + * value -- a grant on "name" would let a filter on "salary" through and expose + * the hidden value by which rows come back. This narrows the set to the grants + * that actually cover the column being read. + * + * @param string $type + * @param string $column + * @return list + */ + private function columnPermissionStrings(string $type, string $column): array + { + $permissions = []; + + foreach ($this->authorization->getRoles() as $role) { + $permissions[] = $type . '("' . $role . '")'; + $permissions[] = $type . '("' . $role . '", "' . $column . '")'; + } + + return $permissions; + } + + /** + * Require read access to each of these columns on every matched document. + * + * @param array $filters + * @param array $columnPermissions + * @param string $type + * @return array + */ + private function applyColumnPermissions(array $filters, array $columnPermissions, string $type): array + { + // Not gated on authorization->getStatus(). That flag is also false when the + // caller holds a collection-level grant, because Database wraps the call in + // authorization->skip() -- and a column-scoped collection grant is exactly the + // case that needs column filtering. Skipping row authorization means the caller + // may see every row, never that it may see every column. Database decides + // whether to pass any columns at all; an empty list filters nothing. The SQL + // adapters keep their column conditions outside the same guard. + if (empty($columnPermissions)) { + return $filters; + } + + // One clause per column, ANDed: a document has to grant every column the + // query reads, not merely one of them. Expressed through $and because each + // clause constrains the same _permissions field. + foreach ($columnPermissions as $column) { + $filters['$and'][] = [ + '_permissions' => ['$in' => $this->columnPermissionStrings($type, $column)], + ]; + } + + return $filters; + } + /** * Find Documents * @@ -2488,11 +2576,12 @@ private function permissionStrings(string $type): array * @param string $cursorDirection * @param string $forPermission * + * @param array $columnPermissions columns that must be readable on the row * @return array * @throws Exception * @throws TimeoutException */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $queries = array_map(fn ($query) => clone $query, $queries); @@ -2509,9 +2598,11 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 // permissions if ($this->authorization->getStatus()) { - $filters['_permissions']['$in'] = $this->permissionStrings($forPermission); + $filters['_permissions']['$in'] = $this->permissionStrings($forPermission, $collection); } + $filters = $this->applyColumnPermissions($filters, $columnPermissions, Database::PERMISSION_READ); + $options = []; if (!\is_null($limit)) { @@ -2738,10 +2829,11 @@ private function replaceInternalIdsKeys(array $array, string $from, string $to, * @param Document $collection * @param array $queries * @param int|null $max + * @param array $columnPermissions columns that must be readable on the row * @return int * @throws Exception */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); @@ -2761,9 +2853,11 @@ public function count(Document $collection, array $queries = [], ?int $max = nul // Add permissions filter if authorization is enabled if ($this->authorization->getStatus()) { - $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ); + $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ, $collection); } + $filters = $this->applyColumnPermissions($filters, $columnPermissions, Database::PERMISSION_READ); + /** * Use MongoDB aggregation pipeline for accurate counting * Accuracy and Sharded Clusters @@ -2842,11 +2936,12 @@ public function count(Document $collection, array $queries = [], ?int $max = nul * @param array $queries * @param int|null $max * + * @param array $columnPermissions columns that must be readable on the row * @return int|float * @throws Exception */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): float|int { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); @@ -2860,9 +2955,11 @@ public function sum(Document $collection, string $attribute, array $queries = [] // permissions if ($this->authorization->getStatus()) { // skip if authorization is disabled - $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ); + $filters['_permissions']['$in'] = $this->permissionStrings(Database::PERMISSION_READ, $collection); } + $filters = $this->applyColumnPermissions($filters, $columnPermissions, Database::PERMISSION_READ); + // using aggregation to get sum an attribute as described in // https://docs.mongodb.com/manual/reference/method/db.collection.aggregate/ // Pipeline consists of stages to aggregation, so first we set $match @@ -4202,6 +4299,126 @@ public function decodePolygon(string $wkb): array return []; } + public function getSupportForColumnPermissions(): bool + { + return true; + } + + + public function deleteColumnPermissions(Document $collection, string $column): int + { + return $this->deleteColumnPermissionRows($collection, $column); + } + + /** + * Drop the permissions scoped to one column. + * + * This adapter keeps permissions inline on each document and authorizes column + * access from those same strings, so a dropped column leaves grants a recreated + * one could inherit if they were not cleared. + * + * Deletion only. Renaming is not reachable -- _column carries the attribute's + * immutable identity, which a rename does not change -- and the removal below is + * expressed as a $pull, which can take elements out but cannot rewrite them. + * + * @param Document $collection + * @param string $column + * @return int documents whose permissions changed + * @throws Exception + */ + private function deleteColumnPermissionRows(Document $collection, string $column): int + { + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $updated = 0; + $cursor = null; + + // Paged by _uid rather than by matching the column, because the column lives + // inside an assembled permission string that no index can answer. Renames and + // deletes are rare, administrator-initiated operations, so a single ordered + // pass is the right shape; the cursor is the last id seen, which keeps it + // stable as rows are rewritten underneath it. + while (true) { + $filters = []; + + if (!\is_null($cursor)) { + $filters['_uid'] = ['$gt' => $cursor]; + } + + if ($this->sharedTables) { + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); + } + + // Both the read and the write below join the open transaction. Without the + // session they run outside it, so a rollback would restore the column while + // leaving its grants rewritten or dropped. + // batchSize matches the limit so the whole batch arrives in firstBatch and + // the server closes the cursor itself. Left to its default, MongoDB returns + // 101 documents and keeps the cursor open for a getMore that never comes -- + // this loop only ever reads firstBatch -- so a sweep over a column with many + // grants would abandon one server cursor per pass. It also turns roughly ten + // round trips per batch into one. + // + // find() and count() solve the same problem the other way, draining the + // cursor with getMore() and killing the remainder in a finally. They have to: + // they honour a caller's limit and cannot size a batch to fit it. This sweep + // owns its own limit, so not opening a cursor is simpler than closing one. + $found = $this->client->find($name, $filters, $this->getTransactionOptions([ + 'limit' => Database::DELETE_BATCH_SIZE, + 'batchSize' => Database::DELETE_BATCH_SIZE, + 'sort' => ['_uid' => 1], + 'projection' => ['_uid' => 1, '_permissions' => 1], + ]))->cursor->firstBatch ?? []; + + if (empty($found)) { + break; + } + + foreach ($found as $row) { + $row = $this->client->toArray($row); + $cursor = $row['_uid']; + + $permissions = $row['_permissions'] ?? []; + + if (!\is_array($permissions)) { + continue; + } + + // Collected as the exact strings to drop rather than as a rewritten + // array. $set would write the whole field back, so a grant added or + // revoked between the read above and this write would be silently + // undone -- a lost revocation being the one that matters. $pull removes + // only these elements and leaves everything else as it stands, which is + // a single atomic server-side operation and needs no re-read or retry. + $remove = []; + + foreach ($permissions as $permission) { + $permission = (string)$permission; + + if (Permission::parse($permission)->getColumn() === $column) { + $remove[] = $permission; + } + } + + if (empty($remove)) { + continue; + } + + $where = ['_uid' => $row['_uid']]; + if ($this->sharedTables) { + $where['_tenant'] = $this->getTenantFilters($collection->getId()); + } + + $this->client->update($name, $where, [ + '$pull' => ['_permissions' => ['$in' => \array_values(\array_unique($remove))]], + ], $this->getTransactionOptions()); + + $updated++; + } + } + + return $updated; + } + /** * Get the query to check for tenant when in shared tables mode * @@ -4209,6 +4426,7 @@ public function decodePolygon(string $wkb): array * @param string $alias The alias of the parent collection if in a subquery * @return string */ + public function getTenantQuery(string $collection, string $alias = ''): string { return ''; diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 511da2b13a..01e8ec381f 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -437,17 +437,17 @@ public function deleteDocuments(string $collection, array $sequences, array $per return $this->delegate(__FUNCTION__, \func_get_args()); } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): float|int { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int { return $this->delegate(__FUNCTION__, \func_get_args()); } @@ -522,6 +522,17 @@ public function getSupportForAttributes(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForColumnPermissions(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + + public function deleteColumnPermissions(Document $collection, string $column): int + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + public function getSupportForSchemaAttributes(): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 3004e9780a..b97feac958 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -256,25 +256,33 @@ public function createCollection(string $name, array $attributes = [], array $in _tenant INTEGER DEFAULT NULL, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, - _document VARCHAR(255) NOT NULL + _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', + _document VARCHAR(255) NOT NULL, + \"_documentInternalId\" BIGINT NOT NULL DEFAULT 0 ); "; if ($this->sharedTables) { $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); $permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission"); + $documentIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_docint"); $permissions .= " CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission); + ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_document,_type,_permission,_column); + CREATE INDEX \"{$documentIndex}\" + ON {$this->getSQLTable($id . '_perms')} USING btree (\"_documentInternalId\",_tenant,_type,_permission,_column); CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); "; } else { $uniquePermissionIndex = $this->getShortKey("{$namespace}_{$id}_ukey"); $permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission"); + $documentIndex = $this->getShortKey("{$namespace}_{$id}_docint"); $permissions .= " CREATE UNIQUE INDEX \"{$uniquePermissionIndex}\" - ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission); + ON {$this->getSQLTable($id . '_perms')} USING btree (_document COLLATE utf8_ci_ai,_type,_permission,_column); + CREATE INDEX \"{$documentIndex}\" + ON {$this->getSQLTable($id . '_perms')} USING btree (\"_documentInternalId\",_type,_permission,_column); CREATE INDEX \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type); "; @@ -1046,11 +1054,14 @@ public function createDocument(Document $collection, Document $document): Docume } $permissions = []; + $permissionBinds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $permission = \str_replace('"', '', $permission); + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { + $role = \str_replace('"', '', $permission['role']); $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $permissions[] = "('{$type}', '{$permission}', :_uid {$sqlTenant})"; + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$sqlTenant})"; } } @@ -1060,7 +1071,7 @@ public function createDocument(Document $collection, Document $document): Docume $sqlTenant = $this->sharedTables ? ', _tenant' : ''; $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$sqlTenant}) VALUES {$permissions} "; @@ -1070,6 +1081,9 @@ public function createDocument(Document $collection, Document $document): Docume if ($sqlTenant) { $stmtPermissions->bindValue(':_tenant', $document->getTenant()); } + foreach ($permissionBinds as $key => $value) { + $stmtPermissions->bindValue($key, $value); + } } try { @@ -1133,10 +1147,12 @@ public function updateDocument(Document $collection, string $id, Document $docum $values = []; $binds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; - $binds[":_add_{$type}_{$i}"] = $permission; + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$sqlTenant})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + + $binds[":_add_{$type}_{$i}"] = $permission['role']; } } @@ -1144,7 +1160,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $sqlTenant = $this->sharedTables ? ', _tenant' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$sqlTenant}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1825,7 +1841,8 @@ protected function getSQLPermissionsCondition( string $collection, array $roles, string $alias, - string $type = Database::PERMISSION_READ + string $type = Database::PERMISSION_READ, + bool $columnSecurity = false ): string { if (!\in_array($type, Database::PERMISSIONS)) { throw new DatabaseException('Unknown permission type: ' . $type); @@ -1846,6 +1863,39 @@ protected function getSQLPermissionsCondition( return 'FALSE'; } + // The containment list above is built from the assembled string read("role"), + // so it can only ever match an UNSCOPED grant. A column-scoped grant is stored + // as read("role", "column") and is not contained by it, which would make a + // document whose only read grant is column-scoped vanish from find() while + // getDocument() -- which carries no permission filter -- still returned it. + // + // Only when the collection enabled column security. Otherwise no permission + // can be column-scoped, the containment list above is complete, and reads stay + // answerable from the row alone -- which is the whole point of the jsonb path. + if (!$columnSecurity) { + return '(' . \implode(' OR ', $permissions) . ')'; + } + + // Rather than enumerate a containment check per role per column, which would + // multiply the BitmapOr branches by the width of the collection, fall back to + // the _perms table for exactly the rows the jsonb path cannot answer. The + // probe is driven by _index1, which leads with _document, and only runs for + // rows the cheap indexed path already missed. + $perms = $this->quote('_rp'); + + $permissions[] = "EXISTS ( + SELECT 1 + FROM {$this->getSQLTable($collection . '_perms')} AS {$perms} + WHERE {$perms}.{$this->quote('_document')} = {$this->quote($alias)}.{$this->quote('_uid')} + AND {$perms}.{$this->quote('_permission')} IN (" . \implode(', ', \array_map( + fn ($role) => $this->getPDO()->quote($role), + $roles + )) . ") + AND {$perms}.{$this->quote('_type')} = '{$type}' + AND {$perms}.{$this->quote('_column')} <> '' + {$this->getTenantQuery($collection, '_rp')} + )"; + return '(' . \implode(' OR ', $permissions) . ')'; } @@ -2090,6 +2140,20 @@ public function getSupportForIntegerBooleans(): bool * * @return bool */ + /** + * _permissions is JSONB here, which has no equality operator against text, so the + * bound value is cast before the comparison. + */ + protected function getJsonBind(string $placeholder): string + { + return $placeholder . '::jsonb'; + } + + public function getSupportForColumnPermissions(): bool + { + return true; + } + public function getSupportForSchemaAttributes(): bool { return false; @@ -2355,11 +2419,13 @@ protected function getInsertPermissionsSuffix(): string return ''; } - $conflictTarget = $this->sharedTables - ? '("_type", "_permission", "_document", "_tenant")' - : '("_type", "_permission", "_document")'; - - return "ON CONFLICT {$conflictTarget} DO NOTHING"; + // No conflict target on purpose. Postgres resolves a target against a real + // unique index and demands an exact column match, so naming one would tie this + // statement to whether the table has been widened for column permissions -- + // and a table created before that existed carries the narrower index. Omitting + // the target skips a row on any unique violation, which is what + // skipDuplicates asks for, and works against either shape. + return 'ON CONFLICT DO NOTHING'; } public function decodePoint(string $wkb): array diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 81f3350634..11c8fa97ba 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -771,6 +771,17 @@ public function setSupportForAttributes(bool $support): bool return true; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + + public function deleteColumnPermissions(Document $collection, string $column): int + { + return 0; + } + public function getSupportForSchemaAttributes(): bool { return false; @@ -2821,7 +2832,7 @@ public function renameIndex(string $collection, string $old, string $new): bool }); } - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array { $collectionId = $this->filter($collection->getId()); $metaKey = $this->key($this->ns(), 'meta', $collectionId); @@ -2860,7 +2871,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 }); } - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): float|int + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): float|int { $collectionId = $this->filter($collection->getId()); $metaKey = $this->key($this->ns(), 'meta', $collectionId); @@ -2896,7 +2907,7 @@ public function sum(Document $collection, string $attribute, array $queries = [] }); } - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int { $collectionId = $this->filter($collection->getId()); $metaKey = $this->key($this->ns(), 'meta', $collectionId); diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9ca4c1aee3..8c53a1d31e 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -15,6 +15,7 @@ use Utopia\Database\Exception\Timeout as TimeoutException; use Utopia\Database\Exception\Transaction as TransactionException; use Utopia\Database\Helpers\ID; +use Utopia\Database\Helpers\Permission; use Utopia\Database\Operator; use Utopia\Database\Query; @@ -22,6 +23,70 @@ abstract class SQL extends Adapter { protected const VECTOR_DISTANCE_COLUMN = '_distance'; + /** + * Name of the unique index that keeps a permissions table free of duplicate + * grants. It always covers _column. + */ + protected const PERMISSIONS_INDEX = '_unique'; + + /** + * What that index was called before it covered _column. + * + * Nothing here creates or rebuilds it -- a table still carrying this name is one + * the column-permissions migration has not reached yet, and the migration is what + * moves it. It is named only so a duplicate-key error raised on such a table is + * still recognised as a permission collision. + */ + protected const PERMISSIONS_INDEX_LEGACY = '_index1'; + + /** + * Index over _documentInternalId. + * + * Groundwork. Permissions correlate on _document today -- a VARCHAR(255), which is + * 1020 bytes of the unique index and the comparison every correlated EXISTS makes + * per outer row. _documentInternalId is the same fact as an 8-byte integer, so the + * intended redesign repoints that correlation at it. The column ships unpopulated: + * the batch insert builds its permission binds before the rows exist, and + * lastInsertId() plus an offset is wrong once skipDuplicates leaves gaps, so + * filling it needs a sequence read-back that belongs with the redesign rather than + * ahead of it. + * + * Shaped like PERMISSIONS_INDEX so the probe stays index-only once it is used: the + * correlated EXISTS reads _type, _permission and _column too, and an index on the + * id alone would seek and then fetch the row for each of those. Deliberately NOT + * unique -- every row holds the default 0 until the backfill, so uniqueness would + * collide on the second document. It becomes the unique index, and _unique goes + * away, when the column is populated. + */ + protected const PERMISSIONS_INDEX_DOCUMENT = '_document_internal'; + + /** + * How many times a conditional permissions rewrite re-reads and retries before + * giving up on a row. Contention here means someone edited the same document's + * permissions mid-sweep, which is rare and does not repeat indefinitely. + */ + protected const REWRITE_MAX_ATTEMPTS = 3; + + /** + * Rows a batched read takes per pass. + * + * Paired with BIND_CHUNK_SIZE below, and deliberately larger. The two are bounded + * by different things: a read whose predicate cannot seek costs a scan whatever it + * returns, so the only way to pay it less often is to take more each time. Reading + * wide and chunking the writes afterwards cuts the number of scans without + * changing how much is bound at once. + */ + protected const SELECT_BATCH_SIZE = 12_000; + + /** + * Values a single statement binds. + * + * Bounded by how many placeholders a prepared statement can carry, not by how much + * was read. Kept well inside the 5000 that Database caps query values at, so rows + * from one SELECT_BATCH_SIZE read are chunked to this before being bound. + */ + protected const BIND_CHUNK_SIZE = 3_000; + protected mixed $pdo; /** @@ -629,7 +694,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ } $sql = " - SELECT _type, _permission + SELECT _type, _permission, _column FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} @@ -654,14 +719,24 @@ public function updateDocuments(Document $collection, Document $updates, array $ } $permissions = \array_reduce($permissions, function (array $carry, array $item) { - $carry[$item['_type']][] = $item['_permission']; + $carry[$item['_type']][] = $item['_permission'] . "\0" . ($item['_column'] ?? ''); return $carry; }, $initial); + // Desired state in the same role\0column shape, so a permission that + // only changes column still shows up as a removal plus an addition. + $desired = []; + foreach (Database::PERMISSIONS as $type) { + $desired[$type] = \array_map( + fn (array $permission) => $permission['role'] . "\0" . $permission['column'], + $updates->getPermissionsByTypeWithColumns($type) + ); + } + // Get removed Permissions $removals = []; foreach (Database::PERMISSIONS as $type) { - $diff = array_diff($permissions[$type], $updates->getPermissionsByType($type)); + $diff = array_diff($permissions[$type], $desired[$type]); if (!empty($diff)) { $removals[$type] = $diff; } @@ -674,18 +749,25 @@ public function updateDocuments(Document $collection, Document $updates, array $ $removeBindKeys[] = ':_uid_' . $index; $removeBindValues[$bindKey] = $document->getId(); + $pairs = []; + foreach (\array_keys($permissionsToRemove) as $i) { + [$role, $column] = \explode("\0", $permissionsToRemove[$i], 2); + + $roleBind = 'remove_' . $type . '_' . $index . '_' . $i; + $columnBind = 'removecol_' . $type . '_' . $index . '_' . $i; + $removeBindKeys[] = ':' . $roleBind; + $removeBindKeys[] = ':' . $columnBind; + $removeBindValues[$roleBind] = $role; + $removeBindValues[$columnBind] = $column; + + $pairs[] = "(_permission = :{$roleBind} AND _column = :{$columnBind})"; + } + $removeQueries[] = "( _document = :_uid_{$index} {$this->getTenantQuery($collection)} AND _type = '{$type}' - AND _permission IN (" . \implode(', ', \array_map(function (string $i) use ($permissionsToRemove, $index, $type, &$removeBindKeys, &$removeBindValues) { - $bindKey = 'remove_' . $type . '_' . $index . '_' . $i; - $removeBindKeys[] = ':' . $bindKey; - $removeBindValues[$bindKey] = $permissionsToRemove[$i]; - - return ':' . $bindKey; - }, \array_keys($permissionsToRemove))) . - ") + AND (" . \implode(' OR ', $pairs) . ") )"; } } @@ -693,7 +775,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ // Get added Permissions $additions = []; foreach (Database::PERMISSIONS as $type) { - $diff = \array_diff($updates->getPermissionsByType($type), $permissions[$type]); + $diff = \array_diff($desired[$type], $permissions[$type]); if (!empty($diff)) { $additions[$type] = $diff; } @@ -703,13 +785,18 @@ public function updateDocuments(Document $collection, Document $updates, array $ if (!empty($additions)) { foreach ($additions as $type => $permissionsToAdd) { foreach ($permissionsToAdd as $i => $permission) { + [$role, $column] = \explode("\0", $permission, 2); + $bindKey = '_uid_' . $index; $addBindValues[$bindKey] = $document->getId(); $bindKey = 'add_' . $type . '_' . $index . '_' . $i; - $addBindValues[$bindKey] = $permission; + $addBindValues[$bindKey] = $role; - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + $columnBindKey = 'addcol_' . $type . '_' . $index . '_' . $i; + $addBindValues[$columnBindKey] = $column; + + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; if ($this->sharedTables) { $addQuery .= ", :_tenant)"; @@ -749,7 +836,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ if (!empty($addQuery)) { $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column "; if ($this->sharedTables) { @@ -1969,7 +2056,8 @@ protected function getSQLPermissionsCondition( string $collection, array $roles, string $alias, - string $type = Database::PERMISSION_READ + string $type = Database::PERMISSION_READ, + bool $columnSecurity = false ): string { if (!\in_array($type, Database::PERMISSIONS)) { throw new DatabaseException('Unknown permission type: ' . $type); @@ -1977,16 +2065,385 @@ protected function getSQLPermissionsCondition( $roles = \array_map(fn ($role) => $this->getPDO()->quote($role), $roles); $roles = \implode(', ', $roles); - - return "{$this->quote($alias)}.{$this->quote('_uid')} IN ( - SELECT _document - FROM {$this->getSQLTable($collection . '_perms')} - WHERE _permission IN ({$roles}) - AND _type = '{$type}' - {$this->getTenantQuery($collection)} + $perms = $this->quote('_rp'); + + // EXISTS rather than _uid IN (SELECT _document ...): correlating on _document + // lets _index1 drive it, since that index leads with _document, and the probe + // stops at the first matching grant. The IN form had to be answered from the + // _permission index, which does not carry _document, so every matching + // permission row needed a lookup -- and a document with several column-scoped + // grants produces several of those where it used to produce one. + // + // _column is deliberately absent from the predicate: this decides whether the + // ROW is visible, and one readable column is enough for that. Which columns + // come back is settled separately, by masking and by + // getSQLColumnPermissionsConditions(). + return "EXISTS ( + SELECT 1 + FROM {$this->getSQLTable($collection . '_perms')} AS {$perms} + WHERE {$perms}.{$this->quote('_document')} = {$this->quote($alias)}.{$this->quote('_uid')} + AND {$perms}.{$this->quote('_permission')} IN ({$roles}) + AND {$perms}.{$this->quote('_type')} = '{$type}' + {$this->getTenantQuery($collection, '_rp')} )"; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + /** + * Drop the permissions scoped to one column. + * + * @param Document $collection + * @param string $column + * @return int documents whose permissions changed + * @throws DatabaseException + */ + public function deleteColumnPermissions(Document $collection, string $column): int + { + return $this->deleteColumnPermissionRows($collection, $column); + } + + /** + * Move or drop the permissions scoped to one column. + * + * The column key lives in two places: _perms._column, which backs permission + * queries, and the _permissions JSON on the collection table, which is what + * callers read back as $permissions. Both have to change together. + * + * The _perms lookup runs first and is empty whenever nobody scoped a permission + * to this column, in which case there is nothing else to do. When it is not + * empty it yields a bounded set of document ids, so the JSON rewrite stays + * targeted instead of scanning the whole collection. + * + * @param Document $collection + * @param string $column + * @return int documents whose permissions changed + * @throws DatabaseException + */ + private function deleteColumnPermissionRows(Document $collection, string $column): int + { + $name = $this->filter($collection->getId()); + $tenantQuery = $this->getTenantQuery($collection->getId()); + $table = $this->getSQLTable($name . '_perms'); + $updated = 0; + + // Worked in batches rather than all at once. A column used by a per-document + // permission is used by one row per document, so the affected set grows with + // the collection: loading every id would hold the whole set in memory, and + // binding them into a single IN list would blow past the server's parameter + // limit long before that. + // + // Reading and writing use different sizes on purpose -- see SELECT_BATCH_SIZE + // and BIND_CHUNK_SIZE. _column is the last member of the unique index, so this + // read cannot seek and scans whatever it returns; taking more per pass is the + // only way to scan fewer times. + // + // The loop advances on _id rather than trusting the deletes to drain the + // predicate. They do, but paging on the primary key makes the whole sweep one + // ordered walk of the table instead of restarting the scan on every pass. + $cursor = 0; + + while (true) { + // The primary key comes back alongside the document id so the mutation + // below can address these rows directly. Matching on _column again would + // re-find them through a predicate that is not a leading index column. + $stmt = $this->getPDO()->prepare(" + SELECT _id, _document + FROM {$table} + WHERE _column = :_column + AND _id > :_cursor + {$tenantQuery} + ORDER BY _id + LIMIT " . self::SELECT_BATCH_SIZE . " + "); + $stmt->bindValue(':_column', $column); + $stmt->bindValue(':_cursor', $cursor); + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $this->execute($stmt); + + $rows = $stmt->fetchAll(); + $stmt->closeCursor(); + + if (empty($rows)) { + break; + } + + foreach (\array_chunk($rows, self::BIND_CHUNK_SIZE) as $chunk) { + $sequences = \array_column($chunk, '_id'); + $documents = \array_values(\array_unique(\array_column($chunk, '_document'))); + + $placeholders = \implode(', ', \array_map( + fn ($index) => ":_uid_{$index}", + \array_keys($documents) + )); + + // The stored $permissions on the row and the _perms rows hold the same + // fact, so they go together, scoped to this batch. + $updated += $this->removePermissionsJson($name, $documents, $placeholders, $tenantQuery, $column); + + // Addressed by primary key. + $sequencePlaceholders = \implode(', ', \array_map( + fn ($index) => ":_id_{$index}", + \array_keys($sequences) + )); + + $mutate = $this->getPDO()->prepare(" + DELETE FROM {$table} + WHERE _id IN ({$sequencePlaceholders}) + "); + + foreach ($sequences as $index => $sequence) { + $mutate->bindValue(":_id_{$index}", $sequence); + } + $this->execute($mutate); + } + + // Ordered by _id, so the last row of the batch is the high-water mark. + $cursor = (int) $rows[\count($rows) - 1]['_id']; + } + + return $updated; + } + + + /** + * Rewrite the stored $permissions of one batch of documents. + * + * @param string $name filtered collection id + * @param array $documents + * @param string $placeholders + * @param string $tenantQuery + * @param string $column + * @return int documents whose $permissions changed + * @throws DatabaseException + */ + private function removePermissionsJson( + string $name, + array $documents, + string $placeholders, + string $tenantQuery, + string $column + ): int { + $select = $this->getPDO()->prepare(" + SELECT _uid, _permissions + FROM {$this->getSQLTable($name)} + WHERE _uid IN ({$placeholders}) + {$tenantQuery} + "); + foreach ($documents as $index => $id) { + $select->bindValue(":_uid_{$index}", $id); + } + if ($this->sharedTables) { + $select->bindValue(':_tenant', $this->tenant); + } + $this->execute($select); + + $rows = $select->fetchAll(); + $select->closeCursor(); + + // Conditional on the value that was read. Writing the whole field back + // unconditionally would undo any grant added or revoked between the select + // above and this update -- a lost revocation being the one that matters. The + // row-lock alternative is not available: SQLite disables FOR UPDATE precisely + // because it deadlocks against the DDL deleteAttribute() performs around this. + $stored = $this->getJsonBind(':_stored'); + + $update = $this->getPDO()->prepare(" + UPDATE {$this->getSQLTable($name)} + SET _permissions = :_permissions + WHERE _uid = :_uid + AND _permissions = {$stored} + {$tenantQuery} + "); + + $updated = 0; + + foreach ($rows as $row) { + $current = $row['_permissions'] ?? '[]'; + + for ($attempt = 0; $attempt < self::REWRITE_MAX_ATTEMPTS; $attempt++) { + $rewritten = $this->withoutColumnPermissions($current, $column); + + if ($rewritten === null) { + // Nothing on this row names the column any more. + break; + } + + $update->bindValue(':_permissions', $rewritten); + $update->bindValue(':_uid', $row['_uid']); + $update->bindValue(':_stored', $current); + if ($this->sharedTables) { + $update->bindValue(':_tenant', $this->tenant); + } + $this->execute($update); + + if ($update->rowCount() > 0) { + $updated++; + break; + } + + // Someone else wrote the row first. Re-read and rebuild on what is + // there now, so their change survives and ours still applies. + $current = $this->currentPermissions($name, $row['_uid'], $tenantQuery); + + if ($current === null) { + // Row is gone; nothing left to rewrite. + break; + } + } + } + + return $updated; + } + + /** + * Rebuild a stored permissions JSON without the grants scoped to one column. + * + * @param string $stored the JSON as it is on the row + * @param string $column the column whose grants are going + * @return string|null the new JSON, or null when nothing names $column + */ + private function withoutColumnPermissions(string $stored, string $column): ?string + { + $permissions = \json_decode($stored, true); + + if (!\is_array($permissions)) { + return null; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + if (Permission::parse($permission)->getColumn() === $column) { + $changed = true; + continue; + } + + $rewritten[] = $permission; + } + + if (!$changed) { + return null; + } + + // Throwing rather than returning false: a row whose permissions will not encode + // is a broken row, and silently skipping it would leave its grants in place + // while the sweep reported success. + return \json_encode(\array_values(\array_unique($rewritten)), JSON_THROW_ON_ERROR); + } + + /** + * Re-read one row's permissions after a conditional update found it changed. + * + * @return string|null the stored JSON, or null when the row no longer exists + */ + private function currentPermissions(string $name, string $uid, string $tenantQuery): ?string + { + $select = $this->getPDO()->prepare(" + SELECT _permissions + FROM {$this->getSQLTable($name)} + WHERE _uid = :_uid + {$tenantQuery} + "); + $select->bindValue(':_uid', $uid); + if ($this->sharedTables) { + $select->bindValue(':_tenant', $this->tenant); + } + $this->execute($select); + + $row = $select->fetch(); + $select->closeCursor(); + + if ($row === false) { + return null; + } + + return $row['_permissions'] ?? '[]'; + } + + /** + * Bind a JSON value for comparison against the _permissions column. + * + * The comparison guards a read-modify-write, so it has to be byte-for-byte: two + * permission sets differing only in the case of a role are different permissions, + * and treating them as equal would let the rewrite undo a concurrent change. + * + * SQLite compares TEXT with BINARY collation already, so it takes the value as + * given. MariaDB and MySQL default to a case-insensitive collation and Postgres + * stores JSONB, which has no equality against text; both override this. + */ + protected function getJsonBind(string $placeholder): string + { + return $placeholder; + } + + /** + * Require read access to specific columns on every returned row. + * + * One EXISTS per column, ANDed: a row must grant every column the query reaches. + * A row granting none of them cannot match, so filtering or ordering on a column + * the caller may not read on that row reveals nothing at all -- as opposed to + * returning the row with the value masked out, which turns the predicate into an + * oracle for the hidden value. + * + * Correlated on _document, so it is driven by _index1, which leads with that + * column and contains every other predicate column. + * + * When it uses the same type and roles it also subsumes the row-level condition: + * any row satisfying _column IN ('', ) already satisfies the bare role + * match, so the caller may drop the row gate. + * + * @param string $collection + * @param array $columns + * @param array $roles + * @param string $alias + * @param string $type + * @return array + * @throws DatabaseException + */ + protected function getSQLColumnPermissionsConditions( + string $collection, + array $columns, + array $roles, + string $alias, + string $type = Database::PERMISSION_READ + ): array { + if (empty($columns) || empty($roles)) { + return []; + } + + if (!\in_array($type, Database::PERMISSIONS)) { + throw new DatabaseException('Unknown permission type: ' . $type); + } + + $quotedRoles = \implode(', ', \array_map(fn ($role) => $this->getPDO()->quote($role), $roles)); + $perms = $this->quote('_cp'); + + $conditions = []; + + foreach ($columns as $column) { + $quotedColumn = $this->getPDO()->quote($column); + + $conditions[] = "EXISTS ( + SELECT 1 + FROM {$this->getSQLTable($collection . '_perms')} AS {$perms} + WHERE {$perms}.{$this->quote('_document')} = {$this->quote($alias)}.{$this->quote('_uid')} + AND {$perms}.{$this->quote('_permission')} IN ({$quotedRoles}) + AND {$perms}.{$this->quote('_type')} = '{$type}' + AND {$perms}.{$this->quote('_column')} IN ('', {$quotedColumn}) + {$this->getTenantQuery($collection, '_cp')} + )"; + } + + return $conditions; + } + /** * Get SQL table * @@ -2512,11 +2969,14 @@ public function createDocuments(Document $collection, array $documents): array $batchKeys[] = '(' . \implode(', ', $bindKeys) . ')'; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; - $permission = \str_replace('"', '', $permission); - $permission = "('{$type}', '{$permission}', :_uid_{$index} {$tenantBind})"; - $permissions[] = $permission; + $role = \str_replace('"', '', $permission['role']); + + $columnBind = ":_column_{$type}_{$index}_{$i}"; + $bindValuesPermissions[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid_{$index} {$tenantBind})"; + $bindValuesPermissions[":_uid_{$index}"] = $document->getId(); if ($this->sharedTables) { $bindValuesPermissions[":_tenant_{$index}"] = $document->getTenant(); @@ -2544,7 +3004,7 @@ public function createDocuments(Document $collection, array $documents): array $permissions = \implode(', ', $permissions); $sqlPermissions = " - {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _document {$tenantColumn}) + {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) VALUES {$permissions} {$this->getInsertPermissionsSuffix()} "; @@ -2836,35 +3296,52 @@ public function upsertDocuments( $old = $change->getOld(); $document = $change->getNew(); + // Permissions are compared as role\0column, so a permission that only + // changes which column it is scoped to still registers as a change. + $flatten = fn (Document $doc, string $type): array => \array_map( + fn (array $permission) => $permission['role'] . "\0" . $permission['column'], + $doc->getPermissionsByTypeWithColumns($type) + ); + $current = []; + $desired = []; foreach (Database::PERMISSIONS as $type) { - $current[$type] = $old->getPermissionsByType($type); + $current[$type] = $flatten($old, $type); + $desired[$type] = $flatten($document, $type); } foreach (Database::PERMISSIONS as $type) { - $toRemove = \array_diff($current[$type], $document->getPermissionsByType($type)); + $toRemove = \array_diff($current[$type], $desired[$type]); if (!empty($toRemove)) { + $pairs = []; + foreach (\array_keys($toRemove) as $i) { + [$role, $column] = \explode("\0", $toRemove[$i], 2); + $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i} AND _column = :removecol_{$type}_{$index}_{$i})"; + $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; + + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $role; + } + $removeQueries[] = "( _document = :_uid_{$index} " . ($this->sharedTables ? " AND _tenant = :_tenant_{$index}" : '') . " AND _type = '{$type}' - AND _permission IN (" . \implode(',', \array_map(fn ($i) => ":remove_{$type}_{$index}_{$i}", \array_keys($toRemove))) . ") + AND (" . \implode(' OR ', $pairs) . ") )"; $removeBindValues[":_uid_{$index}"] = $document->getId(); if ($this->sharedTables) { $removeBindValues[":_tenant_{$index}"] = $document->getTenant(); } - foreach ($toRemove as $i => $perm) { - $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $perm; - } } } foreach (Database::PERMISSIONS as $type) { - $toAdd = \array_diff($document->getPermissionsByType($type), $current[$type]); + $toAdd = \array_diff($desired[$type], $current[$type]); foreach ($toAdd as $i => $permission) { - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + [$role, $column] = \explode("\0", $permission, 2); + + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}, :addcol_{$type}_{$index}_{$i}"; if ($this->sharedTables) { $addQuery .= ", :_tenant_{$index}"; @@ -2873,7 +3350,8 @@ public function upsertDocuments( $addQuery .= ")"; $addQueries[] = $addQuery; $addBindValues[":_uid_{$index}"] = $document->getId(); - $addBindValues[":add_{$type}_{$index}_{$i}"] = $permission; + $addBindValues[":add_{$type}_{$index}_{$i}"] = $role; + $addBindValues[":addcol_{$type}_{$index}_{$i}"] = $column; if ($this->sharedTables) { $addBindValues[":_tenant_{$index}"] = $document->getTenant(); @@ -2892,7 +3370,7 @@ public function upsertDocuments( } if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission"; + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column"; if ($this->sharedTables) { $sqlAddPermissions .= ", _tenant"; } @@ -2970,13 +3448,15 @@ protected function convertArrayToWKT(array $geometry): string * @param array $cursor * @param string $cursorDirection * @param string $forPermission + * @param array $columnPermissions columns that must be readable on the row * @return array * @throws DatabaseException * @throws TimeoutException * @throws Exception */ - public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ): array + public function find(Document $collection, array $queries = [], ?int $limit = 25, ?int $offset = null, array $orderAttributes = [], array $orderTypes = [], array $cursor = [], string $cursorDirection = Database::CURSOR_AFTER, string $forPermission = Database::PERMISSION_READ, array $columnPermissions = []): array { + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $name = $this->filter($collection); $roles = $this->authorization->getRoles(); @@ -3076,8 +3556,25 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $where[] = $conditions; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + // Deliberately outside the getStatus() guard below. That flag is also false + // when the caller holds a collection-level grant (Database wraps the call in + // authorization->skip()), and a column-scoped collection grant is exactly the + // case that needs column filtering. Database decides whether to pass any + // columns at all; an empty list produces no conditions. + $columnConditions = $this->getSQLColumnPermissionsConditions($name, $columnPermissions, $roles, $alias); + + // Any row satisfying _column IN ('', ) already satisfies the bare role + // match, so a column condition of the same type and roles makes the row + // condition redundant. Only true for reads: the row condition may be gated on + // a different permission (updateDocuments queries with forPermission=update). + $subsumesRowCondition = !empty($columnConditions) && $forPermission === Database::PERMISSION_READ; + + if ($this->authorization->getStatus() && !$subsumesRowCondition) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission, $columnSecurity); + } + + foreach ($columnConditions as $condition) { + $where[] = $condition; } if ($this->sharedTables) { @@ -3198,12 +3695,14 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 * @param Document $collection * @param array $queries * @param int|null $max + * @param array $columnPermissions columns that must be readable on the row * @return int * @throws Exception * @throws PDOException */ - public function count(Document $collection, array $queries = [], ?int $max = null): int + public function count(Document $collection, array $queries = [], ?int $max = null, array $columnPermissions = []): int { + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $name = $this->filter($collection); $roles = $this->authorization->getRoles(); @@ -3231,8 +3730,16 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $where[] = $conditions; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + // count() and sum() always gate on read, so a column condition here always + // subsumes the row condition -- see getSQLColumnPermissionsConditions(). + $columnConditions = $this->getSQLColumnPermissionsConditions($name, $columnPermissions, $roles, $alias); + + if ($this->authorization->getStatus() && empty($columnConditions)) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, Database::PERMISSION_READ, $columnSecurity); + } + + foreach ($columnConditions as $condition) { + $where[] = $condition; } if ($this->sharedTables) { @@ -3291,12 +3798,14 @@ public function count(Document $collection, array $queries = [], ?int $max = nul * @param string $attribute * @param array $queries * @param int|null $max + * @param array $columnPermissions columns that must be readable on the row * @return int|float * @throws Exception * @throws PDOException */ - public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null): int|float + public function sum(Document $collection, string $attribute, array $queries = [], ?int $max = null, array $columnPermissions = []): int|float { + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $name = $this->filter($collection); $attribute = $this->filter($attribute); @@ -3325,8 +3834,16 @@ public function sum(Document $collection, string $attribute, array $queries = [] $where[] = $conditions; } - if ($this->authorization->getStatus()) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + // count() and sum() always gate on read, so a column condition here always + // subsumes the row condition -- see getSQLColumnPermissionsConditions(). + $columnConditions = $this->getSQLColumnPermissionsConditions($name, $columnPermissions, $roles, $alias); + + if ($this->authorization->getStatus() && empty($columnConditions)) { + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, Database::PERMISSION_READ, $columnSecurity); + } + + foreach ($columnConditions as $condition) { + $where[] = $condition; } if ($this->sharedTables) { diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 3880aec167..0b6c1c8d57 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -36,6 +36,12 @@ */ class SQLite extends MariaDB { + /** + * SQLite spelt the legacy permissions index with a separator the other + * adapters never used, so the name the migration moves away from differs here. + */ + protected const PERMISSIONS_INDEX_LEGACY = '_index_1'; + /** Suffix appended to every FTS5 virtual table name created by this adapter. */ private const FTS_TABLE_SUFFIX = '_fts'; @@ -421,7 +427,9 @@ public function createCollection(string $name, array $attributes = [], array $in {$tenantQuery} `_type` VARCHAR(12) NOT NULL, `_permission` VARCHAR(255) NOT NULL, - `_document` VARCHAR(255) NOT NULL + `_column` VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', + `_document` VARCHAR(255) NOT NULL, + `_documentInternalId` BIGINT NOT NULL DEFAULT 0 ) "; @@ -440,7 +448,8 @@ public function createCollection(string $name, array $attributes = [], array $in $this->createIndex($id, '_created_at', Database::INDEX_KEY, [ '_createdAt'], [], []); $this->createIndex($id, '_updated_at', Database::INDEX_KEY, [ '_updatedAt'], [], []); - $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission'], [], []); + $this->createIndex("{$id}_perms", static::PERMISSIONS_INDEX, Database::INDEX_UNIQUE, ['_document', '_type', '_permission', '_column'], [], []); + $this->createIndex("{$id}_perms", static::PERMISSIONS_INDEX_DOCUMENT, Database::INDEX_KEY, ['_documentInternalId', '_type', '_permission', '_column'], [], []); $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission', '_type'], [], []); if ($this->sharedTables) { @@ -1206,11 +1215,14 @@ public function createDocument(Document $collection, Document $document): Docume } $permissions = []; + $permissionBinds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $permission) { - $permission = \str_replace('"', '', $permission); + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { + $role = \str_replace('"', '', $permission['role']); $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $permissions[] = "('{$type}', '{$permission}', '{$document->getId()}' {$tenantQuery})"; + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, '{$document->getId()}' {$tenantQuery})"; } } @@ -1218,13 +1230,17 @@ public function createDocument(Document $collection, Document $document): Docume $tenantQuery = $this->sharedTables ? ', _tenant' : ''; $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _document {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _column, _document {$tenantQuery}) VALUES " . \implode(', ', $permissions); $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); $stmtPermissions = $this->getPDO()->prepare($queryPermissions); + foreach ($permissionBinds as $key => $value) { + $stmtPermissions->bindValue($key, $value); + } + if ($this->sharedTables) { $stmtPermissions->bindValue(':_tenant', $this->tenant); } @@ -1295,10 +1311,12 @@ public function updateDocument(Document $collection, string $id, Document $docum $values = []; $binds = []; foreach (Database::PERMISSIONS as $type) { - foreach ($document->getPermissionsByType($type) as $i => $permission) { + foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; - $binds[":_add_{$type}_{$i}"] = $permission; + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantQuery})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + + $binds[":_add_{$type}_{$i}"] = $permission['role']; } } @@ -1306,7 +1324,7 @@ public function updateDocument(Document $collection, string $id, Document $docum $tenantQuery = $this->sharedTables ? ', _tenant' : ''; $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission, _column {$tenantQuery}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1513,6 +1531,22 @@ public function getSupportForGetConnectionId(): bool * * @return bool */ + /** + * Back to the plain bind, undoing MariaDB's cast. SQLite already compares TEXT + * byte-for-byte, and it has no BINARY type: CAST('["a"]' AS BINARY) takes NUMERIC + * affinity and evaluates to 0, so inheriting the cast would leave a comparison that + * never matches and a cleanup that silently stopped running. + */ + protected function getJsonBind(string $placeholder): string + { + return $placeholder; + } + + public function getSupportForColumnPermissions(): bool + { + return true; + } + public function getSupportForSchemaAttributes(): bool { return true; diff --git a/src/Database/Database.php b/src/Database/Database.php index 73dc419e48..488a6df38a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -118,6 +118,43 @@ class Database public const MAX_ARRAY_INDEX_LENGTH = 255; public const MAX_UID_DEFAULT_LENGTH = 36; + /** + * Longest column name a permission may be scoped to. + * + * Bounded by InnoDB's 3072-byte index limit rather than by anything about column + * names. _index1 already spends 2092 of it -- _document 1020, _permission 1020, + * _type 48, _tenant 4 -- leaving 980 bytes, which is 244 characters in utf8mb4. + * + * MySQL enforces that limit; MariaDB is more permissive, so a change here has to + * be checked against MySQL specifically. Columns may be named up to 255 + * characters, so a name between 245 and 255 cannot carry a column-scoped + * permission: the write is refused rather than silently truncated, and rather + * than indexed by prefix, where two names sharing 244 characters would be + * rejected as duplicates of each other. + */ + public const MAX_PERMISSION_COLUMN_LENGTH = 244; + + /** + * Key of an attribute's immutable identity. + * + * A column-scoped permission is stored against this, never against the attribute's + * key, so renaming a column moves no permissions at all and a column created later + * under a freed name inherits nothing. Assigned once at creation and never + * rewritten; the key changes around it. + * + * Callers never see it. Permission strings carry column keys in and out -- + * encode() resolves key to identity on the way to storage, decode() resolves it + * back -- so what a client writes is what it reads, and what travels between + * installs is a name rather than an id only one install can interpret. + * + * Named with the "$" that marks every other system field on a document ($id, + * $permissions, $createdAt). The rest of an attribute's keys -- key, type, size, + * required -- are the caller's to set, and an identity in that namespace would + * read as one of them and could be supplied by anyone building an attribute + * document by hand. + */ + public const ATTRIBUTE_INTERNAL_ID = '$internalId'; + // Maximum byte capacity for TEXT public const MAX_TEXT_BYTES = 65535; public const MAX_MEDIUMTEXT_BYTES = 16777215; @@ -371,6 +408,16 @@ class Database 'signed' => true, 'array' => false, 'filters' => [] + ], + [ + '$id' => 'columnSecurity', + 'key' => 'columnSecurity', + 'type' => self::VAR_BOOLEAN, + 'size' => 0, + 'required' => false, + 'signed' => true, + 'array' => false, + 'filters' => [] ] ], 'indexes' => [], @@ -441,6 +488,11 @@ class Database protected bool $inBatchRelationshipPopulation = false; + /** + * Suppresses column masking for reads the library performs on its own behalf. + */ + protected bool $skipColumnMasking = false; + protected bool $filter = true; /** @@ -1830,9 +1882,24 @@ public function delete(?string $database = null): bool * @throws DuplicateException * @throws LimitException */ - public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true, bool $columnSecurity = false): Document { + // Same precondition updateCollection() enforces, and for a sharper reason here: + // an adapter without column support still masks -- masking reads the flag alone + // -- while find(), count() and sum() skip the column gate, which they take from + // getSupportForColumnPermissions(). Storing the flag anyway would leave a + // collection where values are hidden from a read but a predicate still reveals + // them and a sum still adds them up, which is worse than not offering the + // feature at all. + if ($columnSecurity && !$this->adapter->getSupportForColumnPermissions()) { + throw new DatabaseException('Column security is not supported by this adapter'); + } + foreach ($attributes as &$attribute) { + // Documents the caller built, stored wholesale below, so identity is + // stamped here rather than relying on what arrived. + $attribute = $this->stampAttributeIdentity($attribute); + if (in_array($attribute['type'], self::ATTRIBUTE_FILTER_TYPES)) { $existingFilters = $attribute['filters'] ?? []; if (!is_array($existingFilters)) { @@ -1849,12 +1916,6 @@ public function createCollection(string $id, array $attributes = [], array $inde Permission::create(Role::any()), ]; - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } - } $collection = $this->silent(fn () => $this->getCollection($id)); @@ -1912,9 +1973,22 @@ public function createCollection(string $id, array $attributes = [], array $inde 'name' => $id, 'attributes' => $attributes, 'indexes' => $indexes, - 'documentSecurity' => $documentSecurity + 'documentSecurity' => $documentSecurity, + 'columnSecurity' => $columnSecurity ]); + // The flag guard runs first on purpose: with column security off, no permission + // here may name a column at all, and saying so is more use than reporting that + // the column is missing -- which invites creating it and trying again. + $this->assertColumnSecurityEnabled($collection, $permissions); + + if ($this->validate) { + $validator = new Permissions(columns: $this->getColumnKeys($collection)); + if (!$validator->isValid($permissions)) { + throw new DatabaseException($validator->getDescription()); + } + } + if ($this->validate) { $validator = new IndexValidator( $attributes, @@ -2038,26 +2112,27 @@ public function createCollection(string $id, array $attributes = [], array $inde * @param string $id * @param array $permissions * @param bool $documentSecurity + * @param bool $columnSecurity * * @return Document * @throws ConflictException * @throws DatabaseException */ - public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document + public function updateCollection(string $id, array $permissions, bool $documentSecurity, bool $columnSecurity): Document { - if ($this->validate) { - $validator = new Permissions(); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } - } - $collection = $this->silent(fn () => $this->getCollection($id)); if ($collection->isEmpty()) { throw new NotFoundException('Collection not found'); } + if ($this->validate) { + $validator = new Permissions(columns: $this->getColumnKeys($collection)); + if (!$validator->isValid($permissions)) { + throw new DatabaseException($validator->getDescription()); + } + } + if ( $this->adapter->getSharedTables() && $collection->getTenant() != $this->adapter->getTenant() @@ -2065,9 +2140,46 @@ public function updateCollection(string $id, array $permissions, bool $documentS throw new NotFoundException('Collection not found'); } + // Required, like $documentSecurity beside it: the value passed is the value + // stored, so a caller always states both flags rather than leaving one to be + // inferred. + // + // Validated against the flag this call is setting, not the one the collection + // currently carries: enabling and writing a column-scoped permission in the + // same call has to be accepted, and disabling in the same call as one has to be + // refused. + $this->assertColumnSecurityEnabled( + (new Document($collection->getArrayCopy()))->setAttribute('columnSecurity', $columnSecurity), + $permissions + ); + + // Only enabling has a precondition. Turning it off is always allowed, whatever + // the collection already holds: with the flag off the column half of a + // permission is inert everywhere -- masking, the query gate, count and sum all + // ignore it -- so read("role", "salary") grants what read("role") grants. + // Nothing is rewritten either, so the column stays in the stored permission and + // enabling again restores the exact restriction. + if ($columnSecurity && !$this->adapter->getSupportForColumnPermissions()) { + throw new DatabaseException('Column security is not supported by this adapter'); + } + + if ($columnSecurity) { + // Attributes created before identities existed have none, and a + // column-scoped grant cannot be stored against a column that has no + // identity. Turning the feature on is the moment they first matter, and it + // is already a write to this document -- so they are assigned here rather + // than by a migration that would have to walk every collection whether or + // not it ever uses column permissions. + $collection->setAttribute('attributes', \array_map( + fn (Document $attribute) => $this->stampAttributeIdentity($attribute), + $collection->getAttribute('attributes', []) + )); + } + $collection ->setAttribute('$permissions', $permissions) - ->setAttribute('documentSecurity', $documentSecurity); + ->setAttribute('documentSecurity', $documentSecurity) + ->setAttribute('columnSecurity', $columnSecurity); $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); @@ -2408,7 +2520,7 @@ public function createAttribute(string $collection, string $id, string $type, in } } - $collection->setAttribute('attributes', $attribute, Document::SET_TYPE_APPEND); + $collection->setAttribute('attributes', $this->stampAttributeIdentity($attribute), Document::SET_TYPE_APPEND); $this->updateMetadata( collection: $collection, @@ -2612,7 +2724,7 @@ public function createAttributes(string $collection, array $attributes): bool } foreach ($attributeDocuments as $attributeDocument) { - $collection->setAttribute('attributes', $attributeDocument, Document::SET_TYPE_APPEND); + $collection->setAttribute('attributes', $this->stampAttributeIdentity($attributeDocument), Document::SET_TYPE_APPEND); } $this->updateMetadata( @@ -3344,6 +3456,14 @@ public function updateAttribute(string $collection, string $id, ?string $type = if (!$updated) { throw new DatabaseException('Failed to update attribute'); } + + // Per-document grants need nothing: they are stored against the + // attribute's $internalId, which this method never rewrites. The + // collection's own grants are the exception -- they stay as keys, on one + // row, so they move here. Free, because that row is being written anyway. + if (!\is_null($newKey) && $newKey !== $id) { + $this->repointCollectionColumnPermissions($collectionDoc, $id, $newKey); + } } $collectionDoc->setAttribute('attributes', $attributes); @@ -3492,6 +3612,16 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } + // Hygiene rather than safety: the column is gone, so grants naming it confer + // nothing and a column created later under the same key gets a fresh + // $internalId that no stored grant can match. Clearing them keeps dead rows + // out of _perms rather than waiting for a read-modify-write to shed them. + // + // Two identifiers, deliberately. The collection's own grants are in memory and + // decoded, so they still speak in keys; the adapter works on storage, which + // holds identities. + $this->repointCollectionColumnPermissions($collection, $id, null); + $this->updateMetadata( collection: $collection, rollbackOperation: fn () => $this->adapter->createAttribute( @@ -3511,6 +3641,36 @@ public function deleteAttribute(string $collection, string $id): bool $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); + // Between the metadata write and this, a grant scoped only to the deleted column + // still matches the row gate, which authorizes by role and does not look at + // _column -- so count() reports a document whose every column the caller has just + // lost access to. Listeners are the one reader guaranteed to be in that window, + // so the rows go before they are notified. + // + // Not earlier than this, though. updateMetadata()'s rollback puts the physical + // column back and nothing puts these rows back, so purging before it would leave + // the column restored with every grant on it gone; and purging before the cache + // purge would let a failure leave readers a cached collection still listing the + // column. + // + // No per-document cache purge: purgeCachedCollection() above already listed and + // purged every document key under the collection. + $internalId = $attribute instanceof Document + ? $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID) + : null; + + $purgeFailure = null; + + if (\is_string($internalId) && $internalId !== '') { + try { + $this->withRetries(fn () => $this->adapter->deleteColumnPermissions($collection, $internalId)); + } catch (\Throwable $e) { + // Held, not swallowed. The deletion itself happened, so listeners are + // still told; the caller is told too, once they have been. + $purgeFailure = $e; + } + } + try { $this->trigger(self::EVENT_DOCUMENT_PURGE, new Document([ '$id' => $collection->getId(), @@ -3526,6 +3686,10 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } + if ($purgeFailure !== null) { + throw $purgeFailure; + } + return true; } @@ -3625,6 +3789,11 @@ public function renameAttribute(string $collection, string $old, string $new): b } } + // Per-document grants are untouched by a rename -- they are stored against + // the attribute's $internalId. Only the collection's own grants, which stay as + // keys, have to move, and they move in memory on the row about to be written. + $this->repointCollectionColumnPermissions($collection, $old, $new); + $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); @@ -3930,8 +4099,8 @@ public function createRelationship( // prior partial failure. Skip creation and proceed to metadata update. } - $collection->setAttribute('attributes', $relationship, Document::SET_TYPE_APPEND); - $relatedCollection->setAttribute('attributes', $twoWayRelationship, Document::SET_TYPE_APPEND); + $collection->setAttribute('attributes', $this->stampAttributeIdentity($relationship), Document::SET_TYPE_APPEND); + $relatedCollection->setAttribute('attributes', $this->stampAttributeIdentity($twoWayRelationship), Document::SET_TYPE_APPEND); $this->silent(function () use ($collection, $relatedCollection, $type, $twoWay, $id, $twoWayKey, $junctionCollection, $created) { $indexesCreated = []; @@ -5035,12 +5204,18 @@ public function getDocument(string $collection, string $id, array $queries = [], } } - $this->trigger(self::EVENT_DOCUMENT_READ, $document); - + // Before masking, as on the uncached path. isTtlExpired() reads the TTL + // attribute off the document, and masking can remove it -- a caller who + // cannot read that column would then see null, be told the document has + // not expired, and be handed an expired one from cache. if ($this->isTtlExpired($collection, $document)) { return $this->createDocumentInstance($collection->getId(), []); } + $document = $this->maskUnreadableColumns($collection, $document); + + $this->trigger(self::EVENT_DOCUMENT_READ, $document); + return $document; } @@ -5128,159 +5303,1047 @@ public function getDocument(string $collection, string $id, array $queries = [], } } + $document = $this->maskUnreadableColumns($collection, $document); + $this->trigger(self::EVENT_DOCUMENT_READ, $document); return $document; } - private function isTtlExpired(Document $collection, Document $document): bool - { - if (!$this->adapter->getSupportForTTLIndexes()) { - return false; + /** + * Columns the current roles hold the given permission on, or null when they + * hold it on every column. + * + * Resolved entirely from permissions that already travel with the document, + * so this costs no extra query. + * + * @param Document $collection + * @param Document $document + * @param string $type + * @return array|null + */ + private function getPermittedColumns( + Document $collection, + Document $document, + string $type + ): ?array { + if (!$this->authorization->getStatus()) { + return null; } - foreach ($collection->getAttribute('indexes', []) as $index) { - if ($index->getAttribute('type') !== self::INDEX_TTL) { + + // Column scoping is not in play on this collection, so the column half of a + // permission is inert -- read("role", "salary") grants what read("role") + // grants. Returning null here rather than a column list is what keeps masking + // in step with the query gate, which already drops _column when the flag is + // off. The stored permission keeps its column, so enabling the flag again + // restores the restriction exactly. + if (!$collection->getAttribute('columnSecurity', false)) { + return null; + } + + $permissions = $collection->getPermissionsByTypeWithColumns($type); + + if ($collection->getAttribute('documentSecurity', false)) { + $permissions = [ + ...$permissions, + ...$document->getPermissionsByTypeWithColumns($type), + ]; + } + + $columns = []; + + foreach ($permissions as $permission) { + if (!$this->authorization->hasRole($permission['role'])) { continue; } - $ttlSeconds = (int) $index->getAttribute('ttl', 0); - $ttlAttr = $index->getAttribute('attributes')[0] ?? null; - if ($ttlSeconds <= 0 || !$ttlAttr) { - return false; - } - $val = $document->getAttribute($ttlAttr); - if (is_string($val)) { - try { - $start = new \DateTime($val); - return (new \DateTime()) > (clone $start)->modify("+{$ttlSeconds} seconds"); - } catch (\Throwable) { - return false; - } + + // An unscoped permission covers every column, so there is nothing to scope. + if ($permission['column'] === Permission::COLUMN_ALL) { + return null; } + + $columns[$permission['column']] = true; } - return false; + + return \array_keys($columns); } /** - * Populate relationships for an array of documents with breadth-first traversal + * Rewrite a document's column-scoped permissions between key and identity. + * + * Storage holds the identity so that renaming a column moves nothing; callers + * only ever see the key. Permissions naming neither -- a grant whose column has + * since been deleted -- are dropped rather than passed through: the identity + * means nothing to a caller, and such a grant already confers nothing, so letting + * a read-modify-write shed it is how the leftovers of a dropped column disappear. * - * @param array $documents * @param Document $collection - * @param int $relationshipFetchDepth - * @param array> $selects - * @return array - * @throws DatabaseException + * @param Document $document + * @param bool $toIdentity true going to storage, false coming back + * @return void */ - private function populateDocumentsRelationships( - array $documents, - Document $collection, - int $relationshipFetchDepth = 0, - array $selects = [] - ): array { - // Prevent nested relationship population during fetches - $this->inBatchRelationshipPopulation = true; + private function translatePermissionColumns(Document $collection, Document $document, bool $toIdentity): void + { + if (!$document->offsetExists('$permissions')) { + return; + } - try { - $queue = [ - [ - 'documents' => $documents, - 'collection' => $collection, - 'depth' => $relationshipFetchDepth, - 'selects' => $selects, - 'skipKey' => null, // No back-reference to skip at top level - 'hasExplicitSelects' => !empty($selects) // Track if we're in explicit select mode - ] - ]; + // A collection's own grants are left as keys. They live on one row, are held + // in memory as keys between reads and writes, and are rewritten whenever the + // collection document is persisted -- so a rename would re-encode a grant + // naming a key that no longer exists. repointCollectionColumnPermissions() + // moves them instead, which costs nothing because that row is being written + // anyway. Per-document grants, the ones there can be millions of, carry the + // identity and so survive a rename untouched. + if ($collection->getId() === self::METADATA) { + return; + } - $currentDepth = $relationshipFetchDepth; + $map = []; - while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { - $nextQueue = []; + // Outbound, a value that is already an identity is left alone. updateDocuments() + // encodes $updates once up front and again per merged document, so this runs + // twice over the same permission; without this the second pass would look up an + // identity in a map keyed by column key, find nothing, and reject a grant the + // first pass had just produced. Keys are resolved first, so a column whose key + // happens to look like an identity still resolves as the key it is. + $identities = []; - foreach ($queue as $item) { - $docs = $item['documents']; - $coll = $item['collection']; - $sels = $item['selects']; - $skipKey = $item['skipKey'] ?? null; - $parentHasExplicitSelects = $item['hasExplicitSelects']; + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute['key'] ?? $attribute['$id'] ?? null; + $internalId = $attribute[self::ATTRIBUTE_INTERNAL_ID] ?? null; - if (empty($docs)) { - continue; - } + if (!\is_string($key) || !\is_string($internalId) || $key === '' || $internalId === '') { + continue; + } - $attributes = $coll->getAttribute('attributes', []); - $relationships = []; + $map[$toIdentity ? $key : $internalId] = $toIdentity ? $internalId : $key; - foreach ($attributes as $attribute) { - if ($attribute['type'] === Database::VAR_RELATIONSHIP) { - // Skip the back-reference relationship that brought us here - if ($attribute['key'] === $skipKey) { - continue; - } + if ($toIdentity) { + $identities[$internalId] = true; + } + } - // Include relationship if: - // 1. No explicit selects (fetch all) OR - // 2. Relationship is explicitly selected - if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { - $relationships[] = $attribute; - } - } - } + $translated = []; - foreach ($relationships as $relationship) { - $key = $relationship['key']; - $queries = $sels[$key] ?? []; - $relationship->setAttribute('collection', $coll->getId()); - $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; + foreach ($document->getPermissions() as $permission) { + $parsed = Permission::parse($permission); - // If we're at max depth, remove this relationship from source documents and skip - if ($isAtMaxDepth) { - foreach ($docs as $doc) { - $doc->removeAttribute($key); - } - continue; - } + if ($parsed->isForAllColumns()) { + $translated[] = $permission; + continue; + } - $relatedDocs = $this->populateSingleRelationshipBatch( - $docs, - $relationship, - $queries - ); + $column = $map[$parsed->getColumn()] ?? null; - // Get two-way relationship info - $twoWay = $relationship['options']['twoWay']; - $twoWayKey = $relationship['options']['twoWayKey']; + if ($column === null && isset($identities[$parsed->getColumn()])) { + $column = $parsed->getColumn(); + } - // Queue if: - // 1. No explicit selects (fetch all recursively), OR - // 2. Explicit nested selects for this relationship - $hasNestedSelectsForThisRel = isset($sels[$key]); - $shouldQueue = !empty($relatedDocs) && - ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); + if ($column === null) { + // Going out, this is a grant whose column has been dropped: it confers + // nothing, its identity means nothing to a caller, and letting a + // read-modify-write shed it is how those leftovers disappear. + if (!$toIdentity) { + continue; + } - if ($shouldQueue) { - $relatedCollectionId = $relationship['options']['relatedCollection']; - $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollectionId)); + // Coming in, it is a grant naming a column that does not exist. Storage + // is keyed by identity, so there is nothing to store it against, and + // silently dropping it would lose a permission the caller believes it + // set. + throw new DatabaseException( + 'Permission "' . $permission . '" is scoped to column "' + . $parsed->getColumn() . '", which does not exist on this collection.' + ); + } - if (!$relatedCollection->isEmpty()) { - // Get nested selections for this relationship - $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; + $translated[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $column + ))->toString(); + } - // Extract nested selections for the related collection - $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); - $relatedCollectionRelationships = \array_filter( - $relatedCollectionRelationships, - fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP - ); + $document->setAttribute('$permissions', \array_values(\array_unique($translated))); + } - $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); + /** + * The grants a related document may inherit from its parent. + * + * A column-scoped grant names a column of the parent's collection. The related + * collection does not have that column -- and may not permit column scoping at + * all -- so inheriting one either names a column that does not exist there or + * trips the column-security guard. Only the unscoped grants carry over; a related + * document that needs column scoping is given it explicitly. + * + * @param Document $document + * @return array + */ + private function inheritablePermissions(Document $document): array + { + return \array_values(\array_filter( + $document->getPermissions(), + fn (string $permission) => Permission::parse($permission)->isForAllColumns() + )); + } - // If parent has explicit selects, child inherits that mode - // (even if nextSelects is empty, we're still in explicit mode) - $childHasExplicitSelects = $parentHasExplicitSelects; + /** + * Resolve column keys to the identities storage holds them under. + * + * The gate compares against _column, which carries identities, while everything + * above it -- queries, the collection's own grants, error messages -- speaks in + * keys. This is the one place the two meet, so the keys survive as far as the + * adapter call and no further. + * + * @param Document $collection + * @param array $keys + * @return array + */ + private function columnIdentities(Document $collection, array $keys): array + { + if (empty($keys)) { + return []; + } - $nextQueue[] = [ - 'documents' => $relatedDocs, - 'collection' => $relatedCollection, + $map = []; + + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute['key'] ?? $attribute['$id'] ?? null; + $internalId = $attribute[self::ATTRIBUTE_INTERNAL_ID] ?? null; + + if (\is_string($key) && \is_string($internalId) && $key !== '' && $internalId !== '') { + $map[$key] = $internalId; + } + } + + $identities = []; + + foreach ($keys as $key) { + // A queried column with no identity cannot be matched by any stored grant, + // so it is passed through unchanged and the gate finds nothing -- which is + // the closed answer this should give. + $identities[] = $map[$key] ?? $key; + } + + return \array_values(\array_unique($identities)); + } + + /** + * Give an attribute its immutable identity, if it does not already have one. + * + * Every path that persists an attribute runs this: createAttribute() builds the + * document itself, while createAttributes(), createCollection() and + * createRelationship() take documents the caller built and cannot rely on them + * carrying an identity. An existing one is kept rather than replaced so that a + * migration can assign identities itself, and so that re-running one is harmless. + * + * @param Document $attribute + * @return Document + */ + private function stampAttributeIdentity(Document $attribute): Document + { + $internalId = $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID); + + if (!\is_string($internalId) || $internalId === '') { + $attribute->setAttribute(self::ATTRIBUTE_INTERNAL_ID, ID::unique()); + } + + return $attribute; + } + + /** + * Column keys defined on a collection. + * + * @param Document $collection + * @return array + */ + private function getColumnKeys(Document $collection): array + { + $keys = []; + + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute['key'] ?? $attribute['$id'] ?? null; + + if (\is_string($key) && $key !== '') { + $keys[] = $key; + } + } + + return $keys; + } + + /** + * Reject permissions scoped to a column on a collection that has not enabled it. + * + * The column part of a permission is only written and enforced when column + * security is on, so accepting one here would store a restriction that does not + * apply -- and that would start applying if the flag were later switched on. + * + * @param Document $collection + * @param array $permissions + * @return void + * @throws DatabaseException + */ + private function assertColumnSecurityEnabled(Document $collection, array $permissions): void + { + if ($collection->getAttribute('columnSecurity', false)) { + return; + } + + foreach ($permissions as $permission) { + $parsed = Permission::parse($permission); + + if (!$parsed->isForAllColumns()) { + throw new DatabaseException( + 'Permission "' . $permission . '" is scoped to a column, but column security is not enabled on this collection.' + ); + } + } + } + + /** + * Move or drop the collection's own column-scoped permissions. + * + * Collection-level grants live on the collection document in _metadata, not in + * the collection's _perms table, so the adapter's rename and delete do not reach + * them. Rewriting the document in place is free: updateMetadata() is about to + * persist it anyway. + * + * @param Document $collection + * @param string $old + * @param string|null $new new column key, or null to drop the permissions + * @return void + */ + private function repointCollectionColumnPermissions(Document $collection, string $old, ?string $new): void + { + $permissions = []; + $changed = false; + + foreach ($collection->getPermissions() as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->getColumn() !== $old) { + $permissions[] = $permission; + continue; + } + + $changed = true; + + if (\is_null($new)) { + continue; + } + + $permissions[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); + } + + if ($changed) { + $collection->setAttribute('$permissions', $permissions); + } + } + + /** + * Run a callback with column masking suppressed. + * + * Internal reads need the stored document, not the caller's view of it: they feed + * permission comparisons and merges, so a masked copy would make the library + * delete the columns and grants the caller was never shown. + * + * @template T + * @param callable(): T $callback + * @return T + */ + private function unmasked(callable $callback): mixed + { + $previous = $this->skipColumnMasking; + $this->skipColumnMasking = true; + + try { + return $callback(); + } finally { + $this->skipColumnMasking = $previous; + } + } + + /** + * The columns the current roles are demonstrably limited to at collection level, + * or null when no restriction can be proven. + * + * Returns null in two different situations, both meaning "do not restrict": + * an unscoped grant (every column is allowed), and no collection-level grant at + * all (access comes from per-document permissions, which cannot be bounded before + * the rows are read). A non-empty list means column-level permissions are + * demonstrably in play for this caller. + * + * @param Document $collection + * @param string $type + * @return array|null + */ + private function getCollectionColumnRestriction(Document $collection, string $type): ?array + { + $floor = $this->getCollectionColumnFloor($collection, $type); + + return ($floor === null || $floor === []) ? null : $floor; + } + + /** + * The columns the collection itself grants the current roles, precisely. + * + * Distinguishes the two cases getCollectionColumnRestriction() deliberately + * conflates: null means an unscoped grant covers every column, while an empty + * array means the collection grants nothing and readability can only be settled + * per row. + * + * @param Document $collection + * @param string $type + * @return array|null + */ + private function getCollectionColumnFloor(Document $collection, string $type): ?array + { + if (!$this->authorization->getStatus()) { + return null; + } + + $columns = []; + + foreach ($collection->getPermissionsByTypeWithColumns($type) as $permission) { + if (!$this->authorization->hasRole($permission['role'])) { + continue; + } + + if ($permission['column'] === Permission::COLUMN_ALL) { + return null; + } + + $columns[$permission['column']] = true; + } + + return \array_keys($columns); + } + + /** + * Column keys whose values a set of queries reads in order to decide the result. + * + * Filters and ordering qualify: both make the returned set depend on the value, + * which is what turns a hidden column into an oracle. Query::select() does not -- + * it only chooses a projection, and masking already removes from the response any + * column the caller cannot read, so gating it would drop rows for no benefit. + * + * @param array $queries + * @return array + */ + private function getQueriedColumns(array $queries): array + { + $columns = []; + + foreach ($queries as $query) { + if ($query->getMethod() === Query::TYPE_SELECT) { + continue; + } + + // Query::and()/or() carry their filters as nested Query objects and expose + // no attribute of their own, so reading only the outer node would let a + // filter on an unreadable column through and reopen the oracle the gate + // exists to close. + if (\in_array($query->getMethod(), [Query::TYPE_AND, Query::TYPE_OR], true)) { + $nested = \array_filter( + $query->getValues(), + fn ($value) => $value instanceof Query + ); + + foreach ($this->getQueriedColumns($nested) as $key) { + $columns[$key] = true; + } + + continue; + } + + $key = $query->getAttribute(); + + // Internal fields are skipped for two reasons. They cannot be named by a + // permission at all -- the key validator rejects '$'-prefixed columns -- + // so they are never in the floor, and gating them would turn every + // Query::equal('$id', ...) into a filter no permission row can satisfy. + // And they survive masking untouched, so filtering on one reveals nothing + // the caller could not already read off the row. + // + // $permissions is the exception to that second point, since masking does + // strip entries scoped to unreadable columns -- but the query validator + // rejects it as a filter attribute, so it cannot be reached from here. + // + // Dotted keys are relationship paths, not columns on this collection. + if ($key === '' || \str_starts_with($key, '$') || \str_contains($key, '.')) { + continue; + } + + $columns[$key] = true; + } + + return \array_keys($columns); + } + + /** + * Columns a query reaches whose readability the collection does not settle. + * + * These go to the adapter, which requires each of them on every returned row, so + * a filter or an order on a column the caller may not read on a given row cannot + * reveal anything about it. Columns the collection already grants are omitted -- + * they are readable on every visible row, so gating them would be a no-op. + * + * @param Document $collection + * @param array $queries + * @param string $type + * @return array + */ + private function getRestrictedQueryColumns(Document $collection, array $queries, string $type): array + { + if (!$this->authorization->getStatus()) { + return []; + } + + $floor = $this->getCollectionColumnFloor($collection, $type); + + if ($floor === null) { + return []; + } + + $restricted = []; + + foreach ($this->getQueriedColumns($queries) as $column) { + if (!\in_array($column, $floor, true)) { + $restricted[$column] = true; + } + } + + return \array_keys($restricted); + } + + /** + * Reject a query that reads a column the current roles are restricted from. + * + * Masking removes a column from the response, but a filter, an order or a select + * still reaches it: filtering on a hidden column turns the result set into an + * oracle for its value, and ordering by one reveals the ranking. A column the + * caller cannot read is treated as a column that does not exist for them, which + * is what query validation already does for unknown columns. + * + * @param Document $collection + * @param array $queries + * @param string $type + * @return void + * @throws AuthorizationException + */ + private function assertColumnsQueryable(Document $collection, array $queries, string $type = self::PERMISSION_READ): void + { + $restriction = $this->getCollectionColumnRestriction($collection, $type); + + if ($restriction === null) { + return; + } + + foreach ($this->getQueriedColumns($queries) as $key) { + if (!\in_array($key, $restriction, true)) { + throw new AuthorizationException('Missing "' . $type . '" permission for column "' . $key . '".'); + } + } + } + + /** + * Reduce a relationship value to what identifies it, for change detection. + * + * A relationship reads back as a Document, a list of Documents, an id, or a list + * of ids depending on how it was loaded, so the same link compares unequal to + * itself unless it is reduced to ids first. + * + * @param mixed $value + * @return mixed + */ + private static function relationshipIdentity(mixed $value): mixed + { + if ($value instanceof Document) { + return $value->getId(); + } + + if (\is_array($value)) { + return \array_map(fn ($item) => self::relationshipIdentity($item), $value); + } + + return $value; + } + + /** + * Reject a write that touches a column the current roles are restricted from at + * collection level. + * + * Used where no stored document is available to consult: a create (the row does + * not exist yet) and a bulk update (one set of changes applied to many rows). + * + * @param Document $collection + * @param Document $document + * @param string $type + * @return void + * @throws AuthorizationException + */ + private function assertColumnsAllowed(Document $collection, Document $document, string $type): void + { + $columns = $this->getCollectionColumnRestriction($collection, $type); + + if ($columns === null) { + return; + } + + $relationships = []; + foreach ($collection->getAttribute('attributes', []) as $attribute) { + if ($attribute['type'] === self::VAR_RELATIONSHIP) { + $relationships[$attribute['key']] = true; + } + } + + foreach ($document as $key => $value) { + if (\str_starts_with($key, '$')) { + continue; + } + + if (\is_null($value)) { + continue; + } + + // Relationships are attributes of the collection and are authorized as + // such. Exempting them let a caller granted one unrelated column supply a + // relationship value, which the relationship writer then persisted. + if (!\in_array($key, $columns, true)) { + throw new AuthorizationException('Missing "' . $type . '" permission for column "' . $key . '".'); + } + } + } + + /** + * Reject an update that changes a column the current roles cannot update. + * + * Returns immediately unless a column-scoped update permission is what + * granted this write, so the ordinary path pays only for resolving the + * permission list already loaded with the document. + * + * @param Document $collection + * @param Document $old stored document, whose permissions govern the write + * @param Document $document merged new state + * @return void + * @throws AuthorizationException + */ + private function assertColumnsWritable( + Document $collection, + Document $old, + Document $document + ): void { + $columns = $this->getPermittedColumns($collection, $old, self::PERMISSION_UPDATE); + + if ($columns === null) { + return; + } + + // Rewriting $permissions is a document-level right. A caller whose update + // access is limited to certain columns must not be able to grant itself more: + // otherwise "may update name" is enough to add read+update on every other + // column, which makes column-level permissions unenforceable. + if ($document->offsetExists('$permissions')) { + // Either side may arrive in either vocabulary -- the bulk path merges a + // decoded document with encoded updates -- so both are normalised to + // identities before comparing. The translation is idempotent, which is what + // makes normalising a side that is already normalised free. + $storedOld = clone $old; + $storedNew = clone $document; + $this->translatePermissionColumns($collection, $storedOld, true); + $this->translatePermissionColumns($collection, $storedNew, true); + + $before = $storedOld->getPermissions(); + $after = $storedNew->getPermissions(); + + \sort($before); + \sort($after); + + if ($before !== $after) { + throw new AuthorizationException( + 'Missing "update" permission to change $permissions: update access is limited to specific columns.' + ); + } + } + + $relationships = []; + foreach ($collection->getAttribute('attributes', []) as $attribute) { + if ($attribute['type'] === self::VAR_RELATIONSHIP) { + $relationships[$attribute['key']] = true; + } + } + + foreach ($document as $key => $value) { + // Internal fields are not columns; $permissions was handled above. + if (\str_starts_with($key, '$')) { + continue; + } + + if (\in_array($key, $columns, true)) { + continue; + } + + // Relationships included. A merged update carries every attribute, so the + // comparison decides -- and for a relationship it runs on identity, since + // the same link can arrive as a Document, an id, or a list of either. + if (isset($relationships[$key])) { + $changed = !self::valuesEqual( + self::relationshipIdentity($value), + self::relationshipIdentity($old->getAttribute($key)) + ); + + if ($changed) { + throw new AuthorizationException('Missing "update" permission for column "' . $key . '".'); + } + + continue; + } + + $changed = Operator::isOperator($value) || !self::valuesEqual($value, $old->getAttribute($key)); + + if ($changed) { + throw new AuthorizationException('Missing "update" permission for column "' . $key . '".'); + } + } + } + + /** + * Strip columns the current roles cannot read. + * + * Must run *after* the document has been written to cache. The cache is shared + * across roles, so caching a masked copy would serve one role's view to another. + * + * @param Document $collection + * @param Document $document + * @return Document + */ + private function maskUnreadableColumns(Document $collection, Document $document): Document + { + if ($this->skipColumnMasking || $document->isEmpty() || $collection->getId() === self::METADATA) { + return $document; + } + + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_READ); + + if ($columns === null) { + return $document; + } + + // Visibility and column access have to agree. A permission can name a column + // that no longer exists -- from a restore, or one written before the column was + // dropped -- leaving the caller able to read nothing while the row filter still + // matches on the role. Returning the document would then disclose its id and + // timestamps to someone entitled to none of its data. + if (empty(\array_intersect($columns, $this->getColumnKeys($collection)))) { + return $this->createDocumentInstance($collection->getId(), []); + } + + $document = clone $document; + + foreach (\array_keys($document->getArrayCopy()) as $key) { + // Internal fields ($id, $createdAt, $permissions, ...) are not columns. + if (\str_starts_with($key, '$')) { + continue; + } + + if (!\in_array($key, $columns, true)) { + $document->removeAttribute($key); + } + } + + // Permission strings name their column, so returning them whole would + // disclose the names of columns this caller cannot read. What is hidden here + // is restored by preserveHiddenPermissions() if the document is written back. + $permissions = []; + + foreach ($document->getPermissions() as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->isForAllColumns() || \in_array($parsed->getColumn(), $columns, true)) { + $permissions[] = $permission; + } + } + + $document->setAttribute('$permissions', $permissions); + + return $document; + } + + /** + * Column keys a caller supplied in a write payload. + * + * Operators are excluded: the caller supplied an instruction, not a value, so the + * computed result is something they do not already know. + * + * @param Document $payload raw payload, before encoding + * @return array + */ + private static function suppliedColumns(Document $payload): array + { + $keys = []; + + foreach ($payload->getArrayCopy() as $key => $value) { + if (\str_starts_with($key, '$') || Operator::isOperator($value)) { + continue; + } + + $keys[] = $key; + } + + return $keys; + } + + /** + * Mask a document being handed back from a write. + * + * Update access and read access are independent, so the merged document a write + * produces can hold columns the writer may not read -- returning it whole would + * make an update on one column a way to read the rest. + * + * What the caller supplied in this same call is exempt. They already have those + * values, so echoing them discloses nothing, and withholding them would make a + * successful write answer with less than it was given. + * + * The exempt keys are passed in rather than read off a payload, because by the + * time a write completes the payload has usually been encoded -- and encoding + * materialises every column of the collection, which would exempt the lot. + * + * @param Document $collection + * @param Document $document merged result of the write + * @param array $supplied column keys this caller provided + * @return Document + */ + private function maskWriteResponse(Document $collection, Document $document, array $supplied): Document + { + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_READ); + + if ($columns === null) { + return $document; + } + + $columns = \array_values(\array_unique([...$columns, ...$supplied])); + + $document = clone $document; + + foreach (\array_keys($document->getArrayCopy()) as $key) { + if (\str_starts_with($key, '$')) { + continue; + } + + if (!\in_array($key, $columns, true)) { + $document->removeAttribute($key); + } + } + + return $document; + } + + /** + * Put back the permissions a caller was never allowed to see. + * + * maskUnreadableColumns() strips permissions scoped to columns the caller cannot + * read, so a client that reads a document and writes it back would otherwise + * delete grants it never received. Runs before change detection, so echoing a + * masked document back is correctly seen as no permission change at all. + * + * @param Document $collection + * @param Document $old unmasked stored document + * @param Document $document incoming document + * @return void + */ + private function preserveHiddenPermissions( + Document $collection, + Document $old, + Document $document + ): void { + if (!$document->offsetExists('$permissions')) { + return; + } + + $columns = $this->getPermittedColumns($collection, $old, self::PERMISSION_READ); + + if ($columns === null) { + return; + } + + $hidden = []; + + foreach ($old->getPermissions() as $permission) { + $parsed = Permission::parse($permission); + + if (!$parsed->isForAllColumns() && !\in_array($parsed->getColumn(), $columns, true)) { + $hidden[] = $permission; + } + } + + if (empty($hidden)) { + return; + } + + $document->setAttribute('$permissions', \array_values(\array_unique([ + ...$document->getPermissions(), + ...$hidden, + ]))); + } + + private function isTtlExpired(Document $collection, Document $document): bool + { + if (!$this->adapter->getSupportForTTLIndexes()) { + return false; + } + foreach ($collection->getAttribute('indexes', []) as $index) { + if ($index->getAttribute('type') !== self::INDEX_TTL) { + continue; + } + $ttlSeconds = (int) $index->getAttribute('ttl', 0); + $ttlAttr = $index->getAttribute('attributes')[0] ?? null; + if ($ttlSeconds <= 0 || !$ttlAttr) { + return false; + } + $val = $document->getAttribute($ttlAttr); + if (is_string($val)) { + try { + $start = new \DateTime($val); + return (new \DateTime()) > (clone $start)->modify("+{$ttlSeconds} seconds"); + } catch (\Throwable) { + return false; + } + } + } + return false; + } + + /** + * Populate relationships for an array of documents with breadth-first traversal + * + * @param array $documents + * @param Document $collection + * @param int $relationshipFetchDepth + * @param array> $selects + * @return array + * @throws DatabaseException + */ + private function populateDocumentsRelationships( + array $documents, + Document $collection, + int $relationshipFetchDepth = 0, + array $selects = [] + ): array { + // Prevent nested relationship population during fetches + $this->inBatchRelationshipPopulation = true; + + try { + $queue = [ + [ + 'documents' => $documents, + 'collection' => $collection, + 'depth' => $relationshipFetchDepth, + 'selects' => $selects, + 'skipKey' => null, // No back-reference to skip at top level + 'hasExplicitSelects' => !empty($selects) // Track if we're in explicit select mode + ] + ]; + + $currentDepth = $relationshipFetchDepth; + + while (!empty($queue) && $currentDepth < self::RELATION_MAX_DEPTH) { + $nextQueue = []; + + foreach ($queue as $item) { + $docs = $item['documents']; + $coll = $item['collection']; + $sels = $item['selects']; + $skipKey = $item['skipKey'] ?? null; + $parentHasExplicitSelects = $item['hasExplicitSelects']; + + if (empty($docs)) { + continue; + } + + $attributes = $coll->getAttribute('attributes', []); + $relationships = []; + + foreach ($attributes as $attribute) { + if ($attribute['type'] === Database::VAR_RELATIONSHIP) { + // Skip the back-reference relationship that brought us here + if ($attribute['key'] === $skipKey) { + continue; + } + + // Include relationship if: + // 1. No explicit selects (fetch all) OR + // 2. Relationship is explicitly selected + if (!$parentHasExplicitSelects || \array_key_exists($attribute['key'], $sels)) { + $relationships[] = $attribute; + } + } + } + + foreach ($relationships as $relationship) { + $key = $relationship['key']; + $queries = $sels[$key] ?? []; + $relationship->setAttribute('collection', $coll->getId()); + $isAtMaxDepth = ($currentDepth + 1) >= self::RELATION_MAX_DEPTH; + + // If we're at max depth, remove this relationship from source documents and skip + if ($isAtMaxDepth) { + foreach ($docs as $doc) { + $doc->removeAttribute($key); + } + continue; + } + + $relatedDocs = $this->populateSingleRelationshipBatch( + $docs, + $relationship, + $queries + ); + + // Get two-way relationship info + $twoWay = $relationship['options']['twoWay']; + $twoWayKey = $relationship['options']['twoWayKey']; + + // Queue if: + // 1. No explicit selects (fetch all recursively), OR + // 2. Explicit nested selects for this relationship + $hasNestedSelectsForThisRel = isset($sels[$key]); + $shouldQueue = !empty($relatedDocs) && + ($hasNestedSelectsForThisRel || !$parentHasExplicitSelects); + + if ($shouldQueue) { + $relatedCollectionId = $relationship['options']['relatedCollection']; + $relatedCollection = $this->silent(fn () => $this->getCollection($relatedCollectionId)); + + if (!$relatedCollection->isEmpty()) { + // Get nested selections for this relationship + $relationshipQueries = $hasNestedSelectsForThisRel ? $sels[$key] : []; + + // Extract nested selections for the related collection + $relatedCollectionRelationships = $relatedCollection->getAttribute('attributes', []); + $relatedCollectionRelationships = \array_filter( + $relatedCollectionRelationships, + fn ($attr) => $attr['type'] === Database::VAR_RELATIONSHIP + ); + + $nextSelects = $this->processRelationshipQueries($relatedCollectionRelationships, $relationshipQueries); + + // If parent has explicit selects, child inherits that mode + // (even if nextSelects is empty, we're still in explicit mode) + $childHasExplicitSelects = $parentHasExplicitSelects; + + $nextQueue[] = [ + 'documents' => $relatedDocs, + 'collection' => $relatedCollection, 'depth' => $currentDepth + 1, 'selects' => $nextSelects, 'skipKey' => $twoWay ? $twoWayKey : null, // Skip the back-reference at next depth @@ -5791,6 +6854,9 @@ public function createDocument(string $collection, Document $document): Document if (!$isValid) { throw new AuthorizationException($this->authorization->getDescription()); } + + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); + $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); } $time = DateTime::now(); @@ -5821,15 +6887,24 @@ public function createDocument(string $collection, Document $document): Document } } - $document = $this->encode($collection, $document); - - if ($this->validate) { - $validator = new Permissions(); + // Before encode(), which swaps column keys for their identities. The caller + // wrote keys and any complaint has to name what the caller wrote. + // + // A metadata document is a collection, and its permissions name that collection's + // columns -- not the columns of _metadata, which is what $collection is here. They + // are judged against the right list by createCollection() and updateCollection(), + // which hold it; checking them again here would only ever compare them to the + // wrong schema. translatePermissionColumns() sits out the same write for the same + // reason. + if ($this->validate && $collection->getId() !== self::METADATA) { + $validator = new Permissions(columns: $this->getColumnKeys($collection)); if (!$validator->isValid($document->getPermissions())) { throw new DatabaseException($validator->getDescription()); } } + $document = $this->encode($collection, $document); + if ($this->validate) { $structure = new Structure( $collection, @@ -5913,6 +6988,11 @@ public function createDocuments( if (!$this->authorization->isValid(new Input(self::PERMISSION_CREATE, $collection->getCreate()))) { throw new AuthorizationException($this->authorization->getDescription()); } + + foreach ($documents as $document) { + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); + $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); + } } $time = DateTime::now(); @@ -6238,7 +7318,7 @@ private function relateDocuments( if ($related->isEmpty()) { // If the related document doesn't exist, create it, inheriting permissions if none are set if (!isset($relation['$permissions'])) { - $relation->setAttribute('$permissions', $document->getPermissions()); + $relation->setAttribute('$permissions', $this->inheritablePermissions($document)); } $related = $this->createDocument($relatedCollection->getId(), $relation); @@ -6353,6 +7433,10 @@ private function relateDocumentsById( */ public function updateDocument(string $collection, string $id, Document $document): Document { + // Held before the merge below replaces $document with the merged result. The + // write response exempts what the caller supplied, so it needs the original. + $supplied = new Document($document->getArrayCopy()); + if (!$id) { throw new DatabaseException('Must define $id attribute'); } @@ -6369,6 +7453,18 @@ public function updateDocument(string $collection, string $id, Document $documen return new Document(); } + $this->preserveHiddenPermissions($collection, $old, $document); + + // Every permission being written is checked, not only the ones this update + // introduces. A column-scoped grant on a collection with the flag off is a + // restriction that does not apply, and carrying it forward silently would + // let it start applying the day the flag went on. The caller resubmits the + // permission it actually means. Only writes that carry $permissions are + // affected -- an update that leaves them alone never reaches here. + if ($collection->getId() !== self::METADATA && $document->offsetExists('$permissions')) { + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); + } + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -6528,6 +7624,8 @@ public function updateDocument(string $collection, string $id, Document $documen if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, $updatePermissions))) { throw new AuthorizationException($this->authorization->getDescription()); } + + $this->assertColumnsWritable($collection, $old, $document); } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { throw new AuthorizationException($this->authorization->getDescription()); @@ -6563,7 +7661,7 @@ public function updateDocument(string $collection, string $id, Document $documen } if ($this->resolveRelationships) { - $document = $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document)); + $document = $this->unmasked(fn () => $this->silent(fn () => $this->updateDocumentRelationships($collection, $old, $document))); } $document = $this->adapter->castingBefore($collection, $document); @@ -6621,7 +7719,11 @@ public function updateDocument(string $collection, string $id, Document $documen $this->trigger(self::EVENT_DOCUMENT_UPDATE, $document); - return $document; + // Write scopes and read scopes are independent, so what the caller was + // allowed to change says nothing about what they may see. The merged document + // carries every stored column, and handing it back would let an update on one + // column return the rest. + return $this->maskWriteResponse($collection, $document, self::suppliedColumns($supplied)); } /** @@ -6670,6 +7772,12 @@ public function updateDocuments( throw new AuthorizationException($this->authorization->getDescription()); } + if ($collection->getId() !== self::METADATA) { + $this->assertColumnSecurityEnabled($collection, $updates->getPermissions()); + $this->assertColumnsAllowed($collection, $updates, self::PERMISSION_UPDATE); + $this->assertColumnsQueryable($collection, $queries); + } + $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -6758,11 +7866,11 @@ public function updateDocuments( $new[] = Query::cursorAfter($last); } - $batch = $this->silent(fn () => $this->find( + $batch = $this->unmasked(fn () => $this->silent(fn () => $this->find( $collection->getId(), array_merge($new, $queries), forPermission: Database::PERMISSION_UPDATE - )); + ))); if (empty($batch)) { break; @@ -6781,7 +7889,14 @@ public function updateDocuments( throw new QueryException('Permission document missing in select'); } - $originalPermissions = $document->getPermissions(); + // $updates was encoded up front so its grants carry identities, + // while $document came back from find() decoded, so its carry + // keys. Comparing the two as they stand never matches, and the + // rewrite this flag exists to avoid would always run. + $stored = clone $document; + $this->translatePermissionColumns($collection, $stored, true); + + $originalPermissions = $stored->getPermissions(); \sort($originalPermissions); @@ -6792,8 +7907,12 @@ public function updateDocuments( $new = new Document(\array_merge($document->getArrayCopy(), $updates->getArrayCopy())); + // Per document: the collection-level check cannot see grants that + // individual rows add, so each row is verified against its own. + $this->assertColumnsWritable($collection, $document, $new); + if ($this->resolveRelationships) { - $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new)); + $this->unmasked(fn () => $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new))); } $document = $new; @@ -6844,7 +7963,10 @@ public function updateDocuments( $doc = $this->decode($collection, $doc); } try { - $onNext && $onNext($doc, $old[$index]); + $onNext && $onNext( + $this->maskWriteResponse($collection, $doc, self::suppliedColumns($updates)), + $this->maskUnreadableColumns($collection, $old[$index]) + ); } catch (Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -7021,7 +8143,7 @@ private function updateDocumentRelationships(Document $collection, Document $old $this->relationshipWriteStack[] = $relatedCollection->getId(); if ($related->isEmpty()) { if (!isset($value['$permissions'])) { - $value->setAttribute('$permissions', $document->getAttribute('$permissions')); + $value->setAttribute('$permissions', $this->inheritablePermissions($document)); } $related = $this->createDocument( $relatedCollection->getId(), @@ -7110,7 +8232,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if ($related->isEmpty()) { if (!isset($relation['$permissions'])) { - $relation->setAttribute('$permissions', $document->getAttribute('$permissions')); + $relation->setAttribute('$permissions', $this->inheritablePermissions($document)); } $this->createDocument( $relatedCollection->getId(), @@ -7150,7 +8272,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if ($related->isEmpty()) { if (!isset($value['$permissions'])) { - $value->setAttribute('$permissions', $document->getAttribute('$permissions')); + $value->setAttribute('$permissions', $this->inheritablePermissions($document)); } $this->createDocument( $relatedCollection->getId(), @@ -7223,7 +8345,7 @@ private function updateDocumentRelationships(Document $collection, Document $old if ($related->isEmpty()) { if (!isset($value['$permissions'])) { - $relation->setAttribute('$permissions', $document->getAttribute('$permissions')); + $relation->setAttribute('$permissions', $this->inheritablePermissions($document)); } $related = $this->createDocument( $relatedCollection->getId(), @@ -7425,6 +8547,12 @@ public function upsertDocumentsWithIncrease( $collection = $this->silent(fn () => $this->getCollection($collection)); $documentSecurity = $collection->getAttribute('documentSecurity', false); $collectionAttributes = $collection->getAttribute('attributes', []); + + if ($collection->getId() !== self::METADATA) { + foreach ($documents as $document) { + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); + } + } $time = DateTime::now(); $created = 0; $updated = 0; @@ -7482,9 +8610,17 @@ public function upsertDocumentsWithIncrease( } } + $suppliedColumns = []; + foreach ($documents as $key => $document) { $old = $existingDocs[$this->tenantKey($document)] ?? new Document(); + // Captured here, before encoding materialises every column of the + // collection. Keyed by tenant identity, not id: the batches are re-indexed + // later, and in tenant-per-document mode one batch can carry the same id for + // two tenants, whose exemptions must not overwrite each other. + $suppliedColumns[$this->tenantKey($document)] = self::suppliedColumns($document); + $document = $this->removeUnknownAttributes($collection, $document); // Extract operators early to avoid comparison issues @@ -7500,6 +8636,13 @@ public function upsertDocumentsWithIncrease( $regularUpdatesUserOnly = \array_diff_key($regularUpdates, \array_flip($internalKeys)); + // Before the comparison, or a caller echoing back a document it read would + // delete the grants masking hid from it. assertColumnsWritable() does not + // cover this: it gates on update scope, while masking keys off read scope, + // so an unscoped update grant plus a column-scoped read grant passes that + // guard and still arrives with permissions missing. + $this->preserveHiddenPermissions($collection, $old, $document); + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -7565,11 +8708,17 @@ public function upsertDocumentsWithIncrease( if (!$this->authorization->isValid(new Input(self::PERMISSION_CREATE, $collection->getCreate()))) { throw new AuthorizationException($this->authorization->getDescription()); } - } elseif (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, [ - ...$collection->getUpdate(), - ...($documentSecurity ? $old->getUpdate() : []) - ]))) { - throw new AuthorizationException($this->authorization->getDescription()); + + $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); + } else { + if (!$this->authorization->isValid(new Input(self::PERMISSION_UPDATE, [ + ...$collection->getUpdate(), + ...($documentSecurity ? $old->getUpdate() : []) + ]))) { + throw new AuthorizationException($this->authorization->getDescription()); + } + + $this->assertColumnsWritable($collection, $old, $document); } $updatedAt = $document->getUpdatedAt(); @@ -7657,8 +8806,17 @@ public function upsertDocumentsWithIncrease( $old = $this->adapter->castingBefore($collection, $old); $document = $this->adapter->castingBefore($collection, $document); + // The adapter diffs the two halves of this Change to work out which + // permission rows to add and remove. "new" has been through encode(), so + // its column-scoped grants carry identities; "old" came back from find() + // and still carries keys. Comparing those two would find no overlap and + // re-insert every row on top of itself, so the old side is put into the + // same terms as the new one. + $stored = clone $old; + $this->translatePermissionColumns($collection, $stored, true); + $documents[$key] = new Change( - old: $old, + old: $stored, new: $document ); } @@ -7739,7 +8897,13 @@ public function upsertDocumentsWithIncrease( } try { - $onNext && $onNext($doc, $old->isEmpty() ? null : $old); + // The exemption source is what this caller supplied for this entry, + // not $doc. $doc is the adapter's merged result, so using it would + // exempt every stored column and mask nothing at all. + $onNext && $onNext( + $this->maskWriteResponse($collection, $doc, $suppliedColumns[$this->tenantKey($doc)] ?? []), + $old->isEmpty() ? null : $this->maskUnreadableColumns($collection, $old) + ); } catch (\Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -7822,6 +8986,14 @@ public function increaseDocumentAttribute( ]))) { throw new AuthorizationException($this->authorization->getDescription()); } + + // This writes one named column, so it needs update permission on that + // column specifically. Without this it bypasses the column gate. + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_UPDATE); + + if ($columns !== null && !\in_array($attribute, $columns, true)) { + throw new AuthorizationException('Missing "update" permission for column "' . $attribute . '".'); + } } if (!\is_null($max) && ($document->getAttribute($attribute) + $value > $max)) { @@ -7852,7 +9024,10 @@ public function increaseDocumentAttribute( $this->trigger(self::EVENT_DOCUMENT_INCREASE, $document); - return $document; + // Nothing is exempt here: the caller asked for an increment, not a value, so + // the result -- including the counter itself -- is something they only get to + // see if they may read it. + return $this->maskUnreadableColumns($collection, $document); } @@ -7923,6 +9098,14 @@ public function decreaseDocumentAttribute( ]))) { throw new AuthorizationException($this->authorization->getDescription()); } + + // This writes one named column, so it needs update permission on that + // column specifically. Without this it bypasses the column gate. + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_UPDATE); + + if ($columns !== null && !\in_array($attribute, $columns, true)) { + throw new AuthorizationException('Missing "update" permission for column "' . $attribute . '".'); + } } if (!\is_null($min) && ($document->getAttribute($attribute) - $value < $min)) { @@ -7953,7 +9136,7 @@ public function decreaseDocumentAttribute( $this->trigger(self::EVENT_DOCUMENT_DECREASE, $document); - return $document; + return $this->maskUnreadableColumns($collection, $document); } /** @@ -8057,10 +9240,10 @@ private function deleteDocumentRelationships(Document $collection, Document $doc switch ($onDelete) { case Database::RELATION_MUTATE_RESTRICT: - $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side); + $this->unmasked(fn () => $this->deleteRestrict($relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side)); break; case Database::RELATION_MUTATE_SET_NULL: - $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side); + $this->unmasked(fn () => $this->deleteSetNull($collection, $relatedCollection, $document, $relationType, $twoWay, $twoWayKey, $side)); break; case Database::RELATION_MUTATE_CASCADE: foreach ($this->relationshipDeleteStack as $processedRelationship) { @@ -8107,7 +9290,7 @@ private function deleteDocumentRelationships(Document $collection, Document $doc break 2; } } - $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship); + $this->unmasked(fn () => $this->deleteCascade($collection, $relatedCollection, $document, $key, $value, $relationType, $twoWayKey, $side, $relationship)); break; } } @@ -8510,11 +9693,11 @@ public function deleteDocuments( /** * @var array $batch */ - $batch = $this->silent(fn () => $this->find( + $batch = $this->unmasked(fn () => $this->silent(fn () => $this->find( $collection->getId(), array_merge($new, $queries), forPermission: Database::PERMISSION_DELETE - )); + ))); if (empty($batch)) { break; @@ -8560,7 +9743,10 @@ public function deleteDocuments( foreach ($batch as $index => $document) { $this->withDocumentTenant($document, fn () => $this->purgeCachedDocument($collection->getId(), $document->getId())); try { - $onNext && $onNext($document, $old[$index]); + $onNext && $onNext( + $this->maskUnreadableColumns($collection, $document), + $this->maskUnreadableColumns($collection, $old[$index]) + ); } catch (Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -8724,6 +9910,25 @@ public function find(string $collection, array $queries = [], string $forPermiss throw new AuthorizationException($this->authorization->getDescription()); } + // Adapters that enforce the gate get the column list and settle it per row. + // The rest keep the conservative refusal, which is all they can do. + $columnPermissions = []; + + if ($collection->getId() !== self::METADATA) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { + $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); + + // With documentSecurity off, collection permissions are the whole + // story, so a column outside the floor is unreadable on every row. + // An error is more use to the caller than an empty result. + if (!empty($columnPermissions) && !$documentSecurity) { + throw new AuthorizationException('Missing "read" permission for column "' . $columnPermissions[0] . '".'); + } + } else { + $this->assertColumnsQueryable($collection, $queries, $forPermission); + } + } + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP @@ -8821,7 +10026,8 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderTypes, $cursor, $cursorDirection, - $forPermission + $forPermission, + $this->columnIdentities($collection, $columnPermissions) ); $results = $skipAuth ? $this->authorization->skip($getResults) : $getResults(); @@ -8847,9 +10053,25 @@ public function find(string $collection, array $queries = [], string $forPermiss $node->setAttribute('$collection', $collection->getId()); } + // Not gated on $skipAuth: that flag only means the caller may see every + // ROW (it is set by any collection-level read, including a column-scoped + // one), so it says nothing about which columns are readable. Masking is + // already a no-op when authorization is disabled. + $node = $this->maskUnreadableColumns($collection, $node); + + // Masking can empty a document the row filter let through -- see + // maskUnreadableColumns(). Drop it rather than return a husk of internal + // fields. + if ($node->isEmpty()) { + unset($results[$index]); + continue; + } + $results[$index] = $node; } + $results = \array_values($results); + $this->trigger(self::EVENT_DOCUMENT_FIND, $results); return $results; @@ -9241,6 +10463,25 @@ public function count(string $collection, array $queries = [], ?int $max = null) throw new AuthorizationException($this->authorization->getDescription()); } + // Adapters that enforce the gate get the column list and settle it per row. + // The rest keep the conservative refusal, which is all they can do. + $columnPermissions = []; + + if ($collection->getId() !== self::METADATA) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { + $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); + + // With documentSecurity off, collection permissions are the whole + // story, so a column outside the floor is unreadable on every row. + // An error is more use to the caller than an empty result. + if (!empty($columnPermissions) && !$documentSecurity) { + throw new AuthorizationException('Missing "read" permission for column "' . $columnPermissions[0] . '".'); + } + } else { + $this->assertColumnsQueryable($collection, $queries); + } + } + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP @@ -9257,7 +10498,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = $queriesOrNull; - $getCount = fn () => $this->adapter->count($collection, $queries, $max); + $getCount = fn () => $this->adapter->count($collection, $queries, $max, $this->columnIdentities($collection, $columnPermissions)); $count = $skipAuth ? $this->authorization->skip($getCount) : $getCount(); $this->trigger(self::EVENT_DOCUMENT_COUNT, $count); @@ -9315,6 +10556,47 @@ public function sum(string $collection, string $attribute, array $queries = [], throw new AuthorizationException($this->authorization->getDescription()); } + // Adapters that enforce the gate get the column list and settle it per row. + // The rest keep the conservative refusal, which is all they can do. + $columnPermissions = []; + + if ($collection->getId() !== self::METADATA) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { + $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); + + // With documentSecurity off, collection permissions are the whole + // story, so a column outside the floor is unreadable on every row. + // An error is more use to the caller than an empty result. + if (!empty($columnPermissions) && !$documentSecurity) { + throw new AuthorizationException('Missing "read" permission for column "' . $columnPermissions[0] . '".'); + } + } else { + $this->assertColumnsQueryable($collection, $queries); + } + } + + // The aggregated column is read too, so it joins the gate. Rows that do not + // grant it simply do not contribute, giving a partial sum over exactly the + // rows the caller could have read one at a time. + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { + $floor = $this->getCollectionColumnFloor($collection, self::PERMISSION_READ); + + if ($floor !== null && !\in_array($attribute, $floor, true)) { + if (!$documentSecurity) { + throw new AuthorizationException('Missing "read" permission for column "' . $attribute . '".'); + } + + $columnPermissions[] = $attribute; + $columnPermissions = \array_values(\array_unique($columnPermissions)); + } + } else { + $columns = $this->getCollectionColumnRestriction($collection, self::PERMISSION_READ); + + if ($columns !== null && !\in_array($attribute, $columns, true)) { + throw new AuthorizationException('Missing "read" permission for column "' . $attribute . '".'); + } + } + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP @@ -9330,7 +10612,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = $queriesOrNull; - $getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max); + $getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max, $this->columnIdentities($collection, $columnPermissions)); $sum = $skipAuth ? $this->authorization->skip($getSum) : $getSum(); $this->trigger(self::EVENT_DOCUMENT_SUM, $sum); @@ -9424,6 +10706,13 @@ protected function removeUnknownAttributes(Document $collection, Document $docum */ public function encode(Document $collection, Document $document, bool $applyDefaults = true): Document { + // The mirror of decode(): a caller writes column keys, storage keeps + // identities, so a rename later moves the column and leaves every permission + // where it is. Runs before the filters below, which are what turn a _metadata + // row's attribute list into JSON -- once that has happened there is nothing + // left to resolve a key against. + $this->translatePermissionColumns($collection, $document, true); + $attributes = $collection->getAttribute('attributes', []); $internalDateAttributes = ['$createdAt', '$updatedAt']; foreach ($this->getInternalAttributes() as $attribute) { @@ -9613,6 +10902,12 @@ public function decode(Document $collection, Document $document, array $selectio } } } + + // Storage holds each column-scoped permission against the attribute's + // identity; callers see the key. Every path that hands a document back runs + // through here, so this is the only place the swap has to happen. + $this->translatePermissionColumns($collection, $document, false); + return $document; } diff --git a/src/Database/Document.php b/src/Database/Document.php index e684956135..6833f70b20 100644 --- a/src/Database/Document.php +++ b/src/Database/Document.php @@ -5,6 +5,7 @@ use ArrayObject; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Exception\Structure as StructureException; +use Utopia\Database\Helpers\Permission; /** * @extends ArrayObject @@ -154,14 +155,46 @@ public function getPermissionsByType(string $type): array { $typePermissions = []; + foreach ($this->getPermissionsByTypeWithColumns($type) as $permission) { + $typePermissions[] = $permission['role']; + } + + return \array_unique($typePermissions); + } + + /** + * Permissions of the given type, split into the role and the column it is scoped to. + * + * A column of Permission::COLUMN_ALL means the role is granted every column, + * which is how every permission written before column-level permissions reads. + * + * @param string $type + * @return array + */ + public function getPermissionsByTypeWithColumns(string $type): array + { + $typePermissions = []; + foreach ($this->getPermissions() as $permission) { if (!\str_starts_with($permission, $type)) { continue; } - $typePermissions[] = \str_replace([$type . '(', ')', '"', ' '], '', $permission); + + $column = Permission::COLUMN_ALL; + + // Peel off an optional second argument: type("role", "column"). + if (\preg_match('/^(.*?)\s*,\s*"([^"]*)"\)$/', $permission, $matches) === 1) { + $permission = $matches[1] . ')'; + $column = $matches[2]; + } + + $typePermissions[] = [ + 'role' => \str_replace([$type . '(', ')', '"', ' '], '', $permission), + 'column' => $column, + ]; } - return \array_unique($typePermissions); + return $typePermissions; } /** diff --git a/src/Database/Helpers/Permission.php b/src/Database/Helpers/Permission.php index 18c4fe5a94..7801f922a3 100644 --- a/src/Database/Helpers/Permission.php +++ b/src/Database/Helpers/Permission.php @@ -8,6 +8,15 @@ class Permission { + /** + * Sentinel column value meaning "every column". + * + * Stored as an empty string rather than NULL: MySQL and MariaDB treat NULLs + * as distinct in a UNIQUE index, so a nullable _column would let duplicate + * permission rows through the _perms uniqueness guarantee. + */ + public const COLUMN_ALL = ''; + private Role $role; /** @@ -26,6 +35,7 @@ public function __construct( string $role, string $identifier = '', string $dimension = '', + private string $column = self::COLUMN_ALL, ) { $this->role = new Role($role, $identifier, $dimension); } @@ -37,7 +47,31 @@ public function __construct( */ public function toString(): string { - return $this->permission . '("' . $this->role->toString() . '")'; + $permission = $this->permission . '("' . $this->role->toString() . '"'; + + if ($this->column !== self::COLUMN_ALL) { + $permission .= ', "' . $this->column . '"'; + } + + return $permission . ')'; + } + + /** + * The column this permission is scoped to, or COLUMN_ALL for every column. + * + * @return string + */ + public function getColumn(): string + { + return $this->column; + } + + /** + * @return bool + */ + public function isForAllColumns(): bool + { + return $this->column === self::COLUMN_ALL; } /** @@ -82,6 +116,24 @@ public function getDimension(): string */ public static function parse(string $permission): self { + $column = self::COLUMN_ALL; + + // Peel off an optional second argument: type("role", "column"). + // Role identifiers and dimensions never contain a comma, so the lazy + // match cannot swallow part of the role. + if (\preg_match('/^(.*?)\s*,\s*"([^"]*)"\)$/', $permission, $matches) === 1) { + $permission = $matches[1] . ')'; + $column = $matches[2]; + + if ($column === self::COLUMN_ALL) { + throw new DatabaseException('Column must not be empty. Omit the argument to grant every column.'); + } + + if ($column === '*') { + throw new DatabaseException('Wildcard column "*" is not supported. Omit the argument to grant every column.'); + } + } + $permissionParts = \explode('("', $permission); if (\count($permissionParts) !== 2) { @@ -101,12 +153,12 @@ 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) { @@ -121,7 +173,7 @@ public static function parse(string $permission): self if (empty($dimension)) { 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 @@ -137,7 +189,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); } /** @@ -169,7 +221,8 @@ public static function aggregate(?array $permissions, array $allowed = Database: $subType, $permission->getRole(), $permission->getIdentifier(), - $permission->getDimension() + $permission->getDimension(), + $permission->getColumn() ))->toString(); } } @@ -181,15 +234,17 @@ public static function aggregate(?array $permissions, array $allowed = Database: * Create a read permission string from the given Role * * @param Role $role + * @param string $column Restrict to a single column, or COLUMN_ALL for every column * @return string */ - public static function read(Role $role): string + public static function read(Role $role, string $column = self::COLUMN_ALL): string { $permission = new self( 'read', $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); } @@ -198,15 +253,17 @@ public static function read(Role $role): string * Create a create permission string from the given Role * * @param Role $role + * @param string $column Restrict to a single column, or COLUMN_ALL for every column * @return string */ - public static function create(Role $role): string + public static function create(Role $role, string $column = self::COLUMN_ALL): string { $permission = new self( 'create', $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); } @@ -215,15 +272,17 @@ public static function create(Role $role): string * Create an update permission string from the given Role * * @param Role $role + * @param string $column Restrict to a single column, or COLUMN_ALL for every column * @return string */ - public static function update(Role $role): string + public static function update(Role $role, string $column = self::COLUMN_ALL): string { $permission = new self( 'update', $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); } @@ -232,15 +291,17 @@ public static function update(Role $role): string * Create a delete permission string from the given Role * * @param Role $role + * @param string $column Restrict to a single column, or COLUMN_ALL for every column * @return string */ - public static function delete(Role $role): string + public static function delete(Role $role, string $column = self::COLUMN_ALL): string { $permission = new self( 'delete', $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); } @@ -249,15 +310,17 @@ public static function delete(Role $role): string * Create a write permission string from the given Role * * @param Role $role + * @param string $column Restrict to a single column, or COLUMN_ALL for every column * @return string */ - public static function write(Role $role): string + public static function write(Role $role, string $column = self::COLUMN_ALL): string { $permission = new self( 'write', $role->getRole(), $role->getIdentifier(), - $role->getDimension() + $role->getDimension(), + $column ); return $permission->toString(); } diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..e386b261da 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -212,14 +212,15 @@ public function delete(?string $database = null): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true): Document + public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true, bool $columnSecurity = false): Document { $result = $this->source->createCollection( $id, $attributes, $indexes, $permissions, - $documentSecurity + $documentSecurity, + $columnSecurity ); if ($this->destination === null) { @@ -241,7 +242,8 @@ public function createCollection(string $id, array $attributes = [], array $inde $attributes, $indexes, $permissions, - $documentSecurity + $documentSecurity, + $columnSecurity ); $this->silent(function () use ($id) { @@ -259,9 +261,9 @@ public function createCollection(string $id, array $attributes = [], array $inde return $result; } - public function updateCollection(string $id, array $permissions, bool $documentSecurity): Document + public function updateCollection(string $id, array $permissions, bool $documentSecurity, bool $columnSecurity): Document { - $result = $this->source->updateCollection($id, $permissions, $documentSecurity); + $result = $this->source->updateCollection($id, $permissions, $documentSecurity, $columnSecurity); if ($this->destination === null) { return $result; @@ -277,7 +279,7 @@ public function updateCollection(string $id, array $permissions, bool $documentS ); } - $this->destination->updateCollection($id, $permissions, $documentSecurity); + $this->destination->updateCollection($id, $permissions, $documentSecurity, $columnSecurity); } catch (\Throwable $err) { $this->logError('updateCollection', $err); } diff --git a/src/Database/Validator/Permissions.php b/src/Database/Validator/Permissions.php index 13e7372050..58f8d0b3cc 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -16,16 +16,30 @@ class Permissions extends Roles protected int $length; + /** + * @var array + */ + protected array $columns; + + protected Key $key; + /** * Permissions constructor. * * @param int $length maximum amount of permissions. 0 means unlimited. * @param array $allowed allowed permissions. Defaults to all available. + * @param array $columns the collection's column keys. A permission may only name + * one of these, so the default of none rejects every column-scoped permission. + * There is no way to waive the check: a caller that cannot name the columns is a + * caller with no collection in scope, and it has no business judging a grant + * against one. */ - public function __construct(int $length = 0, array $allowed = [...Database::PERMISSIONS, Database::PERMISSION_WRITE]) + public function __construct(int $length = 0, array $allowed = [...Database::PERMISSIONS, Database::PERMISSION_WRITE], array $columns = []) { $this->length = $length; $this->allowed = $allowed; + $this->columns = $columns; + $this->key = new Key(maxLength: Database::MAX_PERMISSION_COLUMN_LENGTH); } /** @@ -96,6 +110,29 @@ public function isValid($permissions): bool return false; } + $column = $permission->getColumn(); + + if ($column !== Permission::COLUMN_ALL) { + $type = $permission->getPermission(); + + // Delete removes the whole row, so scoping it to one column is + // meaningless. Write implies delete, so it inherits the same rule. + if (\in_array($type, [Database::PERMISSION_DELETE, Database::PERMISSION_WRITE], true)) { + $this->message = 'Permission "' . $type . '" cannot be scoped to a column, it applies to the whole row.'; + return false; + } + + if (!$this->key->isValid($column)) { + $this->message = 'Column "' . $column . '" is not a valid column key.'; + return false; + } + + if (!\in_array($column, $this->columns, true)) { + $this->message = 'Column "' . $column . '" does not exist.'; + return false; + } + } + $role = $permission->getRole(); $identifier = $permission->getIdentifier(); $dimension = $permission->getDimension(); diff --git a/tests/e2e/Adapter/MirrorTest.php b/tests/e2e/Adapter/MirrorTest.php index de73d7be86..b84586e5c4 100644 --- a/tests/e2e/Adapter/MirrorTest.php +++ b/tests/e2e/Adapter/MirrorTest.php @@ -166,7 +166,8 @@ public function testUpdateMirroredCollection(): void [ Permission::read(Role::users()), ], - $collection->getAttribute('documentSecurity') + $collection->getAttribute('documentSecurity'), + $collection->getAttribute('columnSecurity', false) ); // Asset both databases have updated the collection diff --git a/tests/e2e/Adapter/Scopes/CollectionTests.php b/tests/e2e/Adapter/Scopes/CollectionTests.php index bcbfbe91af..20ee66b791 100644 --- a/tests/e2e/Adapter/Scopes/CollectionTests.php +++ b/tests/e2e/Adapter/Scopes/CollectionTests.php @@ -758,7 +758,7 @@ public function testCollectionUpdate(): Document $this->assertIsArray($collection->getPermissions()); $this->assertCount(4, $collection->getPermissions()); - $collection = $database->updateCollection('collectionUpdate', [], true); + $collection = $database->updateCollection('collectionUpdate', [], true, false); $this->assertTrue($collection->getAttribute('documentSecurity')); $this->assertIsArray($collection->getPermissions()); @@ -786,7 +786,7 @@ public function testUpdateDeleteCollectionNotFound(): void } try { - $database->updateCollection('not_found', [], true); + $database->updateCollection('not_found', [], true, false); $this->fail('Failed to throw exception'); } catch (Exception $e) { $this->assertEquals('Collection not found', $e->getMessage()); diff --git a/tests/e2e/Adapter/Scopes/DocumentTests.php b/tests/e2e/Adapter/Scopes/DocumentTests.php index 2b6d378222..9a1439ae09 100644 --- a/tests/e2e/Adapter/Scopes/DocumentTests.php +++ b/tests/e2e/Adapter/Scopes/DocumentTests.php @@ -5774,7 +5774,7 @@ public function testUpdateDocuments(): void Permission::create(Role::user('asd')), Permission::update(Role::user('asd')), Permission::delete(Role::user('asd')), - ], documentSecurity: false); + ], documentSecurity: false, columnSecurity: false); try { $database->updateDocuments($collection, new Document([ @@ -5786,7 +5786,7 @@ public function testUpdateDocuments(): void } // Check document level permissions - $database->updateCollection($collection, permissions: [], documentSecurity: true); + $database->updateCollection($collection, permissions: [], documentSecurity: true, columnSecurity: false); $this->getDatabase()->getAuthorization()->skip(function () use ($collection, $database) { $database->updateDocument($collection, 'doc0', new Document([ @@ -6353,7 +6353,7 @@ public function testDeleteBulkDocuments(): void } // TEST (FAIL): Bulk delete all documents with invalid collection permission - $database->updateCollection('bulk_delete', [], false); + $database->updateCollection('bulk_delete', [], false, false); try { $database->deleteDocuments('bulk_delete'); $this->fail('Bulk deleted documents with invalid collection permission'); @@ -6364,7 +6364,7 @@ public function testDeleteBulkDocuments(): void Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any()) - ], false); + ], false, false); $this->assertEquals(5, $database->deleteDocuments('bulk_delete')); $this->assertEquals(0, \count($this->getDatabase()->find('bulk_delete'))); @@ -6372,7 +6372,7 @@ public function testDeleteBulkDocuments(): void // TEST: Make sure we can't delete documents we don't have permissions for $database->updateCollection('bulk_delete', [ Permission::create(Role::any()), - ], true); + ], true, false); $this->propagateBulkDocuments('bulk_delete', documentSecurity: true); $this->assertEquals(0, $database->deleteDocuments('bulk_delete')); @@ -6387,7 +6387,7 @@ public function testDeleteBulkDocuments(): void Permission::create(Role::any()), Permission::read(Role::any()), Permission::delete(Role::any()) - ], false); + ], false, false); $database->deleteDocuments('bulk_delete'); diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 97e55633fc..4daa97f1db 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -15,6 +15,145 @@ trait PermissionTests { + /** + * A write response may show what the caller just wrote and what they may read -- + * nothing else. Upsert is the path that got this wrong: the callback receives the + * adapter's merged result, so using that as the exemption source exempted every + * stored column and masked nothing. + */ + public function testUpsertCallbackDoesNotExposeUnreadableColumns(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('upsertMask', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('upsertMask', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('upsertMask', 'email', Database::VAR_STRING, 64, false); + $database->createAttribute('upsertMask', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('upsertMask', new Document([ + '$id' => ID::custom('u1'), + '$permissions' => [ + Permission::update(Role::user('ed'), 'name'), + Permission::read(Role::user('ed'), 'email'), + ], + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'salary' => 100000, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:ed'); + + $seen = []; + + try { + $database->upsertDocuments( + 'upsertMask', + [new Document(['$id' => ID::custom('u1'), 'name' => 'Robert'])], + 100, + onNext: function (Document $document) use (&$seen) { + $seen[] = \array_keys(\array_filter( + $document->getArrayCopy(), + fn (string $key) => !\str_starts_with($key, '$'), + ARRAY_FILTER_USE_KEY + )); + } + ); + } catch (DatabaseException $e) { + // adapters without upsert support + $this->assertStringContainsString('not implemented', $e->getMessage()); + $authorization->skip(fn () => $database->deleteCollection('upsertMask')); + + return; + } + + // `name` was supplied by this call, `email` is readable; `salary` is neither + $this->assertSame([['name', 'email']], $seen, 'upsert callback exposed an unreadable column'); + + $stored = $authorization->skip(fn () => $database->getDocument('upsertMask', 'u1')); + $this->assertSame(100000, $stored->getAttribute('salary')); + + $authorization->skip(fn () => $database->deleteCollection('upsertMask')); + } + + /** + * Column-scoped permissions, exercised through the public API so every adapter is + * held to the same observable behaviour rather than to one adapter's internals. + */ + public function testColumnScopedPermissionsMaskAndGate(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('columnPerms', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('columnPerms', 'name', Database::VAR_STRING, 128, false); + $database->createAttribute('columnPerms', 'salary', Database::VAR_INTEGER, 8, false); + + // one column each, to different roles + $database->createDocument('columnPerms', new Document([ + '$id' => ID::custom('cp1'), + '$permissions' => [ + Permission::read(Role::user('viewer'), 'name'), + Permission::read(Role::user('payroll'), 'salary'), + ], + 'name' => 'Bob', + 'salary' => 100000, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:viewer'); + + // masked to the granted column + $document = $database->getDocument('columnPerms', 'cp1'); + $this->assertSame('Bob', $document->getAttribute('name')); + $this->assertNull($document->getAttribute('salary')); + + // the row is visible, because one readable column is enough + $this->assertCount(1, $database->find('columnPerms')); + + // but a filter on the column this role cannot read must not act as an oracle + $this->assertSame([], $database->find('columnPerms', [Query::greaterThan('salary', 1)])); + $this->assertSame(0, $database->count('columnPerms', [Query::greaterThan('salary', 1)])); + $this->assertSame(0, $database->sum('columnPerms', 'salary')); + + // nor through a nested filter + $this->assertSame([], $database->find('columnPerms', [ + Query::or([Query::greaterThan('salary', 1), Query::equal('name', ['nobody'])]), + ])); + + // the other role sees the mirror image + $authorization->cleanRoles(); + $authorization->addRole('user:payroll'); + + $document = $database->getDocument('columnPerms', 'cp1'); + $this->assertNull($document->getAttribute('name')); + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertSame(100000, $database->sum('columnPerms', 'salary')); + + $authorization->skip(fn () => $database->deleteCollection('columnPerms')); + } + public function testUpdatingASharedDefinitionKeepsItsPermissionRowsTenantless(): void { /** @var Database $database */ @@ -1224,7 +1363,7 @@ public function testCollectionUpdatePermissionsThrowException(Document $collecti $database->updateCollection($collection->getId(), permissions: [ 'i dont work' - ], documentSecurity: false); + ], documentSecurity: false, columnSecurity: false); } public function testWritePermissions(): void @@ -1434,4 +1573,882 @@ private function documentIds(array $documents): array )); } + /** + * A collection-level grant makes every row readable, so Database skips row + * authorization for the whole query. That says nothing about columns: a grant + * scoped to one column must still hide the others from a predicate, a count and a + * sum. Mongo gated its column filter on the same authorization flag and so dropped + * it in exactly this case, while the SQL adapters keep theirs outside that guard -- + * a divergence no Memory or SQLite test can see. + */ + public function testCollectionGrantDoesNotExposeOtherColumnsToQueries(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('collectionGate', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('collectionGate', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('collectionGate', 'salary', Database::VAR_INTEGER, 8, false); + + // Readable to anyone, but only the name. Nothing grants salary. + $database->updateCollection('collectionGate', [Permission::read(Role::any(), 'name')], true, true); + + $database->createDocument('collectionGate', new Document([ + '$id' => ID::custom('g1'), '$permissions' => [], 'name' => 'Bob', 'salary' => 100, + ])); + $database->createDocument('collectionGate', new Document([ + '$id' => ID::custom('g2'), '$permissions' => [], 'name' => 'Ann', 'salary' => 900, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('any'); + + // The rows are readable, so they come back -- masked down to name. + $this->assertCount(2, $database->find('collectionGate')); + + // Reaching salary is what must find nothing. A predicate that matched would + // report whether a salary exceeds a threshold; a sum would report the values. + $this->assertCount(0, $database->find('collectionGate', [Query::greaterThan('salary', 50)])); + $this->assertEquals(0, $database->count('collectionGate', [Query::greaterThan('salary', 50)])); + $this->assertEquals(0, $database->sum('collectionGate', 'salary')); + + $authorization->cleanRoles(); + } + + /** + * Dropping a column purges the grants scoped to it, so a rollback has to put both + * back or neither. Restoring the column alone is the dangerous half: the grants + * stay purged and access to a column that still exists is silently revoked. + * + * Whether the column itself survives a rollback is the engine's business -- MySQL + * and MariaDB commit implicitly on DDL, so nothing about the drop is reversible + * there -- which is why this asserts the two agree rather than that either returns. + * Memory has to journal the rewrite to hold this, and Mongo has to run it inside + * the session; both got it wrong in different ways. + */ + public function testRollbackRestoresGrantsPurgedByAColumnDelete(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('rollbackGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('rollbackGrants', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('rollbackGrants', new Document([ + '$id' => ID::custom('r1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'salary' => 100, + ])); + }); + + $before = $authorization->skip( + fn () => $database->getDocument('rollbackGrants', 'r1')->getPermissions() + ); + + $rolledBack = false; + + try { + $authorization->skip(fn () => $database->withTransaction(function () use ($database) { + $database->deleteAttribute('rollbackGrants', 'salary'); + + throw new DatabaseException('rollback'); + })); + } catch (DatabaseException) { + $rolledBack = true; + } + + $this->assertTrue($rolledBack, 'the transaction should have propagated the failure'); + + $attributes = $authorization->skip( + fn () => $database->getCollection('rollbackGrants')->getAttribute('attributes', []) + ); + $after = $authorization->skip( + fn () => $database->getDocument('rollbackGrants', 'r1')->getPermissions() + ); + + $this->assertSame( + \count($attributes) === 1, + $before === $after, + 'a rollback must restore the column and the grants scoped to it together' + ); + } + /** + * An adapter without column support must not be able to hold the flag at all. + * Masking reads the flag alone, while find(), count() and sum() take the column + * gate from getSupportForColumnPermissions() -- so storing it on such an adapter + * yields a collection that hides a value from a read while a predicate still + * reveals it and a sum still adds it up. updateCollection() has always refused + * this; createCollection() has to refuse it too, or the refusal is one call away + * from being bypassed. + */ + public function testColumnSecurityIsRefusedWhenTheAdapterCannotEnforceIt(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + $authorization = $database->getAuthorization(); + $supported = $database->getAdapter()->getSupportForColumnPermissions(); + + $create = fn () => $authorization->skip(fn () => $database->createCollection( + 'columnSupport' . ($supported ? 'Yes' : 'No'), + documentSecurity: true, + columnSecurity: true, + permissions: [] + )); + + if (!$supported) { + try { + $create(); + $this->fail('createCollection stored columnSecurity on an adapter that cannot enforce it'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('not supported by this adapter', $e->getMessage()); + } + + return; + } + + $create(); + + // Judged by enforcement, not by the stored flag: an adapter answering true to + // getSupportForColumnPermissions() is promising the gate works, and reading the + // setting back would not tell us whether it does. + $authorization->skip(function () use ($database) { + $database->createAttribute('columnSupportYes', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('columnSupportYes', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('columnSupportYes', new Document([ + '$id' => ID::custom('s1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('columnSupportYes', 's1'); + + $this->assertSame(100, $document->getAttribute('salary'), 'the granted column is readable'); + $this->assertNull($document->getAttribute('name'), 'and every other column is withheld'); + + // The query gate, which is separate from masking above: salary is granted, so a + // predicate on it matches; name is not, so a predicate on it must find nothing + // rather than confirm the value through the result count. + $this->assertCount(1, $database->find('columnSupportYes', [Query::greaterThan('salary', 50)])); + $this->assertCount(0, $database->find('columnSupportYes', [Query::equal('name', ['Bob'])])); + + $authorization->cleanRoles(); + } + + /** + * updateDocuments() encodes $updates once up front and again per merged document, + * so a column-scoped grant passes through the key-to-identity translation twice. + * The second pass must recognise what the first produced; treating it as an unknown + * column key rejected every bulk update that carried one. + */ + public function testBulkUpdateAcceptsColumnScopedPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('bulkColumnGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('bulkColumnGrants', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('bulkColumnGrants', 'salary', Database::VAR_INTEGER, 8, false); + + foreach ([['b1', 'Bob', 100], ['b2', 'Ann', 900]] as [$id, $name, $salary]) { + $database->createDocument('bulkColumnGrants', new Document([ + '$id' => ID::custom($id), + '$permissions' => [Permission::read(Role::any(), 'name')], + 'name' => $name, + 'salary' => $salary, + ])); + } + }); + + $modified = $authorization->skip(fn () => $database->updateDocuments( + 'bulkColumnGrants', + new Document([ + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + ]) + )); + + $this->assertSame(2, $modified); + + // Read back in the caller's vocabulary: the identity is storage's business. + foreach (['b1', 'b2'] as $id) { + $stored = $authorization->skip( + fn () => $database->getDocument('bulkColumnGrants', $id)->getPermissions() + ); + + $this->assertSame([Permission::read(Role::user('hr'), 'salary')], $stored); + } + } + + /** + * A related document with no permissions of its own inherits the parent's. A + * column-scoped grant cannot come along: it names a column of the parent's + * collection, which the related collection does not have and may not even allow + * scoping on. Only the unscoped grants carry over. + */ + public function testRelatedDocumentInheritsOnlyUnscopedPermissions(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('relParent', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('relParent', 'title', Database::VAR_STRING, 64, false); + $database->createAttribute('relParent', 'secret', Database::VAR_STRING, 64, false); + + // Column security deliberately off here: a related collection need not have + // it, and inheriting a column-scoped grant would be rejected outright. + $database->createCollection('relChild', documentSecurity: true, columnSecurity: false, permissions: []); + $database->createAttribute('relChild', 'label', Database::VAR_STRING, 64, false); + + $database->createRelationship( + collection: 'relParent', + relatedCollection: 'relChild', + type: Database::RELATION_ONE_TO_MANY, + id: 'kids' + ); + + $database->createDocument('relParent', new Document([ + '$id' => ID::custom('rp1'), + '$permissions' => [ + Permission::read(Role::any()), + Permission::update(Role::user('ed')), + Permission::read(Role::user('hr'), 'secret'), + ], + 'title' => 'T', + 'secret' => 'S', + 'kids' => [new Document(['$id' => ID::custom('rc1'), 'label' => 'one'])], + ])); + }); + + $child = $authorization->skip( + fn () => $database->getDocument('relChild', 'rc1')->getPermissions() + ); + + \sort($child); + + $this->assertSame( + [Permission::read(Role::any()), Permission::update(Role::user('ed'))], + $child, + 'the child inherits the unscoped grants and none of the column-scoped ones' + ); + } + + /** + * Deleting a column revokes the grants scoped to it. + * + * The rollback case is covered elsewhere; this is the ordinary one, and it is the + * test the cleanup most needs. Its SQL is a compare-and-set whose comparison differs + * per adapter -- CAST(.. AS BINARY) on MariaDB and MySQL, ::jsonb on Postgres, plain + * on SQLite -- and if one of those stops matching, the purge quietly does nothing + * and every other test still passes. + */ + public function testDeletingAColumnRevokesTheGrantsScopedToIt(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('revokeOnDelete', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('revokeOnDelete', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('revokeOnDelete', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('revokeOnDelete', new Document([ + '$id' => ID::custom('d1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $this->assertSame(1, $database->count('revokeOnDelete'), 'the grant makes the row visible'); + + $authorization->skip(fn () => $database->deleteAttribute('revokeOnDelete', 'salary')); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + // The only grant named the column that is now gone, so nothing is left to see -- + // not the values, and not the fact that the row exists. + $this->assertSame(0, $database->count('revokeOnDelete'), 'and its removal takes that away'); + $this->assertCount(0, $database->find('revokeOnDelete')); + $this->assertTrue($database->getDocument('revokeOnDelete', 'd1')->isEmpty()); + + $authorization->cleanRoles(); + } + + /** + * Renaming a column keeps the grants scoped to it. What a caller is entitled to read + * does not depend on what the column is called. + */ + public function testRenamingAColumnKeepsItsGrants(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('renameKeepsGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('renameKeepsGrants', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('renameKeepsGrants', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('renameKeepsGrants', new Document([ + '$id' => ID::custom('r1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->skip(fn () => $database->updateAttribute('renameKeepsGrants', 'salary', newKey: 'pay')); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('renameKeepsGrants', 'r1'); + + $this->assertSame(100, $document->getAttribute('pay'), 'the grant follows the column under its new name'); + $this->assertNull($document->getAttribute('name'), 'and still withholds the rest'); + + $authorization->cleanRoles(); + } + + /** + * A bulk update that does not touch $permissions must leave them exactly as they + * were. The comparison deciding that runs between a decoded document and encoded + * updates, so it has to normalise both before it can mean anything. + */ + public function testBulkUpdateLeavesUnchangedColumnPermissionsIntact(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + $granted = [Permission::read(Role::user('hr'), 'salary'), Permission::update(Role::any())]; + + $authorization->skip(function () use ($database, $granted) { + $database->createCollection('bulkKeepsGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('bulkKeepsGrants', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('bulkKeepsGrants', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('bulkKeepsGrants', new Document([ + '$id' => ID::custom('k1'), + '$permissions' => $granted, + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + // No $permissions on the updates: only the value changes. + $authorization->skip(fn () => $database->updateDocuments( + 'bulkKeepsGrants', + new Document(['name' => 'Ann']) + )); + + $stored = $authorization->skip( + fn () => $database->getDocument('bulkKeepsGrants', 'k1') + ); + + $permissions = $stored->getPermissions(); + \sort($permissions); + $expected = $granted; + \sort($expected); + + $this->assertSame('Ann', $stored->getAttribute('name')); + $this->assertSame($expected, $permissions, 'an update that says nothing about permissions changes none'); + } + + /** + * Upsert carries a column-scoped grant through a different path than create or + * update: the adapter merges stored and incoming state, and the comparison deciding + * what changed sees both vocabularies at once. + */ + public function testUpsertKeepsColumnScopedPermissionsReadable(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForUpserts()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('upsertGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('upsertGrants', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('upsertGrants', 'salary', Database::VAR_INTEGER, 8, false); + + // Insert half. + $database->upsertDocument('upsertGrants', new Document([ + '$id' => ID::custom('u1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('upsertGrants', 'u1'); + $this->assertSame(100, $document->getAttribute('salary'), 'insert half stores a usable grant'); + $this->assertNull($document->getAttribute('name')); + + // Update half: same id, same grant, different value. + $authorization->skip(fn () => $database->upsertDocument('upsertGrants', new Document([ + '$id' => ID::custom('u1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Ann', + 'salary' => 900, + ]))); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('upsertGrants', 'u1'); + $this->assertSame(900, $document->getAttribute('salary'), 'update half keeps it usable'); + $this->assertNull($document->getAttribute('name')); + + $stored = $authorization->skip( + fn () => $database->getDocument('upsertGrants', 'u1')->getPermissions() + ); + $this->assertSame([Permission::read(Role::user('hr'), 'salary')], $stored, 'and readable as the key it was written with'); + + $authorization->cleanRoles(); + } + + /** + * A query reading two columns needs a grant on both. + * + * The gate emits one condition per column and ANDs them, so holding one of the two + * is not enough. Getting this wrong in the other direction is the dangerous way: an + * OR would let a grant on a harmless column carry a predicate on a sensitive one, + * and the predicate is what leaks the value. + */ + public function testAQueryOnTwoColumnsNeedsAGrantOnBoth(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('twoColumnFilters', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('twoColumnFilters', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('twoColumnFilters', 'salary', Database::VAR_INTEGER, 8, false); + $database->createAttribute('twoColumnFilters', 'grade', Database::VAR_INTEGER, 8, false); + + // Two grants: name and salary. Nothing grants grade. + $database->createDocument('twoColumnFilters', new Document([ + '$id' => ID::custom('both'), + '$permissions' => [ + Permission::read(Role::user('hr'), 'name'), + Permission::read(Role::user('hr'), 'salary'), + ], + 'name' => 'Bob', + 'salary' => 100, + 'grade' => 7, + ])); + + // One grant: name only. + $database->createDocument('twoColumnFilters', new Document([ + '$id' => ID::custom('one'), + '$permissions' => [Permission::read(Role::user('hr'), 'name')], + 'name' => 'Bob', + 'salary' => 100, + 'grade' => 7, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + // Both columns granted on 'both', only one on 'one'. + $found = $database->find('twoColumnFilters', [ + Query::equal('name', ['Bob']), + Query::greaterThan('salary', 50), + ]); + + $this->assertCount(1, $found, 'only the row granting both columns matches'); + $this->assertSame('both', $found[0]->getId()); + + // grade is granted on neither, so adding it to the same query matches nothing. + $this->assertCount(0, $database->find('twoColumnFilters', [ + Query::equal('name', ['Bob']), + Query::greaterThan('grade', 1), + ]), 'one ungranted column is enough to exclude the row'); + + $this->assertSame(0, $database->count('twoColumnFilters', [ + Query::equal('name', ['Bob']), + Query::greaterThan('grade', 1), + ])); + + $authorization->cleanRoles(); + } + + /** + * Collection-level and document-level grants combine. + * + * They are stored differently -- the collection's on its own row as keys, the + * document's in _perms and the row JSON as identities -- so the union is the one + * place both vocabularies have to agree. With documentSecurity off, only the + * collection's half counts, which is what makes this a union rather than a merge. + */ + public function testCollectionAndDocumentColumnGrantsCombine(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('unionGrants', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('unionGrants', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('unionGrants', 'salary', Database::VAR_INTEGER, 8, false); + $database->createAttribute('unionGrants', 'grade', Database::VAR_INTEGER, 8, false); + + // The collection grants name; the document grants salary. Neither grants grade. + $database->updateCollection('unionGrants', [Permission::read(Role::user('hr'), 'name')], true, true); + + $database->createDocument('unionGrants', new Document([ + '$id' => ID::custom('u1'), + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + 'grade' => 7, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('unionGrants', 'u1'); + + $this->assertSame('Bob', $document->getAttribute('name'), 'the collection half'); + $this->assertSame(100, $document->getAttribute('salary'), 'and the document half'); + $this->assertNull($document->getAttribute('grade'), 'and nothing neither of them named'); + + // The query gate sees the same union. + $this->assertCount(1, $database->find('unionGrants', [ + Query::equal('name', ['Bob']), + Query::greaterThan('salary', 50), + ]), 'a query spanning both halves is allowed'); + + $this->assertCount(0, $database->find('unionGrants', [ + Query::greaterThan('grade', 1), + ]), 'a query on the ungranted column is not'); + + // With documentSecurity off the document's own grant stops counting, leaving + // only what the collection gave. + $authorization->skip(fn () => $database->updateCollection( + 'unionGrants', + [Permission::read(Role::user('hr'), 'name')], + false, + true + )); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('unionGrants', 'u1'); + + $this->assertSame('Bob', $document->getAttribute('name'), 'the collection half survives'); + $this->assertNull($document->getAttribute('salary'), 'the document half does not'); + + $authorization->cleanRoles(); + } + + /** + * An update grant names the columns it may change, and nothing else. + * + * Rewriting $permissions is deliberately not one of them: if "may update name" were + * enough to edit the grant list, a caller could grant itself every other column and + * column permissions would enforce nothing. + */ + public function testUpdatingAColumnWithoutAGrantOnItIsRefused(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('writeScope', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('writeScope', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('writeScope', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('writeScope', new Document([ + '$id' => ID::custom('w1'), + '$permissions' => [ + Permission::read(Role::user('hr')), + Permission::update(Role::user('hr'), 'name'), + ], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + // The granted column changes. + $updated = $database->updateDocument('writeScope', 'w1', new Document(['name' => 'Ann'])); + $this->assertSame('Ann', $updated->getAttribute('name')); + + // The ungranted one does not. + try { + $database->updateDocument('writeScope', 'w1', new Document(['salary' => 900])); + $this->fail('updating a column with no grant on it should be refused'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('salary', $e->getMessage()); + } + + // Nor may the caller widen its own grant. + try { + $database->updateDocument('writeScope', 'w1', new Document([ + '$permissions' => [ + Permission::read(Role::user('hr')), + Permission::update(Role::user('hr')), + ], + ])); + $this->fail('rewriting $permissions should be refused'); + } catch (AuthorizationException $e) { + $this->assertStringContainsString('$permissions', $e->getMessage()); + } + + $stored = $authorization->skip(fn () => $database->getDocument('writeScope', 'w1')); + $this->assertSame(100, $stored->getAttribute('salary'), 'the refused write changed nothing'); + + $authorization->cleanRoles(); + } + + /** + * Delete takes the whole row, so it cannot be scoped to a column -- which means a + * caller holding only column-scoped grants holds no delete at all, however many + * columns they cover. + */ + public function testColumnScopedGrantsDoNotPermitDelete(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('deleteScope', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('deleteScope', 'name', Database::VAR_STRING, 64, false); + $database->createAttribute('deleteScope', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('deleteScope', new Document([ + '$id' => ID::custom('x1'), + '$permissions' => [ + Permission::read(Role::user('hr'), 'name'), + Permission::read(Role::user('hr'), 'salary'), + Permission::update(Role::user('hr'), 'name'), + ], + 'name' => 'Bob', + 'salary' => 100, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + try { + $database->deleteDocument('deleteScope', 'x1'); + $this->fail('column-scoped grants should not permit deleting the row'); + } catch (AuthorizationException) { + // expected + } + + $this->assertFalse( + $authorization->skip(fn () => $database->getDocument('deleteScope', 'x1'))->isEmpty(), + 'the row survives' + ); + + $authorization->cleanRoles(); + } + + /** + * A related document is masked by its own collection's rules, not the parent's. + * Each side keeps its own flag and its own grants, so a parent that hides a column + * says nothing about what the child hides. + */ + public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForRelationships()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $authorization->skip(function () use ($database) { + $database->createCollection('maskParent', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('maskParent', 'title', Database::VAR_STRING, 64, false); + $database->createAttribute('maskParent', 'secret', Database::VAR_STRING, 64, false); + + $database->createCollection('maskChild', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('maskChild', 'label', Database::VAR_STRING, 64, false); + $database->createAttribute('maskChild', 'hidden', Database::VAR_STRING, 64, false); + + $database->createRelationship( + collection: 'maskParent', + relatedCollection: 'maskChild', + type: Database::RELATION_ONE_TO_MANY, + id: 'kids', + twoWayKey: 'parent' + ); + + $database->createDocument('maskParent', new Document([ + '$id' => ID::custom('p1'), + '$permissions' => [ + Permission::read(Role::user('hr'), 'title'), + Permission::read(Role::user('hr'), 'kids'), + ], + 'title' => 'T', + 'secret' => 'S', + 'kids' => [new Document([ + '$id' => ID::custom('c1'), + // The grant on 'parent' is what lets the child be found at all: + // populating the relationship queries this collection by that + // column, and the gate wants a grant on every column a query reads. + // Without it the child is absent, which is a different outcome from + // being present and masked -- and only the second one is under test. + '$permissions' => [ + Permission::read(Role::user('hr'), 'label'), + Permission::read(Role::user('hr'), 'parent'), + ], + 'label' => 'visible', + 'hidden' => 'not', + ])], + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $parent = $database->getDocument('maskParent', 'p1'); + + $this->assertSame('T', $parent->getAttribute('title')); + $this->assertNull($parent->getAttribute('secret'), 'the parent hides what the parent hides'); + + // The child as it travels inside the parent. The parent is masked only after + // its relationships are populated, so an unmasked child would leave here. + $kids = $parent->getAttribute('kids', []); + $this->assertCount(1, $kids, 'the child is readable, so it comes along'); + $this->assertSame('visible', $kids[0]->getAttribute('label')); + $this->assertNull($kids[0]->getAttribute('hidden'), 'the child hides what the child hides, in here too'); + + // A direct read of the same child agrees. + $seen = $database->getDocument('maskChild', 'c1'); + $this->assertSame('visible', $seen->getAttribute('label')); + $this->assertNull($seen->getAttribute('hidden')); + + // Withheld, not lost. + $child = $authorization->skip(fn () => $database->getDocument('maskChild', 'c1')); + $this->assertSame('not', $child->getAttribute('hidden'), 'stored intact'); + + $authorization->cleanRoles(); + } } diff --git a/tests/e2e/Adapter/Scopes/RelationshipTests.php b/tests/e2e/Adapter/Scopes/RelationshipTests.php index dbfba7bfc2..9a76e2093b 100644 --- a/tests/e2e/Adapter/Scopes/RelationshipTests.php +++ b/tests/e2e/Adapter/Scopes/RelationshipTests.php @@ -1301,7 +1301,7 @@ public function testNoChangeUpdateDocumentWithRelationWithoutPermission(): void Permission::create(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any()), - ], false); + ], false, false); $level2 = $level1->getAttribute('level2'); $level3 = $level2->getAttribute('level3'); diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php new file mode 100644 index 0000000000..e3b98157a1 --- /dev/null +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -0,0 +1,492 @@ +authorization = new Authorization(); + + $this->database = new Database(new Memory(), new Cache(new NoCache())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('columnPermissions') + ->setNamespace('cols_' . \uniqid()); + + if (!$this->database->exists()) { + $this->database->create(); + } + + $this->authorization->skip(function () { + $this->database->createCollection('employees', permissions: [], documentSecurity: true, columnSecurity: true); + + foreach (['name', 'email', 'salary'] as $column) { + $this->database->createAttribute('employees', $column, Database::VAR_STRING, 128, false); + } + + $this->database->createDocument('employees', new Document([ + '$id' => 'e1', + '$permissions' => [ + // Reads and writes only the columns it is granted + Permission::read(Role::user('peer'), 'name'), + Permission::read(Role::user('peer'), 'email'), + Permission::update(Role::user('peer'), 'email'), + // Unscoped, so every column + Permission::read(Role::user('boss')), + Permission::update(Role::user('boss')), + ], + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'salary' => '100000', + ])); + }); + } + + /** + * @return array + */ + private function columnsVisibleTo(string $role): array + { + $this->authorization->cleanRoles(); + $this->authorization->addRole($role); + + $document = $this->database->getDocument('employees', 'e1'); + + return \array_values(\array_filter( + \array_keys($document->getArrayCopy()), + fn (string $key) => !\str_starts_with($key, '$') + )); + } + + public function testUnscopedRoleSeesEveryColumn(): void + { + $this->assertSame(['name', 'email', 'salary'], $this->columnsVisibleTo('user:boss')); + } + + public function testColumnScopedRoleSeesOnlyGrantedColumns(): void + { + $this->assertSame(['name', 'email'], $this->columnsVisibleTo('user:peer')); + } + + public function testRoleWithNoReadPermissionSeesNothing(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:stranger'); + + $this->assertTrue($this->database->getDocument('employees', 'e1')->isEmpty()); + } + + public function testFindMasksColumnsToo(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + $results = $this->database->find('employees'); + $this->assertCount(1, $results); + + $columns = \array_values(\array_filter( + \array_keys($results[0]->getArrayCopy()), + fn (string $key) => !\str_starts_with($key, '$') + )); + + $this->assertSame(['name', 'email'], $columns); + } + + public function testSkippedAuthorizationIsNotMasked(): void + { + $columns = $this->authorization->skip(function () { + $document = $this->database->getDocument('employees', 'e1'); + + return \array_values(\array_filter( + \array_keys($document->getArrayCopy()), + fn (string $key) => !\str_starts_with($key, '$') + )); + }); + + $this->assertSame(['name', 'email', 'salary'], $columns); + } + + /** + * A column-scoped grant on the COLLECTION sets $skipAuth, because the roles-only + * permission check cannot see the column. That flag means "may see every row", + * never "may see every column", so masking must still apply. + */ + public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('public_employees', documentSecurity: true, columnSecurity: true, permissions: []); + + foreach (['name', 'email', 'salary'] as $column) { + $this->database->createAttribute('public_employees', $column, Database::VAR_STRING, 128, false); + } + + $this->database->updateCollection('public_employees', [Permission::read(Role::any(), 'name')], true, true); + + $this->database->createDocument('public_employees', new Document([ + '$id' => 'pub1', + '$permissions' => [], + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'salary' => '100000', + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + + $results = $this->database->find('public_employees'); + $this->assertCount(1, $results); + + $columns = \array_values(\array_filter( + \array_keys($results[0]->getArrayCopy()), + fn (string $key) => !\str_starts_with($key, '$') + )); + + $this->assertSame(['name'], $columns, 'find() must mask even when $skipAuth is set'); + } + + /** + * Collection-level grants live on the collection document in _metadata, not in + * the collection's _perms table, so they need their own repointing on rename and + * their own cleanup on delete. + */ + public function testCollectionLevelColumnGrantFollowsARename(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('scoped', documentSecurity: true, columnSecurity: true, permissions: []); + + $this->database->createAttribute('scoped', 'name', Database::VAR_STRING, 128, false); + $this->database->updateCollection('scoped', [Permission::read(Role::any(), 'name')], true, true); + + $this->assertSame( + ['read("any", "name")'], + $this->database->getCollection('scoped')->getPermissions() + ); + + $this->database->updateAttribute('scoped', 'name', newKey: 'fullName'); + + $this->assertSame( + ['read("any", "fullName")'], + $this->database->getCollection('scoped')->getPermissions() + ); + + $this->database->deleteAttribute('scoped', 'fullName'); + + $this->assertSame([], $this->database->getCollection('scoped')->getPermissions()); + }); + } + + /** + * renameAttribute() is a second rename path, separate from updateAttribute()'s + * newKey. It has to run the same permission migration: without it the grant stays + * on the old key, so the caller loses the renamed column -- and a column later + * created under the old name inherits authority nobody granted it. + */ + public function testGrantFollowsRenameAttributeAndDoesNotOutliveTheOldKey(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('renamed', documentSecurity: true, columnSecurity: true, permissions: []); + $this->database->createAttribute('renamed', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('renamed', 'salary', Database::VAR_INTEGER, 8, false); + + $this->database->createDocument('renamed', new Document([ + '$id' => 'r1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 5, + ])); + + $this->database->renameAttribute('renamed', 'salary', 'pay'); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + // the grant moved with the column + $this->assertSame(5, $this->database->getDocument('renamed', 'r1')->getAttribute('pay')); + + // ...and a column recreated under the old key inherits nothing + $this->authorization->skip(function () { + $this->database->createAttribute('renamed', 'salary', Database::VAR_INTEGER, 8, false); + $this->database->updateDocument('renamed', 'r1', new Document(['salary' => 999])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('renamed', 'r1'); + + $this->assertSame(5, $document->getAttribute('pay')); + $this->assertNull($document->getAttribute('salary'), 'stale grant authorized a recreated column'); + } + + /** + * The flag controls enforcement, not storage: disabling it leaves scoped grants in + * place, dormant. So the rename and delete migrations must run regardless -- gating + * them on the flag lets a rename slip past a grant, and re-enabling would then + * point it at a key that no longer exists, or at whatever column took that name. + */ + public function testGrantMigrationsRunWhileColumnSecurityIsDisabled(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('dormant', documentSecurity: true, columnSecurity: true, permissions: []); + $this->database->createAttribute('dormant', 'salary', Database::VAR_INTEGER, 8, false); + + $this->database->createDocument('dormant', new Document([ + '$id' => 'r1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'salary' => 5, + ])); + + // grants stay in storage while the flag is off + $this->database->updateCollection('dormant', [], true, false); + $this->database->renameAttribute('dormant', 'salary', 'pay'); + + $this->database->updateCollection('dormant', [], true, true); + $this->database->createAttribute('dormant', 'salary', Database::VAR_INTEGER, 8, false); + $this->database->updateDocument('dormant', 'r1', new Document(['salary' => 999])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('dormant', 'r1'); + + $this->assertSame(5, $document->getAttribute('pay'), 'grant did not follow the rename'); + $this->assertNull($document->getAttribute('salary'), 'stale grant authorized a recreated column'); + } + + public function testUpdateOfGrantedColumnIsAllowed(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + $this->database->updateDocument('employees', 'e1', new Document([ + 'email' => 'new@example.com', + ])); + + $stored = $this->authorization->skip(fn () => $this->database->getDocument('employees', 'e1')); + $this->assertSame('new@example.com', $stored->getAttribute('email')); + } + + public function testUpdateOfUngrantedColumnIsRejected(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "update" permission for column "salary"'); + + $this->database->updateDocument('employees', 'e1', new Document([ + 'salary' => '999999', + ])); + } + + public function testUpdateIsRejectedWholesaleWhenOneColumnIsUngranted(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + try { + $this->database->updateDocument('employees', 'e1', new Document([ + 'email' => 'allowed@example.com', + 'salary' => '999999', + ])); + $this->fail('Expected an AuthorizationException'); + } catch (AuthorizationException) { + // The permitted column must not have been written either + } + + $stored = $this->authorization->skip(fn () => $this->database->getDocument('employees', 'e1')); + $this->assertSame('bob@example.com', $stored->getAttribute('email')); + $this->assertSame('100000', $stored->getAttribute('salary')); + } + + /** + * Regression: a caller whose update access is limited to one column must not be + * able to rewrite $permissions. Allowing it made column-level permissions + * unenforceable -- "may update email" was enough to grant yourself read and + * update on salary, then read and overwrite it. + */ + public function testColumnScopedUpdaterCannotRewritePermissions(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "update" permission to change $permissions'); + + $this->database->updateDocument('employees', 'e1', new Document([ + '$permissions' => [ + Permission::read(Role::user('peer'), 'salary'), + Permission::update(Role::user('peer'), 'salary'), + ], + ])); + } + + public function testFailedEscalationLeavesTheHiddenColumnHidden(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:peer'); + + try { + $this->database->updateDocument('employees', 'e1', new Document([ + '$permissions' => [Permission::read(Role::user('peer'), 'salary')], + ])); + $this->fail('Expected an AuthorizationException'); + } catch (AuthorizationException) { + // expected + } + + $this->assertSame(['name', 'email'], $this->columnsVisibleTo('user:peer')); + + $stored = $this->authorization->skip( + fn () => $this->database->getDocument('employees', 'e1') + ); + $this->assertSame('100000', $stored->getAttribute('salary')); + } + + public function testUnscopedUpdaterMayRewritePermissions(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:boss'); + + $current = $this->database->getDocument('employees', 'e1')->getPermissions(); + + $this->database->updateDocument('employees', 'e1', new Document([ + '$permissions' => [...$current, Permission::read(Role::team('audit'), 'salary')], + ])); + + $stored = $this->authorization->skip( + fn () => $this->database->getDocument('employees', 'e1') + ); + + $this->assertContains('read("team:audit", "salary")', $stored->getPermissions()); + } + + /** + * Write scopes and read scopes are independent, so being allowed to change a + * column says nothing about being allowed to see the rest of the row. The merged + * document a write returns carries every stored column, so it has to go through + * the same read masking a get would. + */ + public function testUpdateResponseIsMaskedByReadPermissions(): void + { + $this->authorization->skip(function () { + $this->database->createDocument('employees', new Document([ + '$id' => 'w1', + '$permissions' => [ + Permission::update(Role::user('ed'), 'name'), // may write name + Permission::read(Role::user('ed'), 'email'), // may read email + ], + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'salary' => '100000', + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:ed'); + + $returned = $this->database->updateDocument('employees', 'w1', new Document([ + 'name' => 'Robert', + ])); + + // readable, so returned + $this->assertSame('bob@example.com', $returned->getAttribute('email')); + + // supplied in this very call, so returned -- the caller already has it + $this->assertSame('Robert', $returned->getAttribute('name')); + + // neither readable nor supplied: this is the column the response used to leak + $this->assertNull($returned->getAttribute('salary'), 'update response leaked a hidden column'); + + // the write itself still landed + $stored = $this->authorization->skip( + fn () => $this->database->getDocument('employees', 'w1') + ); + $this->assertSame('Robert', $stored->getAttribute('name')); + $this->assertSame('100000', $stored->getAttribute('salary')); + } + + public function testBulkUpdateCallbackPayloadIsMasked(): void + { + $this->authorization->skip(function () { + $this->database->createDocument('employees', new Document([ + '$id' => 'w2', + '$permissions' => [ + Permission::update(Role::user('ed'), 'name'), + Permission::read(Role::user('ed'), 'email'), + ], + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'salary' => '100000', + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:ed'); + + $seen = []; + + $this->database->updateDocuments( + 'employees', + new Document(['name' => 'Bobby']), + [Query::equal('$id', ['w2'])], + 100, + onNext: function (Document $document) use (&$seen) { + $seen[] = \array_keys(\array_filter( + $document->getArrayCopy(), + fn (string $key) => !\str_starts_with($key, '$'), + ARRAY_FILTER_USE_KEY + )); + } + ); + + // `name` was supplied by this call, `email` is readable; `salary` is neither + $this->assertSame([['name', 'email']], $seen, 'bulk callback leaked hidden columns'); + + $stored = $this->authorization->skip( + fn () => $this->database->getDocument('employees', 'w2') + ); + $this->assertSame('Bobby', $stored->getAttribute('name')); + $this->assertSame('100000', $stored->getAttribute('salary')); + } + + public function testUnscopedRoleMayUpdateAnyColumn(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:boss'); + + $this->database->updateDocument('employees', 'e1', new Document([ + 'salary' => '123456', + ])); + + $stored = $this->authorization->skip(fn () => $this->database->getDocument('employees', 'e1')); + $this->assertSame('123456', $stored->getAttribute('salary')); + } +} diff --git a/tests/unit/ColumnPermissionQueryTest.php b/tests/unit/ColumnPermissionQueryTest.php new file mode 100644 index 0000000000..d3c036a5e0 --- /dev/null +++ b/tests/unit/ColumnPermissionQueryTest.php @@ -0,0 +1,301 @@ +authorization = new Authorization(); + + $this->database = new Database(new Memory(), new Cache(new NoCache())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('columnPermissions') + ->setNamespace('colq_' . \uniqid()); + + if (!$this->database->exists()) { + $this->database->create(); + } + + $this->authorization->skip(function () { + $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: []); + + $this->database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + + $this->database->updateCollection('employees', [ + Permission::read(Role::any(), 'name'), + Permission::create(Role::any(), 'name'), + ], true, true); + + $this->database->createDocument('employees', new Document([ + '$id' => 'e1', + '$permissions' => [ + Permission::read(Role::user('hr'), 'salary'), + Permission::update(Role::any(), 'name'), + ], + 'name' => 'Bob', + 'salary' => 100000, + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + } + + public function testCreateOfGrantedColumnIsAllowed(): void + { + $created = $this->database->createDocument('employees', new Document([ + '$id' => 'c1', + 'name' => 'Alice', + ])); + + $this->assertSame('c1', $created->getId()); + } + + public function testCreateOfUngrantedColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "create" permission for column "salary"'); + + $this->database->createDocument('employees', new Document([ + '$id' => 'c2', + 'salary' => 9, + ])); + } + + public function testFilterOnReadableColumnIsAllowed(): void + { + $this->assertCount(1, $this->database->find('employees', [Query::equal('name', ['Bob'])])); + } + + /** + * The caller may not read salary on any row, so it may not ask about it either. + * An empty result rather than an exception: the adapter settles it per row. + */ + public function testFilterOnColumnTheCallerCannotReadAnywhereMatchesNothing(): void + { + $this->assertSame([], $this->database->find('employees', [Query::greaterThan('salary', 1)])); + $this->assertSame(0, $this->database->count('employees', [Query::equal('salary', [100000])])); + $this->assertSame(0, $this->database->sum('employees', 'salary')); + } + + /** + * Case 4: the column is granted per document, not at collection level. The row + * that grants it comes back; the collection-level floor cannot see that grant, so + * before the per-row gate existed this was refused outright. + */ + public function testFilterOnColumnGrantedByTheDocumentReturnsThatRow(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + $this->authorization->addRole('user:hr'); + + $results = $this->database->find('employees', [Query::greaterThan('salary', 95000)]); + + $this->assertCount(1, $results); + $this->assertSame('e1', $results[0]->getId()); + $this->assertSame(100000, $results[0]->getAttribute('salary')); + } + + public function testSumIsPartialOverRowsThatGrantTheColumn(): void + { + $this->authorization->skip(function () { + // A second row whose salary nobody may read. + $this->database->createDocument('employees', new Document([ + '$id' => 'e2', + '$permissions' => [], + 'name' => 'Ann', + 'salary' => 200000, + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + $this->authorization->addRole('user:hr'); + + // 100000 from e1, which grants salary -- not 300000. Exactly what the caller + // could have got by reading e1 on its own. + $this->assertSame(100000, $this->database->sum('employees', 'salary')); + } + + /** + * The reason the gate exists: masking hides the value, but an ungated predicate + * still reveals it through the row's presence or absence. + */ + public function testPredicateCannotBoundAHiddenValue(): void + { + $this->authorization->skip(function () { + $this->database->createDocument('employees', new Document([ + '$id' => 'e2', + '$permissions' => [], + 'name' => 'Ann', + 'salary' => 200000, + ])); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + $this->authorization->addRole('user:hr'); + + // e2 satisfies every one of these predicates, and must never appear. + foreach ([150000, 190000, 199999] as $threshold) { + $results = $this->database->find('employees', [Query::greaterThan('salary', $threshold)]); + + $this->assertSame([], $results, "threshold {$threshold} leaked e2"); + } + } + + /** + * Internal fields cannot be named by a permission, so they are never in the + * collection-level floor. Gating them would make every lookup by id return + * nothing for a column-restricted caller, since no permission row can carry + * _column = '$id'. + */ + public function testFilteringByIdStillWorksForAColumnRestrictedCaller(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + $this->authorization->addRole('user:hr'); + + $results = $this->database->find('employees', [Query::equal('$id', ['e1'])]); + + $this->assertCount(1, $results); + $this->assertSame('e1', $results[0]->getId()); + } + + /** + * Skipping internal fields is only safe while none of them can be filtered on + * after masking has altered them. $permissions is the one masking rewrites, so + * it must stay unfilterable -- otherwise a caller could probe for the permission + * strings masking hid from them. + */ + public function testPermissionsCannotBeUsedAsAFilterAttribute(): void + { + $this->expectException(QueryException::class); + + $this->database->find('employees', [ + Query::equal('$permissions', ['read("user:hr", "salary")']), + ]); + } + + public function testOrderByAColumnTheCallerCannotReadDropsThoseRows(): void + { + $this->assertSame([], $this->database->find('employees', [Query::orderDesc('salary')])); + } + + /** + * select() only chooses a projection, so it is not gated: masking already removes + * what the caller may not read, and dropping the row instead would be worse. + */ + public function testSelectOfAnUnreadableColumnIsMaskedNotRejected(): void + { + $results = $this->database->find('employees', [Query::select(['salary'])]); + + $this->assertCount(1, $results); + $this->assertNull($results[0]->getAttribute('salary')); + } + + /** + * With documentSecurity off, collection permissions are the whole story, so the + * column is unreadable on every row and an error beats an empty result. + */ + public function testWithoutDocumentSecurityAnUnreadableColumnThrows(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('strict', documentSecurity: false, columnSecurity: true, permissions: []); + $this->database->createAttribute('strict', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('strict', 'salary', Database::VAR_INTEGER, 8, false); + $this->database->updateCollection('strict', [Permission::read(Role::any(), 'name')], false, true); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "read" permission for column "salary"'); + + $this->database->find('strict', [Query::greaterThan('salary', 1)]); + } + + public function testBulkUpdateOfGrantedColumnIsAllowed(): void + { + $this->assertSame(1, $this->database->updateDocuments('employees', new Document([ + 'name' => 'Renamed', + ]))); + } + + /** + * The collection grants no update at all, so the restriction is only visible on + * the document itself: the bulk path has to check each row, not just the schema. + */ + public function testBulkUpdateOfUngrantedColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "update" permission for column "salary"'); + + $this->database->updateDocuments('employees', new Document(['salary' => 1])); + } + + public function testIncreaseOfUngrantedColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "update" permission for column "salary"'); + + $this->database->increaseDocumentAttribute('employees', 'e1', 'salary', 1); + } + + public function testPermissionsScopedToUnreadableColumnsAreMasked(): void + { + $document = $this->database->getDocument('employees', 'e1'); + + $this->assertSame(['update("any", "name")'], $document->getPermissions()); + } + + /** + * Because $permissions is masked, writing a document straight back would delete + * the grants the caller never saw. + */ + public function testMaskedPermissionsSurviveARoundTrip(): void + { + $document = $this->database->getDocument('employees', 'e1'); + + $this->database->updateDocument('employees', 'e1', new Document([ + '$permissions' => $document->getPermissions(), + 'name' => 'Bob2', + ])); + + $stored = $this->authorization->skip( + fn () => $this->database->getDocument('employees', 'e1') + ); + + $this->assertContains('read("user:hr", "salary")', $stored->getPermissions()); + $this->assertContains('update("any", "name")', $stored->getPermissions()); + $this->assertSame(100000, $stored->getAttribute('salary')); + } +} diff --git a/tests/unit/ColumnPermissionSqlTest.php b/tests/unit/ColumnPermissionSqlTest.php new file mode 100644 index 0000000000..4c236602c0 --- /dev/null +++ b/tests/unit/ColumnPermissionSqlTest.php @@ -0,0 +1,285 @@ +file = \sys_get_temp_dir() . '/utopia_colperm_' . \uniqid() . '.sql'; + + $pdo = new PDO('sqlite:' . $this->file, null, null, SQLite::getPDOAttributes()); + $adapter = new SQLite($pdo); + $adapter->setEmulateMySQL(true); + + $this->authorization = new Authorization(); + + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('utopiaTests') + ->setNamespace('cp_' . \uniqid()); + + $this->database->create(); + + $this->authorization->skip(function () { + // Columns first: a column-scoped grant is stored against the attribute's + // $internalId, so the attribute has to exist before anything can name it. + $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: []); + $this->database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + $this->database->updateCollection('employees', [Permission::read(Role::any(), 'name')], true, true); + + // hr may read salary on e1 only + $this->database->createDocument('employees', new Document([ + '$id' => 'e1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ])); + // nobody may read salary on e2, and its salary is higher + $this->database->createDocument('employees', new Document([ + '$id' => 'e2', + '$permissions' => [], + 'name' => 'Ann', + 'salary' => 200000, + ])); + }); + } + + protected function tearDown(): void + { + if (isset($this->file) && \file_exists($this->file)) { + @\unlink($this->file); + } + } + + /** + * @param array $roles + */ + private function as(array $roles): void + { + $this->authorization->cleanRoles(); + + foreach ($roles as $role) { + $this->authorization->addRole($role); + } + } + + /** + * @param array $rows + * @return array> + */ + private function shape(array $rows): array + { + $shape = []; + + foreach ($rows as $row) { + $shape[$row->getId()] = \array_values(\array_filter( + \array_keys($row->getArrayCopy()), + fn (string $key) => !\str_starts_with($key, '$') + )); + } + + return $shape; + } + + /** + * A column-scoped grant lives in two places: the _permissions JSON on the row, which + * drives masking, and a _perms row, which drives the find/count/sum gate. Re-scoping + * the grant to another column has to move both. If only the JSON is rewritten the + * filter still answers on the old column -- which is what happens when the permission + * diff compares roles and ignores the column. + */ + public function testRescopingAGrantMovesBothTheMaskAndTheFilter(): void + { + $this->as(['any', 'user:hr']); + + $this->assertSame( + ['e1' => ['name', 'salary']], + $this->shape($this->database->find('employees', [Query::greaterThan('salary', 95000)])) + ); + + $this->authorization->skip(fn () => $this->database->updateDocument('employees', 'e1', new Document([ + '$permissions' => [Permission::read(Role::user('hr'), 'name')], + ]))); + + $this->as(['any', 'user:hr']); + + // the mask no longer yields salary... + $this->assertSame( + ['e1' => ['name'], 'e2' => ['name']], + $this->shape($this->database->find('employees')) + ); + + // ...and neither does the gate, so the row cannot be found through it + $this->assertSame([], $this->database->find('employees', [Query::greaterThan('salary', 95000)])); + $this->assertSame(0, $this->database->sum('employees', 'salary')); + } + + /** + * The row gate matches on the role with _column left out of the predicate, so a + * grant scoped to one column still makes the row visible. This is the case that + * an assembled-string match (Mongo, and Postgres' jsonb path) gets wrong. + */ + public function testColumnScopedGrantAloneMakesTheRowVisible(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('scoped', documentSecurity: true, columnSecurity: true, permissions: []); + $this->database->createAttribute('scoped', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('scoped', 'salary', Database::VAR_INTEGER, 8, false); + + $this->database->createDocument('scoped', new Document([ + '$id' => 'only', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 5, + ])); + }); + + $this->as(['any', 'user:hr']); + + $this->assertSame(['only' => ['salary']], $this->shape($this->database->find('scoped'))); + $this->assertSame(1, $this->database->count('scoped')); + } + + public function testUnfilteredFindMasksPerRow(): void + { + $this->as(['any', 'user:hr']); + + $this->assertSame( + ['e1' => ['name', 'salary'], 'e2' => ['name']], + $this->shape($this->database->find('employees')) + ); + } + + /** + * Case 4: salary is granted per document, which the collection-level floor cannot + * see. The EXISTS settles it per row. + */ + public function testFilterOnAPerDocumentGrantedColumnReturnsThatRow(): void + { + $this->as(['any', 'user:hr']); + + $this->assertSame( + ['e1' => ['name', 'salary']], + $this->shape($this->database->find('employees', [Query::greaterThan('salary', 95000)])) + ); + } + + public function testOrderingOnAPerDocumentGrantedColumnKeepsOnlyGrantingRows(): void + { + $this->as(['any', 'user:hr']); + + $this->assertSame( + ['e1' => ['name', 'salary']], + $this->shape($this->database->find('employees', [Query::orderDesc('salary')])) + ); + } + + /** + * e2 satisfies every one of these predicates. If it ever appears, the filter has + * become an oracle for a value the caller may not read. + */ + public function testPredicateCannotBoundAValueTheCallerCannotRead(): void + { + $this->as(['any', 'user:hr']); + + foreach ([150000, 190000, 199999] as $threshold) { + $this->assertSame( + [], + $this->database->find('employees', [Query::greaterThan('salary', $threshold)]), + "threshold {$threshold} leaked e2" + ); + } + } + + public function testSumCountsOnlyRowsThatGrantTheColumn(): void + { + $this->as(['any', 'user:hr']); + + // e1 only. Not 300000, and not e2's 200000. + $this->assertSame(100000, $this->database->sum('employees', 'salary')); + $this->assertSame(1, $this->database->count('employees', [Query::greaterThan('salary', 95000)])); + } + + public function testCallerWithNoGrantOnTheColumnMatchesNothing(): void + { + $this->as(['any']); + + $this->assertSame([], $this->database->find('employees', [Query::greaterThan('salary', 1)])); + $this->assertSame(0, $this->database->sum('employees', 'salary')); + + // ...while the rows themselves stay visible through the collection's name grant + $this->assertSame( + ['e1' => ['name'], 'e2' => ['name']], + $this->shape($this->database->find('employees')) + ); + } + + /** + * A permission can name a column that no longer exists -- written before the + * column was dropped, or restored from a backup. The caller can then read nothing, + * while the row filter still matches on the role. Returning the document would + * disclose its id and timestamps to someone entitled to none of its data. + */ + public function testDocumentIsInvisibleWhenNoGrantedColumnExists(): void + { + $this->authorization->skip(function () { + $this->database->createDocument('employees', new Document([ + '$id' => 'ghost', + '$permissions' => [Permission::read(Role::user('nobody'), 'salary')], + 'name' => 'Cid', + 'salary' => 1, + ])); + + // the granted column disappears from under the permission + $this->database->deleteAttribute('employees', 'salary'); + $this->database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + }); + + $this->as(['user:nobody']); + + $document = $this->database->getDocument('employees', 'ghost'); + + $this->assertTrue($document->isEmpty(), 'document leaked its metadata'); + $this->assertSame([], $this->database->find('employees')); + } + + public function testSelectOfAnUnreadableColumnIsMaskedNotDropped(): void + { + $this->as(['any']); + + $rows = $this->database->find('employees', [Query::select(['salary'])]); + + $this->assertCount(2, $rows); + $this->assertNull($rows[0]->getAttribute('salary')); + } +} diff --git a/tests/unit/ColumnPermissionTest.php b/tests/unit/ColumnPermissionTest.php new file mode 100644 index 0000000000..cf3b246080 --- /dev/null +++ b/tests/unit/ColumnPermissionTest.php @@ -0,0 +1,473 @@ +assertSame(Permission::COLUMN_ALL, $permission->getColumn()); + $this->assertTrue($permission->isForAllColumns()); + $this->assertSame($string, $permission->toString()); + } + } + + public function testParseWithColumn(): void + { + $permission = Permission::parse('read("user:123", "salary")'); + + $this->assertSame('read', $permission->getPermission()); + $this->assertSame('user', $permission->getRole()); + $this->assertSame('123', $permission->getIdentifier()); + $this->assertSame('', $permission->getDimension()); + $this->assertSame('salary', $permission->getColumn()); + $this->assertFalse($permission->isForAllColumns()); + } + + public function testParseWithColumnAndDimension(): void + { + $permission = Permission::parse('update("team:abc/owner", "salary")'); + + $this->assertSame('update', $permission->getPermission()); + $this->assertSame('team', $permission->getRole()); + $this->assertSame('abc', $permission->getIdentifier()); + $this->assertSame('owner', $permission->getDimension()); + $this->assertSame('salary', $permission->getColumn()); + } + + /** + * @return array + */ + public static function roundTripProvider(): array + { + return [ + 'no column' => ['read("any")'], + 'column' => ['read("user:123", "salary")'], + 'dimension and column' => ['read("team:abc/owner", "salary")'], + 'update column' => ['update("user:123", "name")'], + 'create column' => ['create("users", "name")'], + ]; + } + + /** + * @dataProvider roundTripProvider + */ + public function testRoundTrip(string $string): void + { + $this->assertSame($string, Permission::parse($string)->toString()); + } + + public function testFactories(): void + { + $this->assertSame('read("user:123")', Permission::read(Role::user('123'))); + $this->assertSame('read("user:123", "salary")', Permission::read(Role::user('123'), 'salary')); + $this->assertSame('update("team:abc/owner", "name")', Permission::update(Role::team('abc', 'owner'), 'name')); + $this->assertSame('create("users", "name")', Permission::create(Role::users(), 'name')); + } + + public function testAggregatePreservesColumn(): void + { + $aggregated = Permission::aggregate(['read("user:1", "name")']); + + $this->assertSame(['read("user:1", "name")'], $aggregated); + } + + public function testEmptyColumnIsRejected(): void + { + $this->expectException(DatabaseException::class); + Permission::parse('read("user:1", "")'); + } + + public function testWildcardColumnIsRejected(): void + { + $this->expectException(DatabaseException::class); + Permission::parse('read("user:1", "*")'); + } + + /** + * A column-scoped permission still grants its role ordinary row-level access -- + * the column narrows what is returned, it does not withhold the row. Asserted + * through a read rather than through the shape of the extracted permission list. + */ + public function testColumnScopedGrantStillGrantsTheRowToThatRole(): void + { + $authorization = new Authorization(); + + $database = new Database(new Memory(), new Cache(new NoCache())); + $database + ->setAuthorization($authorization) + ->setDatabase('columnPermissions') + ->setNamespace('cpt_' . \uniqid()); + + $database->create(); + + $authorization->skip(function () use ($database) { + $database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + $database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + + $database->createDocument('employees', new Document([ + '$id' => 'e1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ])); + }); + + $authorization->cleanRoles(); + $authorization->addRole('user:hr'); + + $document = $database->getDocument('employees', 'e1'); + + $this->assertFalse($document->isEmpty(), 'a column-scoped grant must still make the row visible'); + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertNull($document->getAttribute('name')); + + $authorization->cleanRoles(); + $authorization->addRole('user:other'); + + $this->assertTrue($database->getDocument('employees', 'e1')->isEmpty()); + } + + public function testValidatorAcceptsColumnScopedReadCreateUpdate(): void + { + $validator = new Permissions(columns: ['salary', 'name']); + + $this->assertTrue($validator->isValid([ + 'read("user:1", "salary")', + 'create("users", "name")', + 'update("team:abc/owner", "name")', + ]), $validator->getDescription()); + } + + /** + * The default is no columns, so a column-scoped permission is refused until the + * caller names the columns that exist. There is no waiver: a caller who forgets is + * told, rather than quietly having a grant on a column nobody checked accepted on + * its behalf. + */ + public function testValidatorRejectsColumnScopedGrantByDefault(): void + { + $validator = new Permissions(); + + $this->assertFalse($validator->isValid(['read("user:1", "salary")'])); + $this->assertStringContainsString('does not exist', $validator->getDescription()); + + $this->assertTrue($validator->isValid(['read("user:1")']), $validator->getDescription()); + } + + /** + * An empty list is a collection with no columns, not a caller who did not say. Every + * column-scoped grant names a column that does not exist. + */ + public function testValidatorWithNoColumnsRejectsEveryColumnScopedGrant(): void + { + $validator = new Permissions(columns: []); + + $this->assertFalse($validator->isValid(['read("user:1", "salary")'])); + $this->assertStringContainsString('does not exist', $validator->getDescription()); + + $this->assertTrue($validator->isValid(['read("user:1")']), $validator->getDescription()); + } + + public function testValidatorRejectsColumnScopedDelete(): void + { + $validator = new Permissions(); + + $this->assertFalse($validator->isValid(['delete("user:1", "salary")'])); + $this->assertStringContainsString('cannot be scoped to a column', $validator->getDescription()); + } + + public function testValidatorRejectsColumnScopedWrite(): void + { + $validator = new Permissions(); + + $this->assertFalse($validator->isValid(['write("user:1", "salary")'])); + $this->assertStringContainsString('cannot be scoped to a column', $validator->getDescription()); + } + + public function testValidatorRejectsUnknownColumnWhenColumnsGiven(): void + { + $validator = new Permissions(columns: ['name', 'email']); + + $this->assertTrue($validator->isValid(['read("user:1", "name")']), $validator->getDescription()); + $this->assertFalse($validator->isValid(['read("user:1", "salary")'])); + $this->assertStringContainsString('does not exist', $validator->getDescription()); + } + + public function testValidatorRejectsInvalidColumnKey(): void + { + $validator = new Permissions(); + + $this->assertFalse($validator->isValid(['read("user:1", "_internal")'])); + $this->assertStringContainsString('not a valid column key', $validator->getDescription()); + } + + // ------------------------------------------------- grants on a missing column + + /** + * @return array{Database, Authorization} + */ + private function database(): array + { + $authorization = new Authorization(); + + $database = new Database(new Memory(), new Cache(new NoCache())); + $database + ->setAuthorization($authorization) + ->setDatabase('columnPermissions') + ->setNamespace('cpm_' . \uniqid()); + + $database->create(); + + return [$database, $authorization]; + } + + /** + * A collection created with no columns has nothing a permission could name, so a + * grant on one is refused rather than held until that column appears and quietly + * starts applying. + */ + public function testCreateCollectionRejectsGrantOnMissingColumn(): void + { + [$database, $authorization] = $this->database(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $authorization->skip(fn () => $database->createCollection( + 'employees', + documentSecurity: true, + columnSecurity: true, + permissions: [Permission::read(Role::any(), 'salary')] + )); + } + + /** + * The same grant is accepted when the column it names is created in the same call, + * which is what makes the refusal above about the missing column rather than about + * collection-level grants carrying columns at all. + */ + public function testCreateCollectionAcceptsGrantOnColumnCreatedWithIt(): void + { + [$database, $authorization] = $this->database(); + + $authorization->skip(fn () => $database->createCollection( + 'employees', + attributes: [new Document([ + '$id' => 'salary', + 'key' => 'salary', + 'type' => Database::VAR_INTEGER, + 'size' => 8, + 'required' => false, + 'default' => null, + 'signed' => true, + 'array' => false, + 'filters' => [], + ])], + documentSecurity: true, + columnSecurity: true, + permissions: [Permission::read(Role::any(), 'salary')] + )); + + $collection = $authorization->skip(fn () => $database->getCollection('employees')); + + $this->assertSame([Permission::read(Role::any(), 'salary')], $collection->getPermissions()); + } + + /** + * updateCollection judges the grant against the columns the collection has now, so + * one naming a column that was never created is refused here too. + */ + public function testUpdateCollectionRejectsGrantOnMissingColumn(): void + { + [$database, $authorization] = $this->collectionWithoutSalary(); + + try { + $authorization->skip(fn () => $database->updateCollection( + 'employees', + [Permission::read(Role::any(), 'salary')], + true, + true + )); + $this->fail('updateCollection accepted a grant naming a column that does not exist'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('does not exist', $e->getMessage()); + } + + $collection = $authorization->skip(fn () => $database->getCollection('employees')); + + $this->assertSame([], $collection->getPermissions(), 'a refused update must change nothing'); + } + + /** + * A collection with one column, 'name', and one document holding grants on it. + * 'salary' is deliberately never created: it is the column these tests name. + * + * @return array{Database, Authorization} + */ + private function collectionWithoutSalary(): array + { + [$database, $authorization] = $this->database(); + + $authorization->skip(function () use ($database) { + $database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + + $database->createDocument('employees', new Document([ + '$id' => 'e1', + '$permissions' => [ + Permission::read(Role::any(), 'name'), + Permission::update(Role::any(), 'name'), + ], + 'name' => 'Bob', + ])); + }); + + return [$database, $authorization]; + } + + /** + * A grant naming a column that does not exist confers nothing today and binds + * late if that column is ever created -- a restriction nobody reviewed at the + * moment it became real. The write is refused instead. + */ + public function testCreateDocumentRejectsGrantOnMissingColumn(): void + { + [$database, $authorization] = $this->collectionWithoutSalary(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $authorization->skip(fn () => $database->createDocument('employees', new Document([ + '$id' => 'e2', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'name' => 'Ann', + ]))); + } + + /** + * Same refusal with validation skipped. Storage is keyed by the column's identity, + * so a grant naming no column has nothing to be stored against -- which makes this + * a property of the write path rather than of the validator in front of it. + */ + public function testCreateDocumentRejectsGrantOnMissingColumnWithoutValidation(): void + { + [$database, $authorization] = $this->collectionWithoutSalary(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $authorization->skip(fn () => $database->skipValidation( + fn () => $database->createDocument('employees', new Document([ + '$id' => 'e2', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'name' => 'Ann', + ])) + )); + } + + /** + * The update path has to refuse what the create path refuses. Accepting here would + * leave the same orphan grant in storage by a different door. + */ + public function testUpdateDocumentRejectsGrantOnMissingColumn(): void + { + [$database, $authorization] = $this->collectionWithoutSalary(); + + $update = fn () => $authorization->skip(fn () => $database->updateDocument('employees', 'e1', new Document([ + '$id' => 'e1', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'name' => 'Bob', + ]))); + + try { + $update(); + $this->fail('updateDocument accepted a grant naming a column that does not exist'); + } catch (DatabaseException $e) { + $this->assertStringContainsString('does not exist', $e->getMessage()); + } + + $stored = $authorization->skip(fn () => $database->getDocument('employees', 'e1')); + + $this->assertSame( + [Permission::read(Role::any(), 'name'), Permission::update(Role::any(), 'name')], + $stored->getPermissions(), + 'a refused update must leave the stored permissions untouched' + ); + } + + public function testUpdateDocumentRejectsGrantOnMissingColumnWithoutValidation(): void + { + [$database, $authorization] = $this->collectionWithoutSalary(); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('does not exist'); + + $authorization->skip(fn () => $database->skipValidation( + fn () => $database->updateDocument('employees', 'e1', new Document([ + '$id' => 'e1', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'name' => 'Bob', + ])) + )); + } + + /** + * A rename changes an attribute's key and leaves its identity alone. + * + * This pins a design decision rather than a caller-visible behaviour, which is why + * it sits here and not beside the e2e test that covers what callers need -- that + * grants survive a rename. More than one design delivers that: storing the key in + * _column and migrating every permission row on rename does too. This says which one + * is in force, and it lives in the unit tier because no adapter has a say in it -- + * stamping and preserving the identity happen in Database, and the adapter only + * renames a physical column. + * + * If the design is ever traded for another, this is the test to delete. + */ + public function testRenamingAColumnDoesNotChangeItsIdentity(): void + { + [$database, $authorization] = $this->database(); + + $identities = fn (): array => $authorization->skip(function () use ($database): array { + $map = []; + + foreach ($database->getCollection('employees')->getAttribute('attributes', []) as $attribute) { + $map[$attribute['key']] = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? ''; + } + + return $map; + }); + + $authorization->skip(function () use ($database) { + $database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + }); + + $before = $identities(); + $this->assertArrayHasKey('salary', $before); + $this->assertNotSame('', $before['salary']); + + $authorization->skip(fn () => $database->updateAttribute('employees', 'salary', newKey: 'pay')); + + $after = $identities(); + $this->assertArrayNotHasKey('salary', $after, 'the key moved'); + $this->assertSame($before['salary'], $after['pay'] ?? null, 'the identity did not'); + } +} diff --git a/tests/unit/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php new file mode 100644 index 0000000000..3c41965aa6 --- /dev/null +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -0,0 +1,561 @@ +file = \sys_get_temp_dir() . '/utopia_colflag_' . \uniqid() . '.sql'; + + $pdo = new PDO('sqlite:' . $this->file, null, null, SQLite::getPDOAttributes()); + $adapter = new SQLite($pdo); + $adapter->setEmulateMySQL(true); + + $this->authorization = new Authorization(); + + $this->database = new Database($adapter, new Cache(new NoCache())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('utopiaTests') + ->setNamespace('cf_' . \uniqid()); + + $this->database->create(); + } + + protected function tearDown(): void + { + if (isset($this->file) && \file_exists($this->file)) { + @\unlink($this->file); + } + } + + /** + * @param array $permissions + */ + private function collection(string $id, bool $columnSecurity, array $permissions = []): void + { + $this->authorization->skip(function () use ($id, $columnSecurity, $permissions) { + $this->database->createCollection( + $id, + documentSecurity: true, + columnSecurity: $columnSecurity, + permissions: $permissions + ); + $this->database->createAttribute($id, 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute($id, 'salary', Database::VAR_INTEGER, 8, false); + }); + } + + /** + * The value passed is the value stored, in both directions, with no inference from + * what the collection already held -- shown by what the flag does rather than by + * reading it back. Off, the column half of a grant is inert, so read("hr","salary") + * grants what read("hr") grants. On again, the stored grant still names the column, + * so the restriction returns exactly as it was. + */ + public function testTurningTheFlagOffAndOnChangesWhatAColumnGrantDoes(): void + { + $this->collection('secured', true); + + $this->authorization->skip(fn () => $this->database->createDocument('secured', new Document([ + '$id' => 'e1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('secured', 'e1'); + $this->assertSame(100, $document->getAttribute('salary'), 'the grant names salary'); + $this->assertNull($document->getAttribute('name'), 'and so withholds every other column'); + + $this->authorization->skip(fn () => $this->database->updateCollection('secured', [], true, false)); + + $document = $this->database->getDocument('secured', 'e1'); + $this->assertSame('Bob', $document->getAttribute('name'), 'off, the column half is inert'); + $this->assertSame(100, $document->getAttribute('salary')); + + $this->authorization->skip(fn () => $this->database->updateCollection('secured', [], true, true)); + + $document = $this->database->getDocument('secured', 'e1'); + $this->assertNull($document->getAttribute('name'), 'on again, the stored grant restricts as before'); + $this->assertSame(100, $document->getAttribute('salary')); + } + + /** + * createCollection() is called without the argument, so the default is what is under + * test, and it is judged by a write the default has to refuse. + */ + public function testDefaultsToOff(): void + { + $this->authorization->skip(function () { + $this->database->createCollection('implicit', documentSecurity: true, permissions: []); + $this->database->createAttribute('implicit', 'salary', Database::VAR_INTEGER, 8, false); + }); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->createDocument('implicit', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'salary' => 1, + ]))); + } + + // ---------------------------------------------------------------- writes blocked + + public function testCreateDocumentWithColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + } + + public function testCreateDocumentsBatchWithColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->createDocuments('plain', [ + new Document(['$id' => 'd1', '$permissions' => [], 'name' => 'Ann']), + new Document([ + '$id' => 'd2', + '$permissions' => [Permission::update(Role::any(), 'name')], + 'name' => 'Bob', + ]), + ])); + } + + /** + * Regression: upsert reached the adapter without passing through the guard, so a + * column-scoped grant could be stored on a collection with the flag off. The + * permission landed in the _permissions JSON with its column but in _perms with + * _column = '', which reads as "every column" -- so masking and the query gate + * disagreed about the same grant. + */ + public function testUpsertWithColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->upsertDocuments('plain', [ + new Document(['$id' => 'd1', '$permissions' => [], 'name' => 'Ann']), + new Document([ + '$id' => 'd2', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + ]), + ])); + } + + /** + * The column is written to _perms whatever the flag says, so the two stores agree + * about every grant they hold. + */ + public function testColumnIsWrittenToBothStores(): void + { + $this->collection('secured', true); + + $this->authorization->skip(fn () => $this->database->upsertDocuments('secured', [ + new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ]), + ])); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + // masking reads the JSON... + $document = $this->database->getDocument('secured', 'd1'); + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertNull($document->getAttribute('name')); + + // ...the gate reads _perms, and they agree + $this->assertSame(100000, $this->database->sum('secured', 'salary')); + $this->assertSame([], $this->database->find('secured', [Query::isNotNull('name')])); + } + + public function testUpdateDocumentIntroducingAColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->updateDocument('plain', 'd1', new Document([ + '$permissions' => [Permission::read(Role::any()), Permission::read(Role::user('hr'), 'salary')], + ]))); + } + + public function testBulkUpdateWithAColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any()), Permission::update(Role::any())], + 'name' => 'Bob', + ]))); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->updateDocuments('plain', new Document([ + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + ]))); + } + + public function testCollectionPermissionScopedToAColumnIsRejected(): void + { + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->createCollection( + 'plain', + documentSecurity: true, + columnSecurity: false, + permissions: [Permission::read(Role::any(), 'name')] + )); + } + + public function testUpdateCollectionIntroducingAColumnPermissionIsRejected(): void + { + $this->collection('plain', false); + + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); + + $this->authorization->skip(fn () => $this->database->updateCollection( + 'plain', + [Permission::read(Role::any(), 'name')], + true, + false + )); + } + + // ---------------------------------------------------------------- writes allowed + + public function testOrdinaryPermissionsStillWorkWithTheFlagOff(): void + { + $this->collection('plain', false); + + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr')), Permission::update(Role::any())], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('plain', 'd1'); + + $this->assertSame('Bob', $document->getAttribute('name')); + $this->assertSame(100000, $document->getAttribute('salary')); + } + + public function testColumnPermissionIsAcceptedOnceEnabled(): void + { + $this->collection('secured', true); + + $this->authorization->skip(fn () => $this->database->createDocument('secured', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('secured', 'd1'); + + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertNull($document->getAttribute('name')); + } + + // ---------------------------------------------------------------- the transition + + public function testEnablingLaterPreparesTheTableAndThenAcceptsColumnPermissions(): void + { + $this->collection('plain', false); + + // rejected before + try { + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'before', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + ]))); + $this->fail('Expected the write to be rejected while the flag is off'); + } catch (DatabaseException) { + // expected + } + + $this->authorization->skip(fn () => $this->database->updateCollection('plain', [], true, true)); + + // accepted after + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'after', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $this->assertSame(100000, $this->database->getDocument('plain', 'after')->getAttribute('salary')); + } + + /** + * Disabling is allowed whatever the collection holds. With the flag off the column + * half of a permission is inert, so read("user:hr", "salary") grants what + * read("user:hr") grants -- the row opens up rather than staying half-enforced. + */ + public function testDisablingIsAllowedWhileColumnPermissionsExist(): void + { + $this->collection('secured', true); + + $this->authorization->skip(fn () => $this->database->createDocument('secured', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $this->assertNull($this->database->getDocument('secured', 'd1')->getAttribute('name')); + + $this->authorization->skip(fn () => $this->database->updateCollection('secured', [], true, false)); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('secured', 'd1'); + + $this->assertSame('Bob', $document->getAttribute('name')); + $this->assertSame(100000, $document->getAttribute('salary')); + } + + /** + * Nothing is rewritten on the way out, so the restriction comes back intact. + */ + public function testReenablingRestoresTheRestriction(): void + { + $this->collection('secured', true); + + $this->authorization->skip(function () { + $this->database->createDocument('secured', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ])); + + $this->database->updateCollection('secured', [], true, false); + $this->database->updateCollection('secured', [], true, true); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('secured', 'd1'); + + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertNull($document->getAttribute('name')); + } + + public function testDisablingIsAllowedOnceTheyAreRemoved(): void + { + $this->collection('secured', true); + + $this->authorization->skip(function () { + $this->database->createDocument('secured', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ])); + + // narrow it back to an ordinary permission + $this->database->updateDocument('secured', 'd1', new Document([ + '$permissions' => [Permission::read(Role::user('hr'))], + ])); + + $this->database->updateCollection('secured', [], true, false); + }); + + $collection = $this->authorization->skip(fn () => $this->database->getCollection('secured')); + + $this->assertFalse($collection->getAttribute('columnSecurity')); + } + + /** + * A document that already carries a column-scoped permission must stay editable, + * or it would be stranded the moment the flag changed. + */ + public function testExistingColumnPermissionsDoNotBlockOrdinaryUpdates(): void + { + $this->collection('secured', true); + + $this->authorization->skip(function () { + $this->database->createDocument('secured', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 100000, + ])); + + // same permissions, different value + $this->database->updateDocument('secured', 'd1', new Document([ + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Robert', + ])); + }); + + $stored = $this->authorization->skip(fn () => $this->database->getDocument('secured', 'd1')); + + $this->assertSame('Robert', $stored->getAttribute('name')); + + // the grant came through the update intact: hr still reads salary, and the + // scope is still a scope -- the name it was never granted stays masked + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + $document = $this->database->getDocument('secured', 'd1'); + + $this->assertSame(100000, $document->getAttribute('salary')); + $this->assertNull($document->getAttribute('name')); + } + + /** + * Regression: the permissions table's unique index and the ON CONFLICT target it + * is resolved against have to name the same columns. Widening the index while + * leaving the target alone made every skipDuplicates insert fail on Postgres with + * "no unique or exclusion constraint matching the ON CONFLICT specification", + * which is why the table's shape follows the flag rather than always carrying + * _column. + */ + public function testSkipDuplicatesWorksWithTheFlagOff(): void + { + $this->collection('plain', false); + + $write = fn () => $this->database->skipDuplicates( + fn () => $this->database->createDocuments('plain', [ + new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any())], + 'name' => 'Bob', + ]), + ]) + ); + + $this->authorization->skip($write); + $this->authorization->skip($write); // same ids again -- the conflict path + + $this->assertSame(1, $this->authorization->skip(fn () => $this->database->count('plain'))); + } + + public function testSkipDuplicatesWorksWithTheFlagOn(): void + { + $this->collection('secured', true); + + $write = fn () => $this->database->skipDuplicates( + fn () => $this->database->createDocuments('secured', [ + new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'), 'salary')], + 'name' => 'Bob', + 'salary' => 1, + ]), + ]) + ); + + $this->authorization->skip($write); + $this->authorization->skip($write); + + $this->assertSame(1, $this->authorization->skip(fn () => $this->database->count('secured'))); + } + + // ---------------------------------------------------------------- reads unchanged + + public function testQueriesAreUnchangedWithTheFlagOff(): void + { + $this->collection('plain', false); + + $this->authorization->skip(fn () => $this->database->createDocument('plain', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::user('hr'))], + 'name' => 'Bob', + 'salary' => 100000, + ]))); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('user:hr'); + + // no column gate is emitted, so a filter on any column behaves as it always did + $this->assertCount(1, $this->database->find('plain', [Query::greaterThan('salary', 1)])); + $this->assertSame(100000, $this->database->sum('plain', 'salary')); + $this->assertSame(1, $this->database->count('plain')); + } +} diff --git a/tests/unit/HashAwareMemoryCache.php b/tests/unit/HashAwareMemoryCache.php index 7a72186045..f43bf12353 100644 --- a/tests/unit/HashAwareMemoryCache.php +++ b/tests/unit/HashAwareMemoryCache.php @@ -21,8 +21,11 @@ public function load(string $key, int $ttl, string $hash = ''): mixed * @param array|string $data * @return bool|string|array */ - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { + // $ttl is declared to match the adapter and deliberately not forwarded: this + // stub exists to make cache keys hash-aware, and the in-memory adapter it + // extends does not expire entries regardless of what it is given. return parent::save($this->field($key, $hash), $data); } diff --git a/tests/unit/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index 25cf69da9a..2dabb73631 100644 --- a/tests/unit/MongoPermissionStringsTest.php +++ b/tests/unit/MongoPermissionStringsTest.php @@ -7,6 +7,7 @@ use ReflectionMethod; use Utopia\Database\Adapter\Mongo; use Utopia\Database\Database; +use Utopia\Database\Document; use Utopia\Database\Validator\Authorization; class MongoPermissionStringsTest extends TestCase @@ -77,8 +78,12 @@ private function permissionStrings(array $roles, string $type): array $method = new ReflectionMethod(Mongo::class, 'permissionStrings'); + // These cases are about how a role becomes a match string, which does not + // involve columns; an attribute-less collection keeps that the only variable. + $collection = new Document(['$id' => 'test', 'attributes' => []]); + /** @var list $values */ - $values = $method->invoke($adapter, $type); + $values = $method->invoke($adapter, $type, $collection); return $values; } diff --git a/tests/unit/QueryCacheTest.php b/tests/unit/QueryCacheTest.php index 8103a0dd27..cec7316f28 100644 --- a/tests/unit/QueryCacheTest.php +++ b/tests/unit/QueryCacheTest.php @@ -875,7 +875,7 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; @@ -963,7 +963,7 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return \json_decode($saved['data'], true); } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if ($key === '' || empty($data)) { return false; diff --git a/tests/unit/WithCacheLeaseTest.php b/tests/unit/WithCacheLeaseTest.php index c35682a905..87bc8bf404 100644 --- a/tests/unit/WithCacheLeaseTest.php +++ b/tests/unit/WithCacheLeaseTest.php @@ -124,7 +124,7 @@ public function load(string $key, int $ttl, string $hash = ''): mixed return ($saved['time'] + $ttl > \time()) ? $saved['data'] : false; } - public function save(string $key, array|string $data, string $hash = ''): bool|string|array + public function save(string $key, array|string $data, string $hash = '', int $ttl = 0): bool|string|array { if (empty($key) || empty($data)) { return false;