From 635a5941f4d34872512dcabaac0d2bd51643f86d Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 8 Sep 2026 08:40:31 +0300 Subject: [PATCH 01/46] POC --- src/Database/Adapter.php | 26 + src/Database/Adapter/MariaDB.php | 43 +- src/Database/Adapter/Memory.php | 24 + src/Database/Adapter/Mongo.php | 25 + src/Database/Adapter/Pool.php | 15 + src/Database/Adapter/Postgres.php | 33 +- src/Database/Adapter/Redis.php | 24 + src/Database/Adapter/SQL.php | 282 ++++++++-- src/Database/Adapter/SQLite.php | 32 +- src/Database/Database.php | 483 +++++++++++++++++- src/Database/Document.php | 37 +- src/Database/Helpers/Permission.php | 95 +++- src/Database/Validator/Permissions.php | 35 +- .../unit/ColumnPermissionEnforcementTest.php | 229 +++++++++ tests/unit/ColumnPermissionQueryTest.php | 193 +++++++ tests/unit/ColumnPermissionTest.php | 171 +++++++ 16 files changed, 1653 insertions(+), 94 deletions(-) create mode 100644 tests/unit/ColumnPermissionEnforcementTest.php create mode 100644 tests/unit/ColumnPermissionQueryTest.php create mode 100644 tests/unit/ColumnPermissionTest.php diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4d2f0ee38f..4acae02a81 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1011,6 +1011,32 @@ 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; + + /** + * Repoint column-scoped permissions at a renamed column. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return array ids of documents whose $permissions changed + */ + abstract public function renameColumnPermissions(Document $collection, string $old, string $new): array; + + /** + * Drop every permission scoped to a column that no longer exists. + * + * @param Document $collection + * @param string $column + * @return array ids of documents whose $permissions changed + */ + abstract public function deleteColumnPermissions(Document $collection, string $column): array; + /** * Are schema indexes supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6d2aac8ef7..8676742e53 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -189,11 +189,21 @@ 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 _index1. + // + // Sized to MAX_UID_DEFAULT_LENGTH, not 255: these tables are utf8mb4 and + // _index1 already costs ~2097 of InnoDB's 3072-byte key limit, so a + // VARCHAR(255) member would overflow it and the index would fail to build. $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_UID_DEFAULT_LENGTH . ") CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, PRIMARY KEY (_id), "; @@ -201,12 +211,12 @@ public function createCollection(string $name, array $attributes = [], array $in if ($this->sharedTables) { $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission), + UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column), INDEX _permission (_tenant, _permission, _type) "; } else { $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission), + UNIQUE INDEX _index1 (_document, _type, _permission, _column), INDEX _permission (_permission, _type) "; } @@ -895,12 +905,14 @@ public function createDocument(Document $collection, Document $document): Docume } $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 +921,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 +930,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(); @@ -1007,10 +1022,11 @@ 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[":_add_{$type}_{$i}"] = $permission['role']; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } @@ -1018,7 +1034,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 +1787,11 @@ public function getSupportForUpsertOnUniqueIndex(): bool return true; } + public function getSupportForColumnPermissions(): bool + { + return true; + } + public function getSupportForSchemaAttributes(): bool { return true; diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 5e126a7177..d3ad822c77 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2086,6 +2086,30 @@ public function getSchemaIndexes(string $collection): array return []; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + /** + * Column-level permissions are not supported by this adapter, so a rename + * can never have column-scoped permissions to repoint. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return array + */ + public function renameColumnPermissions(Document $collection, string $old, string $new): array + { + return []; + } + + public function deleteColumnPermissions(Document $collection, string $column): array + { + return []; + } + public function getTenantQuery(string $collection, string $alias = ''): string { return ''; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 136ebac0f2..7e99b66c9f 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4202,6 +4202,30 @@ public function decodePolygon(string $wkb): array return []; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + /** + * Column-level permissions are not supported by this adapter, so a rename + * can never have column-scoped permissions to repoint. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return array + */ + public function renameColumnPermissions(Document $collection, string $old, string $new): array + { + return []; + } + + public function deleteColumnPermissions(Document $collection, string $column): array + { + return []; + } + /** * Get the query to check for tenant when in shared tables mode * @@ -4209,6 +4233,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..8a03059fc6 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -522,6 +522,21 @@ public function getSupportForAttributes(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + public function getSupportForColumnPermissions(): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function renameColumnPermissions(Document $collection, string $old, string $new): array + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function deleteColumnPermissions(Document $collection, string $column): array + { + 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 1241123264..2659f06a40 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -256,6 +256,7 @@ public function createCollection(string $name, array $attributes = [], array $in _tenant INTEGER DEFAULT NULL, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, + _column VARCHAR(" . Database::MAX_UID_DEFAULT_LENGTH . ") NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL ); "; @@ -265,7 +266,7 @@ public function createCollection(string $name, array $attributes = [], array $in $permissionIndex = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_permission"); $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 \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_tenant,_permission,_type); "; @@ -274,7 +275,7 @@ public function createCollection(string $name, array $attributes = [], array $in $permissionIndex = $this->getShortKey("{$namespace}_{$id}_permission"); $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 \"{$permissionIndex}\" ON {$this->getSQLTable($id . '_perms')} USING btree (_permission,_type); "; @@ -1046,11 +1047,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 +1064,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 +1074,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 +1140,11 @@ 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[":_add_{$type}_{$i}"] = $permission['role']; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } @@ -1144,7 +1152,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); @@ -2090,6 +2098,11 @@ public function getSupportForIntegerBooleans(): bool * * @return bool */ + public function getSupportForColumnPermissions(): bool + { + return true; + } + public function getSupportForSchemaAttributes(): bool { return false; diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 81f3350634..8eed228caa 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -771,6 +771,30 @@ public function setSupportForAttributes(bool $support): bool return true; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + /** + * Column-level permissions are not supported by this adapter, so a rename + * can never have column-scoped permissions to repoint. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return array + */ + public function renameColumnPermissions(Document $collection, string $old, string $new): array + { + return []; + } + + public function deleteColumnPermissions(Document $collection, string $column): array + { + return []; + } + public function getSupportForSchemaAttributes(): bool { return false; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 9ca4c1aee3..0904b7ed1d 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; @@ -629,7 +630,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 +655,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 +685,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 +711,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 +721,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; + + $columnBindKey = 'addcol_' . $type . '_' . $index . '_' . $i; + $addBindValues[$columnBindKey] = $column; - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; if ($this->sharedTables) { $addQuery .= ", :_tenant)"; @@ -749,7 +772,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) { @@ -1987,6 +2010,187 @@ protected function getSQLPermissionsCondition( )"; } + public function getSupportForColumnPermissions(): bool + { + return false; + } + + /** + * Repoint column-scoped permissions at a renamed column. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return array ids of documents whose $permissions changed + * @throws DatabaseException + */ + public function renameColumnPermissions(Document $collection, string $old, string $new): array + { + return $this->repointColumnPermissions($collection, $old, $new); + } + + /** + * Drop every permission scoped to a column that no longer exists. + * + * Required, not hygiene: because permissions name the column by key, leaving + * rows behind means re-creating a column under the same name inherits the old + * column's grants. + * + * @param Document $collection + * @param string $column + * @return array ids of documents whose $permissions changed + * @throws DatabaseException + */ + public function deleteColumnPermissions(Document $collection, string $column): array + { + return $this->repointColumnPermissions($collection, $column, null); + } + + /** + * 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 $old + * @param string|null $new new column key, or null to drop the permissions + * @return array ids of documents whose $permissions changed + * @throws DatabaseException + */ + private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + { + $name = $this->filter($collection->getId()); + $tenantQuery = $this->getTenantQuery($collection->getId()); + + $stmt = $this->getPDO()->prepare(" + SELECT DISTINCT _document + FROM {$this->getSQLTable($name . '_perms')} + WHERE _column = :_column + {$tenantQuery} + "); + $stmt->bindValue(':_column', $old); + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $this->execute($stmt); + + $documents = $stmt->fetchAll(\PDO::FETCH_COLUMN); + $stmt->closeCursor(); + + if (empty($documents)) { + return []; + } + + if (\is_null($new)) { + $stmt = $this->getPDO()->prepare(" + DELETE FROM {$this->getSQLTable($name . '_perms')} + WHERE _column = :_old + {$tenantQuery} + "); + } else { + $stmt = $this->getPDO()->prepare(" + UPDATE {$this->getSQLTable($name . '_perms')} + SET _column = :_new + WHERE _column = :_old + {$tenantQuery} + "); + $stmt->bindValue(':_new', $new); + } + + $stmt->bindValue(':_old', $old); + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $this->execute($stmt); + + $placeholders = \implode(', ', \array_map( + fn ($index) => ":_uid_{$index}", + \array_keys($documents) + )); + + $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(); + + $update = $this->getPDO()->prepare(" + UPDATE {$this->getSQLTable($name)} + SET _permissions = :_permissions + WHERE _uid = :_uid + {$tenantQuery} + "); + + $updated = []; + + foreach ($rows as $row) { + $permissions = \json_decode($row['_permissions'] ?? '[]', true); + + if (!\is_array($permissions)) { + continue; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->getColumn() !== $old) { + $rewritten[] = $permission; + continue; + } + + $changed = true; + + if (\is_null($new)) { + continue; + } + + $rewritten[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); + } + + if (!$changed) { + continue; + } + + $update->bindValue(':_permissions', \json_encode($rewritten)); + $update->bindValue(':_uid', $row['_uid']); + if ($this->sharedTables) { + $update->bindValue(':_tenant', $this->tenant); + } + $this->execute($update); + + $updated[] = $row['_uid']; + } + + return $updated; + } + /** * Get SQL table * @@ -2512,11 +2716,12 @@ 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 +2749,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 +3041,51 @@ 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[":remove_{$type}_{$index}_{$i}"] = $role; + $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; + } + $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 +3094,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 +3114,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"; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 3880aec167..2c4f25a532 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -421,6 +421,7 @@ public function createCollection(string $name, array $attributes = [], array $in {$tenantQuery} `_type` VARCHAR(12) NOT NULL, `_permission` VARCHAR(255) NOT NULL, + `_column` VARCHAR(" . Database::MAX_UID_DEFAULT_LENGTH . ") NOT NULL DEFAULT '', `_document` VARCHAR(255) NOT NULL ) "; @@ -440,7 +441,7 @@ 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", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission', '_column'], [], []); $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission', '_type'], [], []); if ($this->sharedTables) { @@ -1206,11 +1207,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 +1222,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 +1303,11 @@ 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[":_add_{$type}_{$i}"] = $permission['role']; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } @@ -1306,7 +1315,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 +1522,11 @@ public function getSupportForGetConnectionId(): bool * * @return bool */ + 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 1444286658..f2ff23a5f5 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -419,6 +419,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; /** @@ -3293,6 +3298,24 @@ public function updateAttribute(string $collection, string $id, ?string $type = if (!$updated) { throw new DatabaseException('Failed to update attribute'); } + + // A column-scoped permission names its column, in _perms._column and + // again inside the $permissions JSON, so a rename has to repoint both. + // There is no stable column id to hang permissions off: this method + // rewrites the attribute's '$id' and 'key' together, so the key is the + // only handle there is. The lookup is skipped entirely when no + // permission is scoped to this column, which is the common case. + if ( + !\is_null($newKey) + && $newKey !== $id + && $this->adapter->getSupportForColumnPermissions() + ) { + $repointed = $this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey); + + foreach ($repointed as $documentId) { + $this->purgeCachedDocument($collection, $documentId); + } + } } $collectionDoc->setAttribute('attributes', $attributes); @@ -3441,6 +3464,16 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } + // Permissions name their column by key, so grants left behind would be + // inherited by any column later created under the same name. + if ($this->adapter->getSupportForColumnPermissions()) { + $cleaned = $this->adapter->deleteColumnPermissions($collection, $id); + + foreach ($cleaned as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); + } + } + $this->updateMetadata( collection: $collection, rollbackOperation: fn () => $this->adapter->createAttribute( @@ -4984,6 +5017,8 @@ public function getDocument(string $collection, string $id, array $queries = [], } } + $document = $this->maskUnreadableColumns($collection, $document, $documentSecurity); + $this->trigger(self::EVENT_DOCUMENT_READ, $document); if ($this->isTtlExpired($collection, $document)) { @@ -5077,11 +5112,363 @@ public function getDocument(string $collection, string $id, array $queries = [], } } + $document = $this->maskUnreadableColumns($collection, $document, $documentSecurity); + $this->trigger(self::EVENT_DOCUMENT_READ, $document); return $document; } + /** + * 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 bool $documentSecurity + * @param string $type + * @return array|null + */ + private function getPermittedColumns( + Document $collection, + Document $document, + bool $documentSecurity, + string $type + ): ?array { + if (!$this->authorization->getStatus()) { + return null; + } + + $permissions = $collection->getPermissionsByTypeWithColumns($type); + + if ($documentSecurity) { + $permissions = [ + ...$permissions, + ...$document->getPermissionsByTypeWithColumns($type), + ]; + } + + $columns = []; + + foreach ($permissions as $permission) { + if (!$this->authorization->hasRole($permission['role'])) { + continue; + } + + // 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 \array_keys($columns); + } + + /** + * 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 + { + 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 empty($columns) ? null : \array_keys($columns); + } + + /** + * 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 ($queries as $query) { + $keys = $query->getMethod() === Query::TYPE_SELECT + ? $query->getValues() + : [$query->getAttribute()]; + + foreach ($keys as $key) { + // Internal fields are not columns; dotted keys are relationship paths. + if (!\is_string($key) || $key === '' || \str_starts_with($key, '$') || \str_contains($key, '.')) { + continue; + } + + if (!\in_array($key, $restriction, true)) { + throw new AuthorizationException('Missing "' . $type . '" permission for column "' . $key . '".'); + } + } + } + } + + /** + * 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, '$') || isset($relationships[$key])) { + continue; + } + + if (\is_null($value)) { + continue; + } + + 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 + * @param bool $documentSecurity + * @return void + * @throws AuthorizationException + */ + private function assertColumnsWritable( + Document $collection, + Document $old, + Document $document, + bool $documentSecurity + ): void { + $columns = $this->getPermittedColumns($collection, $old, $documentSecurity, self::PERMISSION_UPDATE); + + 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) { + // Internal fields are not columns. $permissions is deliberately not + // column-scoped: rewriting permissions stays a document-level right. + if (\str_starts_with($key, '$')) { + continue; + } + + if (\in_array($key, $columns, true) || isset($relationships[$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 + * @param bool $documentSecurity + * @return Document + */ + private function maskUnreadableColumns(Document $collection, Document $document, bool $documentSecurity): Document + { + if ($this->skipColumnMasking || $document->isEmpty() || $collection->getId() === self::METADATA) { + return $document; + } + + $columns = $this->getPermittedColumns($collection, $document, $documentSecurity, self::PERMISSION_READ); + + if ($columns === null) { + return $document; + } + + $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; + } + + /** + * 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 + * @param bool $documentSecurity + * @return void + */ + private function preserveHiddenPermissions( + Document $collection, + Document $old, + Document $document, + bool $documentSecurity + ): void { + if (!$document->offsetExists('$permissions')) { + return; + } + + $columns = $this->getPermittedColumns($collection, $old, $documentSecurity, 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()) { @@ -5740,6 +6127,8 @@ public function createDocument(string $collection, Document $document): Document if (!$isValid) { throw new AuthorizationException($this->authorization->getDescription()); } + + $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); } $time = DateTime::now(); @@ -5862,6 +6251,10 @@ 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->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); + } } $time = DateTime::now(); @@ -6314,6 +6707,13 @@ public function updateDocument(string $collection, string $id, Document $documen return new Document(); } + $this->preserveHiddenPermissions( + $collection, + $old, + $document, + $collection->getAttribute('documentSecurity', false) + ); + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -6473,6 +6873,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, $documentSecurity); } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { throw new AuthorizationException($this->authorization->getDescription()); @@ -6508,7 +6910,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); @@ -6615,6 +7017,11 @@ public function updateDocuments( throw new AuthorizationException($this->authorization->getDescription()); } + if ($collection->getId() !== self::METADATA) { + $this->assertColumnsAllowed($collection, $updates, self::PERMISSION_UPDATE); + $this->assertColumnsQueryable($collection, $queries); + } + $attributes = $collection->getAttribute('attributes', []); $indexes = $collection->getAttribute('indexes', []); @@ -6703,11 +7110,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; @@ -6717,7 +7124,7 @@ public function updateDocuments( $currentPermissions = $updates->getPermissions(); sort($currentPermissions); - $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { + $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions, $documentSecurity) { foreach ($batch as $index => $document) { $skipPermissionsUpdate = true; @@ -6737,8 +7144,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, $documentSecurity); + if ($this->resolveRelationships) { - $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new)); + $this->unmasked(fn () => $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new))); } $document = $new; @@ -7510,11 +7921,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, $documentSecurity); } $updatedAt = $document->getUpdatedAt(); @@ -7767,6 +8184,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, $documentSecurity, 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)) { @@ -7868,6 +8293,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, $documentSecurity, 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)) { @@ -8002,10 +8435,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, $value, $relationType, $twoWay, $twoWayKey, $side); + $this->unmasked(fn () => $this->deleteSetNull($collection, $relatedCollection, $document, $value, $relationType, $twoWay, $twoWayKey, $side)); break; case Database::RELATION_MUTATE_CASCADE: foreach ($this->relationshipDeleteStack as $processedRelationship) { @@ -8052,7 +8485,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; } } @@ -8443,11 +8876,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; @@ -8657,6 +9090,8 @@ public function find(string $collection, array $queries = [], string $forPermiss throw new AuthorizationException($this->authorization->getDescription()); } + $this->assertColumnsQueryable($collection, $queries, $forPermission); + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP @@ -8780,6 +9215,12 @@ 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, $documentSecurity); + $results[$index] = $node; } @@ -9174,6 +9615,8 @@ public function count(string $collection, array $queries = [], ?int $max = null) throw new AuthorizationException($this->authorization->getDescription()); } + $this->assertColumnsQueryable($collection, $queries); + $relationships = \array_filter( $collection->getAttribute('attributes', []), fn (Document $attribute) => $attribute->getAttribute('type') === self::VAR_RELATIONSHIP @@ -9248,6 +9691,16 @@ public function sum(string $collection, string $attribute, array $queries = [], throw new AuthorizationException($this->authorization->getDescription()); } + $this->assertColumnsQueryable($collection, $queries); + + // The aggregated column itself is read, so it needs the same permission a + // filter on it would. Without this, sum() extracts a masked column in one call. + $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 diff --git a/src/Database/Document.php b/src/Database/Document.php index 73bd458cd5..cd624d0ad9 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 @@ -150,14 +151,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/Validator/Permissions.php b/src/Database/Validator/Permissions.php index 13e7372050..d03e997741 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -16,16 +16,26 @@ 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 known column keys a permission may be scoped to. Empty means any valid key. */ - 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(); } /** @@ -96,6 +106,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 (!empty($this->columns) && !\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/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php new file mode 100644 index 0000000000..11b8c9ce1a --- /dev/null +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -0,0 +1,229 @@ +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); + + 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, permissions: [ + Permission::read(Role::any(), 'name'), + ]); + + foreach (['name', 'email', 'salary'] as $column) { + $this->database->createAttribute('public_employees', $column, Database::VAR_STRING, 128, false); + } + + $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'); + } + + 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')); + } + + 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..95539f3643 --- /dev/null +++ b/tests/unit/ColumnPermissionQueryTest.php @@ -0,0 +1,193 @@ +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, permissions: [ + Permission::read(Role::any(), 'name'), + Permission::create(Role::any(), 'name'), + ]); + + $this->database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + + $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'])])); + } + + /** + * Masking hides the value, but an unguarded filter turns the result set into an + * oracle for it. + */ + public function testFilterOnUnreadableColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "read" permission for column "salary"'); + + $this->database->find('employees', [Query::greaterThan('salary', 1)]); + } + + public function testOrderByUnreadableColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + + $this->database->find('employees', [Query::orderDesc('salary')]); + } + + public function testSelectOfUnreadableColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + + $this->database->find('employees', [Query::select(['salary'])]); + } + + public function testCountFilteredByUnreadableColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + + $this->database->count('employees', [Query::equal('salary', [100000])]); + } + + /** + * Without this guard sum() extracts a masked column in a single call. + */ + public function testSumOfUnreadableColumnIsRejected(): void + { + $this->expectException(AuthorizationException::class); + $this->expectExceptionMessage('Missing "read" permission for column "salary"'); + + $this->database->sum('employees', 'salary'); + } + + 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/ColumnPermissionTest.php b/tests/unit/ColumnPermissionTest.php new file mode 100644 index 0000000000..922177365b --- /dev/null +++ b/tests/unit/ColumnPermissionTest.php @@ -0,0 +1,171 @@ +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 must still resolve to a bare role, or every + * existing document-level authorization check silently breaks. + */ + public function testDocumentPermissionsByTypeReturnsRolesOnly(): void + { + $document = new Document(['$permissions' => [ + 'read("any")', + 'read("user:1", "salary")', + 'update("user:1", "name")', + 'delete("user:1")', + ]]); + + $this->assertSame(['any', 'user:1'], \array_values($document->getRead())); + $this->assertSame(['user:1'], \array_values($document->getUpdate())); + $this->assertSame(['user:1'], \array_values($document->getDelete())); + } + + public function testDocumentPermissionsByTypeWithColumns(): void + { + $document = new Document(['$permissions' => [ + 'read("any")', + 'read("user:1", "salary")', + ]]); + + $this->assertSame([ + ['role' => 'any', 'column' => Permission::COLUMN_ALL], + ['role' => 'user:1', 'column' => 'salary'], + ], $document->getPermissionsByTypeWithColumns('read')); + } + + public function testValidatorAcceptsColumnScopedReadCreateUpdate(): void + { + $validator = new Permissions(); + + $this->assertTrue($validator->isValid([ + 'read("user:1", "salary")', + 'create("users", "name")', + 'update("team:abc/owner", "name")', + ]), $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()); + } +} From 429c7bef48423a8080db0c7cf03548f8fd2b35d7 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 8 Sep 2026 18:46:26 +0300 Subject: [PATCH 02/46] VARCHAR(255) --- src/Database/Adapter/MariaDB.php | 14 ++++++++------ src/Database/Adapter/Postgres.php | 2 +- src/Database/Adapter/SQLite.php | 2 +- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 8676742e53..fec9f5d9d3 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -195,15 +195,17 @@ public function createCollection(string $name, array $attributes = [], array $in // NULLs as distinct in a UNIQUE index, so a nullable _column would let // duplicate permission rows slip past _index1. // - // Sized to MAX_UID_DEFAULT_LENGTH, not 255: these tables are utf8mb4 and - // _index1 already costs ~2097 of InnoDB's 3072-byte key limit, so a - // VARCHAR(255) member would overflow it and the index would fail to build. + // _index1 indexes it by prefix, not in full: these tables are utf8mb4 and the + // other four members already cost ~2098 of InnoDB's 3072-byte key limit, so a + // full VARCHAR(255) member would take it to ~3120 and the index would fail to + // build. MAX_UID_DEFAULT_LENGTH is the longest a column key may be, so the + // prefix is full uniqueness for every value that can actually be stored. $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_UID_DEFAULT_LENGTH . ") CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', + _column VARCHAR(255) NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, PRIMARY KEY (_id), "; @@ -211,12 +213,12 @@ public function createCollection(string $name, array $attributes = [], array $in if ($this->sharedTables) { $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column), + UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column(" . Database::MAX_UID_DEFAULT_LENGTH . ")), INDEX _permission (_tenant, _permission, _type) "; } else { $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission, _column), + UNIQUE INDEX _index1 (_document, _type, _permission, _column(" . Database::MAX_UID_DEFAULT_LENGTH . ")), INDEX _permission (_permission, _type) "; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2659f06a40..ed2bb255ae 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -256,7 +256,7 @@ public function createCollection(string $name, array $attributes = [], array $in _tenant INTEGER DEFAULT NULL, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, - _column VARCHAR(" . Database::MAX_UID_DEFAULT_LENGTH . ") NOT NULL DEFAULT '', + _column VARCHAR(255) NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL ); "; diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 2c4f25a532..da66094d53 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -421,7 +421,7 @@ public function createCollection(string $name, array $attributes = [], array $in {$tenantQuery} `_type` VARCHAR(12) NOT NULL, `_permission` VARCHAR(255) NOT NULL, - `_column` VARCHAR(" . Database::MAX_UID_DEFAULT_LENGTH . ") NOT NULL DEFAULT '', + `_column` VARCHAR(255) NOT NULL DEFAULT '', `_document` VARCHAR(255) NOT NULL ) "; From 128a18756ece856d68f2e04a5fe819b3922cf997 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 14 Sep 2026 18:00:33 +0300 Subject: [PATCH 03/46] Repoint --- src/Database/Adapter.php | 9 +- src/Database/Adapter/MariaDB.php | 18 +- src/Database/Adapter/Memory.php | 72 ++++- src/Database/Adapter/Mongo.php | 45 ++- src/Database/Adapter/Pool.php | 6 +- src/Database/Adapter/Postgres.php | 26 ++ src/Database/Adapter/Redis.php | 6 +- src/Database/Adapter/SQL.php | 136 ++++++++- src/Database/Database.php | 280 +++++++++++++++--- .../unit/ColumnPermissionEnforcementTest.php | 94 ++++++ tests/unit/ColumnPermissionQueryTest.php | 109 +++++-- tests/unit/MongoPermissionStringsTest.php | 41 ++- 12 files changed, 737 insertions(+), 105 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 4acae02a81..339dedd088 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 diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index fec9f5d9d3..815cb7354d 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -195,17 +195,19 @@ public function createCollection(string $name, array $attributes = [], array $in // NULLs as distinct in a UNIQUE index, so a nullable _column would let // duplicate permission rows slip past _index1. // - // _index1 indexes it by prefix, not in full: these tables are utf8mb4 and the - // other four members already cost ~2098 of InnoDB's 3072-byte key limit, so a - // full VARCHAR(255) member would take it to ~3120 and the index would fail to - // build. MAX_UID_DEFAULT_LENGTH is the longest a column key may be, so the - // prefix is full uniqueness for every value that can actually be stored. + // Declared ASCII rather than inheriting utf8mb4, so _index1 can hold it in + // full: the other four members already cost ~2098 of InnoDB's 3072-byte key + // limit, and a utf8mb4 VARCHAR(255) would add 1022 and overflow it. ASCII + // costs 257, landing at ~2355. Safe because the Key validator restricts a + // column key to /[^A-Za-z0-9_\-\.]/, so a non-ASCII key cannot exist -- and + // indexing the whole value means uniqueness does not depend on a prefix + // length matching a validator constant in another file. $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(255) NOT NULL DEFAULT '', + _column VARCHAR(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, PRIMARY KEY (_id), "; @@ -213,12 +215,12 @@ public function createCollection(string $name, array $attributes = [], array $in if ($this->sharedTables) { $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column(" . Database::MAX_UID_DEFAULT_LENGTH . ")), + UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column), INDEX _permission (_tenant, _permission, _type) "; } else { $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission, _column(" . Database::MAX_UID_DEFAULT_LENGTH . ")), + UNIQUE INDEX _index1 (_document, _type, _permission, _column), INDEX _permission (_permission, _type) "; } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index d3ad822c77..8981a8dbd1 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); @@ -2088,7 +2089,7 @@ public function getSchemaIndexes(string $collection): array public function getSupportForColumnPermissions(): bool { - return false; + return true; } /** @@ -2575,7 +2576,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)) { @@ -2612,6 +2618,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)) { @@ -2629,6 +2641,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 7e99b66c9f..e690085240 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2461,13 +2461,41 @@ 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 { + $columns = []; + + foreach ($collection->getAttribute('attributes', []) as $attribute) { + $key = $attribute['key'] ?? $attribute['$id'] ?? null; + + if (\is_string($key) && $key !== '') { + $columns[$key] = true; + } + } + + $columns = \array_keys($columns); + $permissions = []; + foreach ($this->authorization->getRoles() as $role) { $permissions[] = $type . '("' . $role . '")'; + + foreach ($columns as $column) { + $permissions[] = $type . '("' . $role . '", "' . $column . '")'; + } } return $permissions; @@ -2488,11 +2516,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,7 +2538,7 @@ 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); } $options = []; @@ -2738,10 +2767,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,7 +2791,7 @@ 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); } /** @@ -2842,11 +2872,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,7 +2891,7 @@ 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); } // using aggregation to get sum an attribute as described in diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 8a03059fc6..59c98b1c77 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()); } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index ed2bb255ae..39b8dd7b2a 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -1854,6 +1854,32 @@ 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. + // + // 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) . ')'; } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 8eed228caa..3b1f87b82c 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -2845,7 +2845,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); @@ -2884,7 +2884,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); @@ -2920,7 +2920,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 0904b7ed1d..e1640dacc9 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2000,13 +2000,26 @@ 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')} )"; } @@ -2191,6 +2204,67 @@ private function repointColumnPermissions(Document $collection, string $old, ?st return $updated; } + /** + * 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 * @@ -3192,12 +3266,13 @@ 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 { $collection = $collection->getId(); $name = $this->filter($collection); @@ -3298,10 +3373,27 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $where[] = $conditions; } - if ($this->authorization->getStatus()) { + // 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); } + foreach ($columnConditions as $condition) { + $where[] = $condition; + } + if ($this->sharedTables) { $binds[':_tenant'] = $this->tenant; $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; @@ -3420,11 +3512,12 @@ 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 { $collection = $collection->getId(); $name = $this->filter($collection); @@ -3453,10 +3546,18 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $where[] = $conditions; } - if ($this->authorization->getStatus()) { + // 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); } + foreach ($columnConditions as $condition) { + $where[] = $condition; + } + if ($this->sharedTables) { $binds[':_tenant'] = $this->tenant; $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; @@ -3513,11 +3614,12 @@ 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 { $collection = $collection->getId(); $name = $this->filter($collection); @@ -3547,10 +3649,18 @@ public function sum(Document $collection, string $attribute, array $queries = [] $where[] = $conditions; } - if ($this->authorization->getStatus()) { + // 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); } + foreach ($columnConditions as $condition) { + $where[] = $condition; + } + if ($this->sharedTables) { $binds[':_tenant'] = $this->tenant; $where[] = "{$this->getTenantQuery($collection, $alias, condition: '')}"; diff --git a/src/Database/Database.php b/src/Database/Database.php index f2ff23a5f5..607c42e30e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3305,14 +3305,10 @@ public function updateAttribute(string $collection, string $id, ?string $type = // rewrites the attribute's '$id' and 'key' together, so the key is the // only handle there is. The lookup is skipped entirely when no // permission is scoped to this column, which is the common case. - if ( - !\is_null($newKey) - && $newKey !== $id - && $this->adapter->getSupportForColumnPermissions() - ) { - $repointed = $this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey); + if (!\is_null($newKey) && $newKey !== $id) { + $this->repointCollectionColumnPermissions($collectionDoc, $id, $newKey); - foreach ($repointed as $documentId) { + foreach ($this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey) as $documentId) { $this->purgeCachedDocument($collection, $documentId); } } @@ -3466,12 +3462,10 @@ public function deleteAttribute(string $collection, string $id): bool // Permissions name their column by key, so grants left behind would be // inherited by any column later created under the same name. - if ($this->adapter->getSupportForColumnPermissions()) { - $cleaned = $this->adapter->deleteColumnPermissions($collection, $id); + $this->repointCollectionColumnPermissions($collection, $id, null); - foreach ($cleaned as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); } $this->updateMetadata( @@ -5169,6 +5163,52 @@ private function getPermittedColumns( return \array_keys($columns); } + /** + * 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. * @@ -5207,6 +5247,25 @@ private function unmasked(callable $callback): mixed * @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; @@ -5226,7 +5285,76 @@ private function getCollectionColumnRestriction(Document $collection, string $ty $columns[$permission['column']] = true; } - return empty($columns) ? null : \array_keys($columns); + 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; + } + + $key = $query->getAttribute(); + + // Internal fields are not columns; dotted keys are relationship paths. + 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); } /** @@ -5252,20 +5380,9 @@ private function assertColumnsQueryable(Document $collection, array $queries, st return; } - foreach ($queries as $query) { - $keys = $query->getMethod() === Query::TYPE_SELECT - ? $query->getValues() - : [$query->getAttribute()]; - - foreach ($keys as $key) { - // Internal fields are not columns; dotted keys are relationship paths. - if (!\is_string($key) || $key === '' || \str_starts_with($key, '$') || \str_contains($key, '.')) { - continue; - } - - if (!\in_array($key, $restriction, true)) { - throw new AuthorizationException('Missing "' . $type . '" permission for column "' . $key . '".'); - } + foreach ($this->getQueriedColumns($queries) as $key) { + if (!\in_array($key, $restriction, true)) { + throw new AuthorizationException('Missing "' . $type . '" permission for column "' . $key . '".'); } } } @@ -5339,6 +5456,24 @@ private function assertColumnsWritable( 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')) { + $before = $old->getPermissions(); + $after = $document->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) { @@ -5347,8 +5482,7 @@ private function assertColumnsWritable( } foreach ($document as $key => $value) { - // Internal fields are not columns. $permissions is deliberately not - // column-scoped: rewriting permissions stays a document-level right. + // Internal fields are not columns; $permissions was handled above. if (\str_starts_with($key, '$')) { continue; } @@ -9090,7 +9224,24 @@ public function find(string $collection, array $queries = [], string $forPermiss throw new AuthorizationException($this->authorization->getDescription()); } - $this->assertColumnsQueryable($collection, $queries, $forPermission); + // 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 ($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', []), @@ -9189,7 +9340,8 @@ public function find(string $collection, array $queries = [], string $forPermiss $orderTypes, $cursor, $cursorDirection, - $forPermission + $forPermission, + $columnPermissions ); $results = $skipAuth ? $this->authorization->skip($getResults) : $getResults(); @@ -9615,7 +9767,24 @@ public function count(string $collection, array $queries = [], ?int $max = null) throw new AuthorizationException($this->authorization->getDescription()); } - $this->assertColumnsQueryable($collection, $queries); + // 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 ($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', []), @@ -9633,7 +9802,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, $columnPermissions); $count = $skipAuth ? $this->authorization->skip($getCount) : $getCount(); $this->trigger(self::EVENT_DOCUMENT_COUNT, $count); @@ -9691,14 +9860,45 @@ public function sum(string $collection, string $attribute, array $queries = [], throw new AuthorizationException($this->authorization->getDescription()); } - $this->assertColumnsQueryable($collection, $queries); + // 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 ($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 ($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 . '".'); + } - // The aggregated column itself is read, so it needs the same permission a - // filter on it would. Without this, sum() extracts a masked column in one call. - $columns = $this->getCollectionColumnRestriction($collection, self::PERMISSION_READ); + $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 . '".'); + if ($columns !== null && !\in_array($attribute, $columns, true)) { + throw new AuthorizationException('Missing "read" permission for column "' . $attribute . '".'); + } } $relationships = \array_filter( @@ -9716,7 +9916,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, $columnPermissions); $sum = $skipAuth ? $this->authorization->skip($getSum) : $getSum(); $this->trigger(self::EVENT_DOCUMENT_SUM, $sum); diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 11b8c9ce1a..85c2c70535 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -168,6 +168,38 @@ public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void $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, permissions: [ + Permission::read(Role::any(), 'name'), + ]); + + $this->database->createAttribute('scoped', 'name', Database::VAR_STRING, 128, false); + + $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()); + }); + } + public function testUpdateOfGrantedColumnIsAllowed(): void { $this->authorization->cleanRoles(); @@ -214,6 +246,68 @@ public function testUpdateIsRejectedWholesaleWhenOneColumnIsUngranted(): void $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()); + } + public function testUnscopedRoleMayUpdateAnyColumn(): void { $this->authorization->cleanRoles(); diff --git a/tests/unit/ColumnPermissionQueryTest.php b/tests/unit/ColumnPermissionQueryTest.php index 95539f3643..68bfa65fd5 100644 --- a/tests/unit/ColumnPermissionQueryTest.php +++ b/tests/unit/ColumnPermissionQueryTest.php @@ -92,47 +92,120 @@ public function testFilterOnReadableColumnIsAllowed(): void } /** - * Masking hides the value, but an unguarded filter turns the result set into an - * oracle for it. + * 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 testFilterOnUnreadableColumnIsRejected(): void + public function testFilterOnColumnTheCallerCannotReadAnywhereMatchesNothing(): void { - $this->expectException(AuthorizationException::class); - $this->expectExceptionMessage('Missing "read" permission for column "salary"'); + $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')); + } - $this->database->find('employees', [Query::greaterThan('salary', 1)]); + /** + * 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 testOrderByUnreadableColumnIsRejected(): void + public function testSumIsPartialOverRowsThatGrantTheColumn(): void { - $this->expectException(AuthorizationException::class); + $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->database->find('employees', [Query::orderDesc('salary')]); + $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')); } - public function testSelectOfUnreadableColumnIsRejected(): void + /** + * 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->expectException(AuthorizationException::class); + $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'); - $this->database->find('employees', [Query::select(['salary'])]); + // 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"); + } } - public function testCountFilteredByUnreadableColumnIsRejected(): void + public function testOrderByAColumnTheCallerCannotReadDropsThoseRows(): void { - $this->expectException(AuthorizationException::class); + $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->database->count('employees', [Query::equal('salary', [100000])]); + $this->assertCount(1, $results); + $this->assertNull($results[0]->getAttribute('salary')); } /** - * Without this guard sum() extracts a masked column in a single call. + * 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 testSumOfUnreadableColumnIsRejected(): void + public function testWithoutDocumentSecurityAnUnreadableColumnThrows(): void { + $this->authorization->skip(function () { + $this->database->createCollection('strict', documentSecurity: false, permissions: [ + Permission::read(Role::any(), 'name'), + ]); + $this->database->createAttribute('strict', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('strict', 'salary', Database::VAR_INTEGER, 8, false); + }); + + $this->authorization->cleanRoles(); + $this->authorization->addRole('any'); + $this->expectException(AuthorizationException::class); $this->expectExceptionMessage('Missing "read" permission for column "salary"'); - $this->database->sum('employees', 'salary'); + $this->database->find('strict', [Query::greaterThan('salary', 1)]); } public function testBulkUpdateOfGrantedColumnIsAllowed(): void diff --git a/tests/unit/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index 25cf69da9a..e3b41ffa82 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 @@ -59,11 +60,42 @@ public function testValuesAreStringsNotRegex(): void } } + /** + * Column-scoped permissions name the column inside the string, so the candidate + * list has to carry a variant per column. With no columns the output is exactly + * what it was before column-level permissions existed. + */ + public function testColumnsAddAVariantPerColumnAlongsideTheUnscopedGrant(): void + { + $this->assertSame( + [ + 'read("user:alice")', + 'read("user:alice", "name")', + 'read("user:alice", "salary")', + ], + $this->permissionStrings(['user:alice'], Database::PERMISSION_READ, ['name', 'salary']) + ); + } + + public function testColumnVariantsMatchThePermissionHelperSerialisation(): void + { + $strings = $this->permissionStrings(['user:alice'], Database::PERMISSION_READ, ['salary']); + + $this->assertContains( + \Utopia\Database\Helpers\Permission::read( + \Utopia\Database\Helpers\Role::user('alice'), + 'salary' + ), + $strings + ); + } + /** * @param list $roles + * @param list $columns * @return list */ - private function permissionStrings(array $roles, string $type): array + private function permissionStrings(array $roles, string $type, array $columns = []): array { $authorization = new Authorization(); $authorization->enable(); @@ -77,8 +109,13 @@ private function permissionStrings(array $roles, string $type): array $method = new ReflectionMethod(Mongo::class, 'permissionStrings'); + $collection = new Document([ + '$id' => 'test', + 'attributes' => \array_map(fn (string $column) => ['key' => $column], $columns), + ]); + /** @var list $values */ - $values = $method->invoke($adapter, $type); + $values = $method->invoke($adapter, $type, $collection); return $values; } From 15e7e68118a3ca64f9a779419d7efa949b4772ec Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 14 Sep 2026 18:00:53 +0300 Subject: [PATCH 04/46] Unit --- tests/unit/ColumnPermissionSqlTest.php | 231 +++++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 tests/unit/ColumnPermissionSqlTest.php diff --git a/tests/unit/ColumnPermissionSqlTest.php b/tests/unit/ColumnPermissionSqlTest.php new file mode 100644 index 0000000000..ada1813906 --- /dev/null +++ b/tests/unit/ColumnPermissionSqlTest.php @@ -0,0 +1,231 @@ +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 () { + $this->database->createCollection('employees', documentSecurity: true, permissions: [ + Permission::read(Role::any(), 'name'), + ]); + $this->database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); + $this->database->createAttribute('employees', 'salary', Database::VAR_INTEGER, 8, false); + + // 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; + } + + public function testColumnIsPersistedOnThePermissionsTable(): void + { + $rows = $this->authorization->skip( + fn () => $this->database->find('employees', [Query::equal('$id', ['e1'])]) + ); + + $this->assertSame(['read("user:hr", "salary")'], $rows[0]->getPermissions()); + } + + /** + * 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, 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')) + ); + } + + 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')); + } +} From afaf2e5bce5c1a0ed31ab6b31f84858f89227965 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 16 Sep 2026 09:59:30 +0300 Subject: [PATCH 05/46] check index before dropping column permissions --- src/Database/Adapter.php | 19 ++ src/Database/Adapter/MariaDB.php | 166 ++++++++++-- src/Database/Adapter/Memory.php | 10 + src/Database/Adapter/Mongo.php | 10 + src/Database/Adapter/Pool.php | 10 + src/Database/Adapter/Postgres.php | 143 +++++++++- src/Database/Adapter/Redis.php | 10 + src/Database/Adapter/SQL.php | 253 ++++++++++++++---- src/Database/Adapter/SQLite.php | 120 ++++++++- src/Database/Database.php | 205 ++++++++++++-- src/Database/Mirror.php | 14 +- .../unit/ColumnPermissionEnforcementTest.php | 6 +- tests/unit/ColumnPermissionQueryTest.php | 38 ++- tests/unit/ColumnPermissionSqlTest.php | 33 ++- 14 files changed, 906 insertions(+), 131 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 339dedd088..bc02d2da18 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1040,6 +1040,25 @@ abstract public function renameColumnPermissions(Document $collection, string $o */ abstract public function deleteColumnPermissions(Document $collection, string $column): array; + /** + * Prepare a collection's permissions table to hold column-scoped permissions. + * + * Tables created after column permissions existed are already in this shape, so + * this is a no-op for them; older ones gain the column and a widened unique index. + * + * @param Document $collection + * @return bool + */ + abstract public function prepareColumnPermissions(Document $collection): bool; + + /** + * Is any permission in this collection still scoped to a column? + * + * @param Document $collection + * @return bool + */ + abstract public function hasColumnPermissions(Document $collection): bool; + /** * Are schema indexes supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 815cb7354d..6ba174c15b 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -194,20 +194,12 @@ public function createCollection(string $name, array $attributes = [], array $in // 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 _index1. - // - // Declared ASCII rather than inheriting utf8mb4, so _index1 can hold it in - // full: the other four members already cost ~2098 of InnoDB's 3072-byte key - // limit, and a utf8mb4 VARCHAR(255) would add 1022 and overflow it. ASCII - // costs 257, landing at ~2355. Safe because the Key validator restricts a - // column key to /[^A-Za-z0-9_\-\.]/, so a non-ASCII key cannot exist -- and - // indexing the whole value means uniqueness does not depend on a prefix - // length matching a validator constant in another file. $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(255) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL DEFAULT '', + _column VARCHAR(255) NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, PRIMARY KEY (_id), "; @@ -843,6 +835,7 @@ public function createDocument(Document $collection, Document $document): Docume { try { $spatialAttributes = $this->getSpatialAttributes($collection); + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -908,24 +901,33 @@ public function createDocument(Document $collection, Document $document): Docume $attributeIndex++; } + // _column is named only when the collection enabled column security, so + // a table that never did is never referenced with it and needs no ALTER. + // Same shape as the _tenant conditional below. $permissions = []; $permissionBinds = []; foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantBind = $this->sharedTables ? ", :_tenant" : ''; $role = \str_replace('"', '', $permission['role']); - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$tenantBind})"; + + if ($columnSecurity) { + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$tenantBind})"; + } else { + $permissions[] = "('{$type}', '{$role}', :_uid {$tenantBind})"; + } } } if (!empty($permissions)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $permissions = \implode(', ', $permissions); $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$tenantColumn}) VALUES {$permissions}; "; @@ -951,10 +953,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: '_index1' is 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->getViolatedKey($e->getMessage()) === '_index1'; if (!$isOrphanedPermission) { throw $e; @@ -996,6 +1002,7 @@ public function updateDocument(Document $collection, string $id, Document $docum { try { $spatialAttributes = $this->getSpatialAttributes($collection); + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1028,17 +1035,24 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantPlaceholder})"; + + if ($columnSecurity) { + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantPlaceholder})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + } else { + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})"; + } + $binds[":_add_{$type}_{$i}"] = $permission['role']; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } if (!empty($values)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} {$tenantColumn}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1796,6 +1810,124 @@ public function getSupportForColumnPermissions(): bool return true; } + /** + * Give an older permissions table the shape column permissions need. + * + * Tables created since column permissions existed already have both parts, so + * this does nothing for them. For older ones it runs two changes with very + * different costs: adding the column is metadata-only and instant at any table + * size, while widening the unique index has to read every row. + * + * Both index changes go in a single statement on purpose. Dropping the old unique + * index first would leave a window with no uniqueness at all, during which + * duplicate permission rows could be inserted -- and the new index would then + * fail to build. + * + * @param Document $collection + * @return bool + * @throws DatabaseException + */ + public function prepareColumnPermissions(Document $collection): bool + { + $name = $this->filter($collection->getId()); + $table = $this->getSQLTable($name . '_perms'); + + $hasColumn = $this->hasColumnPermissionsColumn($name); + $hasIndex = $this->hasColumnPermissionsIndex($name); + + // Both halves are checked separately. A table created since column + // permissions existed already has both, so this is a no-op for it. And the + // two can genuinely disagree: adding the column is instant while rebuilding + // the index reads every row, so a prepare interrupted between them leaves the + // column in place and the index narrow. Keying the whole method off the + // column would then skip the rebuild for good, and two permissions scoped to + // different columns of one document would collide. + if ($hasColumn && $hasIndex) { + return true; + } + + $index = $this->sharedTables + ? '(_document, _tenant, _type, _permission, _column)' + : '(_document, _type, _permission, _column)'; + + try { + if (!$hasColumn) { + $this->getPDO()->prepare(" + ALTER TABLE {$table} + ADD COLUMN _column VARCHAR(255) NOT NULL DEFAULT '' + ")->execute(); + } + + if (!$hasIndex) { + // Dropped and added in one statement so uniqueness is never absent: + // splitting them leaves a window in which duplicate permission rows + // can land, and the rebuild then fails on them, leaving no unique + // index at all. + $this->getPDO()->prepare(" + ALTER TABLE {$table} + DROP INDEX _index1, + ADD UNIQUE INDEX _index1 {$index}, + ALGORITHM=INPLACE, LOCK=NONE + ")->execute(); + } + } catch (PDOException $e) { + throw $this->processException($e); + } + + return true; + } + + /** + * @param string $name filtered collection id + * @return bool + * @throws DatabaseException + */ + /** + * Does the unique permissions index already cover _column? + * + * @param string $name filtered collection id + * @return bool + * @throws DatabaseException + */ + protected function hasColumnPermissionsIndex(string $name): bool + { + $stmt = $this->getPDO()->prepare(" + SELECT 1 + FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = :schema + AND TABLE_NAME = :table + AND INDEX_NAME = '_index1' + AND COLUMN_NAME = '_column' + LIMIT 1 + "); + $stmt->bindValue(':schema', $this->getDatabase()); + $stmt->bindValue(':table', $this->getNamespace() . '_' . $name . '_perms'); + $stmt->execute(); + + $found = $stmt->fetchColumn(); + $stmt->closeCursor(); + + return $found !== false; + } + + protected function hasColumnPermissionsColumn(string $name): bool + { + $stmt = $this->getPDO()->prepare(" + SELECT 1 + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table AND COLUMN_NAME = '_column' + LIMIT 1 + "); + $stmt->bindValue(':schema', $this->getDatabase()); + $stmt->bindValue(':table', $this->getNamespace() . '_' . $name . '_perms'); + $stmt->execute(); + + $found = $stmt->fetchColumn(); + $stmt->closeCursor(); + + return $found !== false; + } + public function getSupportForSchemaAttributes(): bool { return true; diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 8981a8dbd1..d0619926ba 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2111,6 +2111,16 @@ public function deleteColumnPermissions(Document $collection, string $column): a return []; } + public function prepareColumnPermissions(Document $collection): bool + { + return false; + } + + public function hasColumnPermissions(Document $collection): bool + { + return false; + } + public function getTenantQuery(string $collection, string $alias = ''): string { return ''; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index e690085240..10a54f8cf4 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4257,6 +4257,16 @@ public function deleteColumnPermissions(Document $collection, string $column): a return []; } + public function prepareColumnPermissions(Document $collection): bool + { + return false; + } + + public function hasColumnPermissions(Document $collection): bool + { + return false; + } + /** * Get the query to check for tenant when in shared tables mode * diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 59c98b1c77..5ab9221bed 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -537,6 +537,16 @@ public function deleteColumnPermissions(Document $collection, string $column): a return $this->delegate(__FUNCTION__, \func_get_args()); } + public function prepareColumnPermissions(Document $collection): bool + { + return $this->delegate(__FUNCTION__, \func_get_args()); + } + + public function hasColumnPermissions(Document $collection): bool + { + 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 39b8dd7b2a..b2f813c4c7 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -990,6 +990,7 @@ public function renameIndex(string $collection, string $old, string $new): bool */ public function createDocument(Document $collection, Document $document): Document { + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1052,9 +1053,13 @@ public function createDocument(Document $collection, Document $document): Docume foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $role = \str_replace('"', '', $permission['role']); $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$sqlTenant})"; + if ($columnSecurity) { + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$sqlTenant})"; + } else { + $permissions[] = "('{$type}', '{$role}', :_uid {$sqlTenant})"; + } } } @@ -1062,9 +1067,10 @@ public function createDocument(Document $collection, Document $document): Docume if (!empty($permissions)) { $permissions = \implode(', ', $permissions); $sqlTenant = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$sqlTenant}) VALUES {$permissions} "; @@ -1110,6 +1116,7 @@ public function createDocument(Document $collection, Document $document): Docume public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { $spatialAttributes = $this->getSpatialAttributes($collection); + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1142,17 +1149,23 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$sqlTenant})"; + if ($columnSecurity) { + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$sqlTenant})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + } else { + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; + } + $binds[":_add_{$type}_{$i}"] = $permission['role']; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } if (!empty($values)) { $sqlTenant = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} {$sqlTenant}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1833,7 +1846,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); @@ -1860,6 +1874,13 @@ protected function getSQLPermissionsCondition( // 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 @@ -2129,6 +2150,98 @@ public function getSupportForColumnPermissions(): bool return true; } + /** + * Give an older permissions table the shape column permissions need. + * + * Postgres names indexes per schema rather than per table, so the unique index is + * dropped and recreated under the same generated name the table was built with. + * IF NOT EXISTS keeps this safe to run twice. + * + * @param Document $collection + * @return bool + * @throws DatabaseException + */ + public function prepareColumnPermissions(Document $collection): bool + { + $id = $this->filter($collection->getId()); + $table = $this->getSQLTable($id . '_perms'); + $namespace = $this->getNamespace(); + + if ($this->sharedTables) { + $unique = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); + $columns = '(_tenant,_document,_type,_permission,_column)'; + } else { + $unique = $this->getShortKey("{$namespace}_{$id}_ukey"); + $columns = '(_document COLLATE utf8_ci_ai,_type,_permission,_column)'; + } + + // A table created since column permissions existed already has both the + // column and the widened index, so there is nothing to do. Checked separately + // because a prepare interrupted between them -- the column is instant, the + // index reads every row -- leaves the column present and the index narrow. + $hasIndex = $this->hasColumnPermissionsIndex($unique); + + $staged = $this->getShortKey("{$unique}_staged"); + + try { + $this->getPDO()->prepare(" + ALTER TABLE {$table} + ADD COLUMN IF NOT EXISTS _column VARCHAR(255) NOT NULL DEFAULT '' + ")->execute(); + + if (!$hasIndex) { + // Build the replacement before dropping what it replaces. Postgres + // cannot drop and create an index in one statement, so doing it in + // that order would leave a window with no uniqueness at all -- and a + // duplicate inserted during that window makes the CREATE fail, + // leaving the table with no unique index rather than the old one. + // + // Safe in this order because the existing index is the stricter of + // the two: it forbids two rows sharing (document, type, permission) + // whatever their column, so nothing it allows can violate the wider + // one being built. The swap itself is metadata only. + $this->getPDO()->prepare("DROP INDEX IF EXISTS \"{$staged}\"")->execute(); + + $this->getPDO()->prepare(" + CREATE UNIQUE INDEX \"{$staged}\" ON {$table} USING btree {$columns} + ")->execute(); + + $this->getPDO()->prepare("DROP INDEX IF EXISTS \"{$unique}\"")->execute(); + + $this->getPDO()->prepare("ALTER INDEX \"{$staged}\" RENAME TO \"{$unique}\"")->execute(); + } + } catch (PDOException $e) { + throw $this->processException($e); + } + + return true; + } + + /** + * Does the named unique index already cover _column? + * + * @param string $index + * @return bool + * @throws DatabaseException + */ + protected function hasColumnPermissionsIndex(string $index): bool + { + $stmt = $this->getPDO()->prepare(" + SELECT 1 + FROM pg_indexes + WHERE indexname = :index + AND indexdef LIKE '%_column%' + LIMIT 1 + "); + $stmt->bindValue(':index', $index); + $stmt->execute(); + + $found = $stmt->fetchColumn(); + $stmt->closeCursor(); + + return $found !== false; + } + public function getSupportForSchemaAttributes(): bool { return false; @@ -2388,17 +2501,19 @@ protected function getInsertSuffix(string $table): string return "ON CONFLICT {$conflictTarget} DO NOTHING"; } - protected function getInsertPermissionsSuffix(): string + protected function getInsertPermissionsSuffix(bool $columnSecurity = false): string { if (!$this->skipDuplicates) { 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 3b1f87b82c..7ae4887dde 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -795,6 +795,16 @@ public function deleteColumnPermissions(Document $collection, string $column): a return []; } + public function prepareColumnPermissions(Document $collection): bool + { + return false; + } + + public function hasColumnPermissions(Document $collection): bool + { + return false; + } + public function getSupportForSchemaAttributes(): bool { return false; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index e1640dacc9..6781da491e 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -505,6 +505,7 @@ protected function getSpatialAttributes(Document $collection): array */ public function updateDocuments(Document $collection, Document $updates, array $documents): int { + $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($documents)) { return 0; } @@ -629,8 +630,10 @@ public function updateDocuments(Document $collection, Document $updates, array $ continue; } + $columnSelect = $columnSecurity ? ', _column' : ''; + $sql = " - SELECT _type, _permission, _column + SELECT _type, _permission{$columnSelect} FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} @@ -696,7 +699,17 @@ public function updateDocuments(Document $collection, Document $updates, array $ $removeBindValues[$roleBind] = $role; $removeBindValues[$columnBind] = $column; - $pairs[] = "(_permission = :{$roleBind} AND _column = :{$columnBind})"; + $pairs[] = $columnSecurity + ? "(_permission = :{$roleBind} AND _column = :{$columnBind})" + : "(_permission = :{$roleBind})"; + + if (!$columnSecurity) { + unset($removeBindValues[$columnBind]); + $removeBindKeys = \array_values(\array_filter( + $removeBindKeys, + fn ($key) => $key !== ':' . $columnBind + )); + } } $removeQueries[] = "( @@ -732,7 +745,12 @@ public function updateDocuments(Document $collection, Document $updates, array $ $columnBindKey = 'addcol_' . $type . '_' . $index . '_' . $i; $addBindValues[$columnBindKey] = $column; - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; + if ($columnSecurity) { + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; + } else { + unset($addBindValues[$columnBindKey]); + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; + } if ($this->sharedTables) { $addQuery .= ", :_tenant)"; @@ -771,8 +789,10 @@ public function updateDocuments(Document $collection, Document $updates, array $ } if (!empty($addQuery)) { + $columnColumn = $columnSecurity ? ', _column' : ''; + $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} "; if ($this->sharedTables) { @@ -1130,7 +1150,7 @@ protected function getInsertSuffix(string $table): string * Returns a suffix for the permissions INSERT statement when ignoring duplicates. * Override in adapter subclasses for DB-specific syntax. */ - protected function getInsertPermissionsSuffix(): string + protected function getInsertPermissionsSuffix(bool $columnSecurity = false): string { return ''; } @@ -1992,7 +2012,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); @@ -2059,6 +2080,53 @@ public function deleteColumnPermissions(Document $collection, string $column): a return $this->repointColumnPermissions($collection, $column, null); } + /** + * Does any permission in this collection still name a column? + * + * Used to refuse disabling column security while it would still change meaning: + * the row filter matches on the role alone, so a permission left scoped to a + * column would widen to the whole row once masking stops being applied. + * + * _column is the last member of _index1, so this cannot seek -- it scans. The scan + * is index-only, since every column it reads is in that index, and LIMIT 1 stops + * it at the first match. That makes the refusing case cheap and the permitting + * case (nothing scoped, so nothing to find) a full pass over the index. Acceptable + * because it runs once, on a deliberate disable, rather than on any query path; + * an index on _column would make it instant but has to be maintained on every + * permission write to buy that. + * + * @param Document $collection + * @return bool + * @throws DatabaseException + */ + public function hasColumnPermissions(Document $collection): bool + { + if (!$collection->getAttribute('columnSecurity', false)) { + return false; + } + + $name = $this->filter($collection->getId()); + + $stmt = $this->getPDO()->prepare(" + SELECT 1 + FROM {$this->getSQLTable($name . '_perms')} + WHERE {$this->quote('_column')} <> '' + {$this->getTenantQuery($collection->getId())} + LIMIT 1 + "); + + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + + $this->execute($stmt); + + $found = $stmt->fetchColumn(); + $stmt->closeCursor(); + + return $found !== false; + } + /** * Move or drop the permissions scoped to one column. * @@ -2081,53 +2149,99 @@ private function repointColumnPermissions(Document $collection, string $old, ?st { $name = $this->filter($collection->getId()); $tenantQuery = $this->getTenantQuery($collection->getId()); + $table = $this->getSQLTable($name . '_perms'); + $updated = []; - $stmt = $this->getPDO()->prepare(" - SELECT DISTINCT _document - FROM {$this->getSQLTable($name . '_perms')} - WHERE _column = :_column - {$tenantQuery} - "); - $stmt->bindValue(':_column', $old); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - $this->execute($stmt); - - $documents = $stmt->fetchAll(\PDO::FETCH_COLUMN); - $stmt->closeCursor(); - - if (empty($documents)) { - return []; - } - - if (\is_null($new)) { - $stmt = $this->getPDO()->prepare(" - DELETE FROM {$this->getSQLTable($name . '_perms')} - WHERE _column = :_old - {$tenantQuery} - "); - } else { + // 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. + // + // The loop needs no offset because the work removes its own rows from the + // predicate -- once a batch is repointed or deleted it no longer matches + // _column = :_old, so the next pass returns the following batch. + while (true) { $stmt = $this->getPDO()->prepare(" - UPDATE {$this->getSQLTable($name . '_perms')} - SET _column = :_new - WHERE _column = :_old + SELECT DISTINCT _document + FROM {$table} + WHERE _column = :_column {$tenantQuery} + LIMIT " . Database::DELETE_BATCH_SIZE . " "); - $stmt->bindValue(':_new', $new); - } + $stmt->bindValue(':_column', $old); + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $this->execute($stmt); - $stmt->bindValue(':_old', $old); - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); + $documents = $stmt->fetchAll(\PDO::FETCH_COLUMN); + $stmt->closeCursor(); + + if (empty($documents)) { + break; + } + + $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 move together, scoped to this batch. + $updated = [...$updated, ...$this->repointPermissionsJson($name, $documents, $placeholders, $tenantQuery, $old, $new)]; + + if (\is_null($new)) { + $mutate = $this->getPDO()->prepare(" + DELETE FROM {$table} + WHERE _column = :_old + AND _document IN ({$placeholders}) + {$tenantQuery} + "); + } else { + $mutate = $this->getPDO()->prepare(" + UPDATE {$table} + SET _column = :_new + WHERE _column = :_old + AND _document IN ({$placeholders}) + {$tenantQuery} + "); + $mutate->bindValue(':_new', $new); + } + + $mutate->bindValue(':_old', $old); + foreach ($documents as $index => $id) { + $mutate->bindValue(":_uid_{$index}", $id); + } + if ($this->sharedTables) { + $mutate->bindValue(':_tenant', $this->tenant); + } + $this->execute($mutate); } - $this->execute($stmt); - $placeholders = \implode(', ', \array_map( - fn ($index) => ":_uid_{$index}", - \array_keys($documents) - )); + 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 $old + * @param string|null $new + * @return array ids whose $permissions changed + * @throws DatabaseException + */ + private function repointPermissionsJson( + string $name, + array $documents, + string $placeholders, + string $tenantQuery, + string $old, + ?string $new + ): array { $select = $this->getPDO()->prepare(" SELECT _uid, _permissions FROM {$this->getSQLTable($name)} @@ -2705,6 +2819,7 @@ protected function execute(mixed $stmt): bool */ public function createDocuments(Document $collection, array $documents): array { + $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($documents)) { return $documents; } @@ -2793,9 +2908,15 @@ public function createDocuments(Document $collection, array $documents): array foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; $role = \str_replace('"', '', $permission['role']); - $columnBind = ":_column_{$type}_{$index}_{$i}"; - $bindValuesPermissions[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid_{$index} {$tenantBind})"; + + if ($columnSecurity) { + $columnBind = ":_column_{$type}_{$index}_{$i}"; + $bindValuesPermissions[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid_{$index} {$tenantBind})"; + } else { + $permissions[] = "('{$type}', '{$role}', :_uid_{$index} {$tenantBind})"; + } + $bindValuesPermissions[":_uid_{$index}"] = $document->getId(); if ($this->sharedTables) { $bindValuesPermissions[":_tenant_{$index}"] = $document->getTenant(); @@ -2820,12 +2941,13 @@ public function createDocuments(Document $collection, array $documents): array if (!empty($permissions)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $permissions = \implode(', ', $permissions); $sqlPermissions = " - {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) + {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$tenantColumn}) VALUES {$permissions} - {$this->getInsertPermissionsSuffix()} + {$this->getInsertPermissionsSuffix($columnSecurity)} "; $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); @@ -2856,6 +2978,7 @@ public function upsertDocuments( string $attribute, array $changes ): array { + $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($changes)) { return $changes; } @@ -3135,9 +3258,14 @@ public function upsertDocuments( $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})"; + if ($columnSecurity) { + $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i} AND _column = :removecol_{$type}_{$index}_{$i})"; + $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; + } else { + $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i})"; + } + $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $role; - $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; } $removeQueries[] = "( @@ -3159,7 +3287,9 @@ public function upsertDocuments( foreach ($toAdd as $i => $permission) { [$role, $column] = \explode("\0", $permission, 2); - $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}, :addcol_{$type}_{$index}_{$i}"; + $addQuery = $columnSecurity + ? "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}, :addcol_{$type}_{$index}_{$i}" + : "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; if ($this->sharedTables) { $addQuery .= ", :_tenant_{$index}"; @@ -3169,7 +3299,10 @@ public function upsertDocuments( $addQueries[] = $addQuery; $addBindValues[":_uid_{$index}"] = $document->getId(); $addBindValues[":add_{$type}_{$index}_{$i}"] = $role; - $addBindValues[":addcol_{$type}_{$index}_{$i}"] = $column; + + if ($columnSecurity) { + $addBindValues[":addcol_{$type}_{$index}_{$i}"] = $column; + } if ($this->sharedTables) { $addBindValues[":_tenant_{$index}"] = $document->getTenant(); @@ -3188,7 +3321,8 @@ public function upsertDocuments( } if (!empty($addQueries)) { - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column"; + $columnColumn = $columnSecurity ? ', _column' : ''; + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn}"; if ($this->sharedTables) { $sqlAddPermissions .= ", _tenant"; } @@ -3274,6 +3408,7 @@ protected function convertArrayToWKT(array $geometry): string */ 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(); @@ -3387,7 +3522,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $subsumesRowCondition = !empty($columnConditions) && $forPermission === Database::PERMISSION_READ; if ($this->authorization->getStatus() && !$subsumesRowCondition) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, $forPermission, $columnSecurity); } foreach ($columnConditions as $condition) { @@ -3519,6 +3654,7 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 */ 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(); @@ -3551,7 +3687,7 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $columnConditions = $this->getSQLColumnPermissionsConditions($name, $columnPermissions, $roles, $alias); if ($this->authorization->getStatus() && empty($columnConditions)) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, Database::PERMISSION_READ, $columnSecurity); } foreach ($columnConditions as $condition) { @@ -3621,6 +3757,7 @@ public function count(Document $collection, array $queries = [], ?int $max = nul */ 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); @@ -3654,7 +3791,7 @@ public function sum(Document $collection, string $attribute, array $queries = [] $columnConditions = $this->getSQLColumnPermissionsConditions($name, $columnPermissions, $roles, $alias); if ($this->authorization->getStatus() && empty($columnConditions)) { - $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias); + $where[] = $this->getSQLPermissionsCondition($name, $roles, $alias, Database::PERMISSION_READ, $columnSecurity); } foreach ($columnConditions as $condition) { diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index da66094d53..f3f114fb58 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1146,6 +1146,7 @@ private function escapeLikePattern(string $value): string */ public function createDocument(Document $collection, Document $document): Document { + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1212,17 +1213,22 @@ public function createDocument(Document $collection, Document $document): Docume foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $role = \str_replace('"', '', $permission['role']); $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, '{$document->getId()}' {$tenantQuery})"; + if ($columnSecurity) { + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, '{$document->getId()}' {$tenantQuery})"; + } else { + $permissions[] = "('{$type}', '{$role}', '{$document->getId()}' {$tenantQuery})"; + } } } if (!empty($permissions)) { $tenantQuery = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _column, _document {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission{$columnColumn}, _document {$tenantQuery}) VALUES " . \implode(', ', $permissions); $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); @@ -1269,6 +1275,7 @@ public function createDocument(Document $collection, Document $document): Docume public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { $spatialAttributes = $this->getSpatialAttributes($collection); + $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1305,17 +1312,23 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantQuery})"; + if ($columnSecurity) { + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantQuery})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; + } else { + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; + } + $binds[":_add_{$type}_{$i}"] = $permission['role']; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; } } if (!empty($values)) { $tenantQuery = $this->sharedTables ? ', _tenant' : ''; + $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission, _column {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission{$columnColumn} {$tenantQuery}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -1527,6 +1540,99 @@ public function getSupportForColumnPermissions(): bool return true; } + /** + * Give an older permissions table the shape column permissions need. + * + * SQLite has no INFORMATION_SCHEMA, so the column list comes from PRAGMA, and + * indexes are dropped and recreated rather than altered. + * + * @param Document $collection + * @return bool + * @throws DatabaseException + */ + public function prepareColumnPermissions(Document $collection): bool + { + $id = $this->filter($collection->getId()); + $table = "{$this->getNamespace()}_{$id}_perms"; + + $hasColumn = false; + foreach ($this->getPDO()->query("PRAGMA table_info(`{$table}`)")->fetchAll() as $column) { + if (($column['name'] ?? null) === '_column') { + $hasColumn = true; + break; + } + } + + $hasIndex = $this->hasColumnPermissionsIndex($table); + + // Both are checked, not just the column. A table created since column + // permissions existed has both already; and a prepare interrupted between + // adding the column and rebuilding the index leaves them disagreeing, which + // keying off the column alone would never repair. + if ($hasColumn && $hasIndex) { + return true; + } + + if (!$hasColumn) { + try { + $this->getPDO()->prepare(" + ALTER TABLE `{$table}` ADD COLUMN `_column` VARCHAR(255) NOT NULL DEFAULT '' + ")->execute(); + } catch (PDOException $e) { + throw $this->processException($e); + } + } + + if ($hasIndex) { + return true; + } + + // One transaction, so uniqueness is never absent. Dropping and recreating as + // two statements leaves a window in which a duplicate permission row can be + // inserted -- and the recreate then fails, leaving the table with no unique + // index at all. SQLite keeps DDL transactional, so the pair is atomic. + $this->startTransaction(); + + try { + $this->deleteIndex("{$id}_perms", '_index_1'); + $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission', '_column'], [], []); + } catch (\Throwable $e) { + $this->rollbackTransaction(); + + throw $e; + } + + $this->commitTransaction(); + + return true; + } + + /** + * Does the unique permissions index already cover _column? + * + * Found through PRAGMA rather than by rebuilding the index name, so it cannot + * drift from however createIndex() chose to name it. + * + * @param string $table unprefixed physical table name + * @return bool + */ + protected function hasColumnPermissionsIndex(string $table): bool + { + foreach ($this->getPDO()->query("PRAGMA index_list(`{$table}`)")->fetchAll() as $index) { + if ((int)($index['unique'] ?? 0) !== 1) { + continue; + } + + foreach ($this->getPDO()->query("PRAGMA index_info(`{$index['name']}`)")->fetchAll() as $column) { + if (($column['name'] ?? null) === '_column') { + return true; + } + } + } + + return false; + } + public function getSupportForSchemaAttributes(): bool { return true; diff --git a/src/Database/Database.php b/src/Database/Database.php index 607c42e30e..bae32162cb 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -372,6 +372,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' => [], @@ -1784,7 +1794,7 @@ 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 { foreach ($attributes as &$attribute) { if (in_array($attribute['type'], self::ATTRIBUTE_FILTER_TYPES)) { @@ -1804,7 +1814,15 @@ public function createCollection(string $id, array $attributes = [], array $inde ]; if ($this->validate) { - $validator = new Permissions(); + $columns = []; + foreach ($attributes as $attribute) { + $key = $attribute['key'] ?? $attribute['$id'] ?? null; + if (\is_string($key) && $key !== '') { + $columns[] = $key; + } + } + + $validator = new Permissions(columns: $columns); if (!$validator->isValid($permissions)) { throw new DatabaseException($validator->getDescription()); } @@ -1866,9 +1884,12 @@ public function createCollection(string $id, array $attributes = [], array $inde 'name' => $id, 'attributes' => $attributes, 'indexes' => $indexes, - 'documentSecurity' => $documentSecurity + 'documentSecurity' => $documentSecurity, + 'columnSecurity' => $columnSecurity ]); + $this->assertColumnSecurityEnabled($collection, $permissions); + if ($this->validate) { $validator = new IndexValidator( $attributes, @@ -1997,21 +2018,21 @@ public function createCollection(string $id, array $attributes = [], array $inde * @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 = null): 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() @@ -2019,9 +2040,21 @@ public function updateCollection(string $id, array $permissions, bool $documentS throw new NotFoundException('Collection not found'); } + $resolved = $columnSecurity ?? $collection->getAttribute('columnSecurity', false); + + $this->assertColumnSecurityEnabled( + (new Document($collection->getArrayCopy()))->setAttribute('columnSecurity', $resolved), + $permissions + ); + + if (!\is_null($columnSecurity) && $columnSecurity !== $collection->getAttribute('columnSecurity', false)) { + $this->setColumnSecurity($collection, $columnSecurity); + } + $collection ->setAttribute('$permissions', $permissions) - ->setAttribute('documentSecurity', $documentSecurity); + ->setAttribute('documentSecurity', $documentSecurity) + ->setAttribute('columnSecurity', $columnSecurity ?? $collection->getAttribute('columnSecurity', false)); $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); @@ -3305,7 +3338,7 @@ public function updateAttribute(string $collection, string $id, ?string $type = // rewrites the attribute's '$id' and 'key' together, so the key is the // only handle there is. The lookup is skipped entirely when no // permission is scoped to this column, which is the common case. - if (!\is_null($newKey) && $newKey !== $id) { + if (!\is_null($newKey) && $newKey !== $id && $collectionDoc->getAttribute('columnSecurity', false)) { $this->repointCollectionColumnPermissions($collectionDoc, $id, $newKey); foreach ($this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey) as $documentId) { @@ -3462,10 +3495,12 @@ public function deleteAttribute(string $collection, string $id): bool // Permissions name their column by key, so grants left behind would be // inherited by any column later created under the same name. - $this->repointCollectionColumnPermissions($collection, $id, null); + if ($collection->getAttribute('columnSecurity', false)) { + $this->repointCollectionColumnPermissions($collection, $id, null); - foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); + foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); + } } $this->updateMetadata( @@ -5163,6 +5198,89 @@ private function getPermittedColumns( return \array_keys($columns); } + /** + * 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; + } + + /** + * Turn column security on or off for a collection. + * + * Enabling prepares the permissions table, which for a collection created before + * column permissions existed means an ALTER. Disabling is refused while any + * permission is still scoped to a column: the row filter matches on the role + * alone, so such a permission would widen to the whole row once the column part + * stops being written and queried. + * + * @param Document $collection + * @param bool $columnSecurity + * @return void + * @throws DatabaseException + */ + private function setColumnSecurity(Document $collection, bool $columnSecurity): void + { + if ($columnSecurity) { + if (!$this->adapter->getSupportForColumnPermissions()) { + throw new DatabaseException('Column security is not supported by this adapter'); + } + + $this->adapter->prepareColumnPermissions($collection); + + return; + } + + if ($this->adapter->hasColumnPermissions($collection)) { + throw new DependencyException( + 'Cannot disable column security: permissions scoped to a column still exist. Remove them first.' + ); + } + } + + /** + * 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. * @@ -5310,7 +5428,18 @@ private function getQueriedColumns(array $queries): array $key = $query->getAttribute(); - // Internal fields are not columns; dotted keys are relationship paths. + // 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; } @@ -5522,6 +5651,15 @@ private function maskUnreadableColumns(Document $collection, Document $document, 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) { @@ -6262,6 +6400,7 @@ public function createDocument(string $collection, Document $document): Document throw new AuthorizationException($this->authorization->getDescription()); } + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); } @@ -6296,7 +6435,7 @@ public function createDocument(string $collection, Document $document): Document $document = $this->encode($collection, $document); if ($this->validate) { - $validator = new Permissions(); + $validator = new Permissions(columns: $this->getColumnKeys($collection)); if (!$validator->isValid($document->getPermissions())) { throw new DatabaseException($validator->getDescription()); } @@ -6387,6 +6526,7 @@ public function createDocuments( } foreach ($documents as $document) { + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); $this->assertColumnsAllowed($collection, $document, self::PERMISSION_CREATE); } } @@ -6848,6 +6988,16 @@ public function updateDocument(string $collection, string $id, Document $documen $collection->getAttribute('documentSecurity', false) ); + // Only newly introduced ones are rejected. A document that already carries + // a column-scoped permission must stay editable -- otherwise disabling the + // flag, or writing one before it was disabled, would lock the document. + if ($collection->getId() !== self::METADATA && $document->offsetExists('$permissions')) { + $this->assertColumnSecurityEnabled( + $collection, + \array_diff($document->getPermissions(), $old->getPermissions()) + ); + } + $skipPermissionsUpdate = true; if ($document->offsetExists('$permissions')) { @@ -7152,6 +7302,7 @@ public function updateDocuments( } if ($collection->getId() !== self::METADATA) { + $this->assertColumnSecurityEnabled($collection, $updates->getPermissions()); $this->assertColumnsAllowed($collection, $updates, self::PERMISSION_UPDATE); $this->assertColumnsQueryable($collection, $queries); } @@ -9229,7 +9380,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $columnPermissions = []; if ($collection->getId() !== self::METADATA) { - if ($this->adapter->getSupportForColumnPermissions()) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); // With documentSecurity off, collection permissions are the whole @@ -9373,9 +9524,19 @@ public function find(string $collection, array $queries = [], string $forPermiss // already a no-op when authorization is disabled. $node = $this->maskUnreadableColumns($collection, $node, $documentSecurity); + // 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; @@ -9772,7 +9933,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $columnPermissions = []; if ($collection->getId() !== self::METADATA) { - if ($this->adapter->getSupportForColumnPermissions()) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); // With documentSecurity off, collection permissions are the whole @@ -9865,7 +10026,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $columnPermissions = []; if ($collection->getId() !== self::METADATA) { - if ($this->adapter->getSupportForColumnPermissions()) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { $columnPermissions = $this->getRestrictedQueryColumns($collection, $queries, self::PERMISSION_READ); // With documentSecurity off, collection permissions are the whole @@ -9882,7 +10043,7 @@ public function sum(string $collection, string $attribute, array $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 ($this->adapter->getSupportForColumnPermissions()) { + if ($collection->getAttribute('columnSecurity', false) && $this->adapter->getSupportForColumnPermissions()) { $floor = $this->getCollectionColumnFloor($collection, self::PERMISSION_READ); if ($floor !== null && !\in_array($attribute, $floor, true)) { diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index a0151cb92f..39f9edaa54 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 = null): 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/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 85c2c70535..9aa8dd8f03 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -41,7 +41,7 @@ protected function setUp(): void } $this->authorization->skip(function () { - $this->database->createCollection('employees', permissions: [], documentSecurity: true); + $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); @@ -137,7 +137,7 @@ public function testSkippedAuthorizationIsNotMasked(): void public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void { $this->authorization->skip(function () { - $this->database->createCollection('public_employees', documentSecurity: true, permissions: [ + $this->database->createCollection('public_employees', documentSecurity: true, columnSecurity: true, permissions: [ Permission::read(Role::any(), 'name'), ]); @@ -176,7 +176,7 @@ public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void public function testCollectionLevelColumnGrantFollowsARename(): void { $this->authorization->skip(function () { - $this->database->createCollection('scoped', documentSecurity: true, permissions: [ + $this->database->createCollection('scoped', documentSecurity: true, columnSecurity: true, permissions: [ Permission::read(Role::any(), 'name'), ]); diff --git a/tests/unit/ColumnPermissionQueryTest.php b/tests/unit/ColumnPermissionQueryTest.php index 68bfa65fd5..0e1e9c812c 100644 --- a/tests/unit/ColumnPermissionQueryTest.php +++ b/tests/unit/ColumnPermissionQueryTest.php @@ -9,6 +9,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Authorization as AuthorizationException; +use Utopia\Database\Exception\Query as QueryException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\Query; @@ -42,7 +43,7 @@ protected function setUp(): void } $this->authorization->skip(function () { - $this->database->createCollection('employees', documentSecurity: true, permissions: [ + $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: [ Permission::read(Role::any(), 'name'), Permission::create(Role::any(), 'name'), ]); @@ -168,6 +169,39 @@ public function testPredicateCannotBoundAHiddenValue(): void } } + /** + * 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')])); @@ -192,7 +226,7 @@ public function testSelectOfAnUnreadableColumnIsMaskedNotRejected(): void public function testWithoutDocumentSecurityAnUnreadableColumnThrows(): void { $this->authorization->skip(function () { - $this->database->createCollection('strict', documentSecurity: false, permissions: [ + $this->database->createCollection('strict', documentSecurity: false, columnSecurity: true, permissions: [ Permission::read(Role::any(), 'name'), ]); $this->database->createAttribute('strict', 'name', Database::VAR_STRING, 128, false); diff --git a/tests/unit/ColumnPermissionSqlTest.php b/tests/unit/ColumnPermissionSqlTest.php index ada1813906..ad5c661e2d 100644 --- a/tests/unit/ColumnPermissionSqlTest.php +++ b/tests/unit/ColumnPermissionSqlTest.php @@ -50,7 +50,7 @@ protected function setUp(): void $this->database->create(); $this->authorization->skip(function () { - $this->database->createCollection('employees', documentSecurity: true, permissions: [ + $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: [ Permission::read(Role::any(), 'name'), ]); $this->database->createAttribute('employees', 'name', Database::VAR_STRING, 128, false); @@ -127,7 +127,7 @@ public function testColumnIsPersistedOnThePermissionsTable(): void public function testColumnScopedGrantAloneMakesTheRowVisible(): void { $this->authorization->skip(function () { - $this->database->createCollection('scoped', documentSecurity: true, permissions: []); + $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); @@ -219,6 +219,35 @@ public function testCallerWithNoGrantOnTheColumnMatchesNothing(): void ); } + /** + * 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']); From 512264591ac9c8c5d9441504738e54ae1b98f180 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 09:04:54 +0300 Subject: [PATCH 06/46] Fix index size and Mongo perms --- src/Database/Adapter/MariaDB.php | 11 +- src/Database/Adapter/Memory.php | 69 +++- src/Database/Adapter/Mongo.php | 63 ++- src/Database/Adapter/Postgres.php | 4 +- src/Database/Adapter/SQL.php | 127 +++++- src/Database/Adapter/SQLite.php | 4 +- src/Database/Database.php | 33 ++ src/Database/Validator/Permissions.php | 2 +- tests/e2e/Adapter/Scopes/PermissionTests.php | 67 +++ tests/unit/ColumnSecurityFlagTest.php | 410 +++++++++++++++++++ tests/unit/MongoPermissionStringsTest.php | 30 -- tests/unit/QueryCacheTest.php | 4 +- tests/unit/WithCacheLeaseTest.php | 2 +- 13 files changed, 764 insertions(+), 62 deletions(-) create mode 100644 tests/unit/ColumnSecurityFlagTest.php diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 6ba174c15b..4a8d07259f 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -194,12 +194,19 @@ public function createCollection(string $name, array $attributes = [], array $in // 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 _index1. + // + // Sized to MAX_UID_DEFAULT_LENGTH rather than the 255 the other string members + // use. _index1 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(255) NOT NULL DEFAULT '', + _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL, PRIMARY KEY (_id), "; @@ -1854,7 +1861,7 @@ public function prepareColumnPermissions(Document $collection): bool if (!$hasColumn) { $this->getPDO()->prepare(" ALTER TABLE {$table} - ADD COLUMN _column VARCHAR(255) NOT NULL DEFAULT '' + ADD COLUMN _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' ")->execute(); } diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index d0619926ba..c242a7a652 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2103,12 +2103,77 @@ public function getSupportForColumnPermissions(): bool */ public function renameColumnPermissions(Document $collection, string $old, string $new): array { - return []; + return $this->repointColumnPermissions($collection, $old, $new); } public function deleteColumnPermissions(Document $collection, string $column): array { - return []; + return $this->repointColumnPermissions($collection, $column, null); + } + + /** + * Move or drop the permissions scoped to one column. + * + * Only the stored _permissions of each row are rewritten. The permission indexes + * this adapter keeps are built from getPermissionsByType(), which strips the + * column, so they hold roles alone and nothing in them refers to a column name. + * Column access is decided from the row's own permissions by rowGrantsColumns(), + * which is why leaving these unrewritten would silently revoke access after a + * rename, and let a recreated column inherit grants after a delete. + * + * @param Document $collection + * @param string $old + * @param string|null $new new column key, or null to drop the permissions + * @return array ids of documents whose permissions changed + * @throws DatabaseException + */ + private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + { + $key = $this->key($collection->getId()); + $updated = []; + + foreach ($this->data[$key]['documents'] ?? [] as $documentKey => $row) { + $permissions = $row['_permissions'] ?? []; + + if (!\is_array($permissions)) { + continue; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->getColumn() !== $old) { + $rewritten[] = $permission; + continue; + } + + $changed = true; + + if (\is_null($new)) { + continue; + } + + $rewritten[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); + } + + if (!$changed) { + continue; + } + + $this->data[$key]['documents'][$documentKey]['_permissions'] = \array_values(\array_unique($rewritten)); + $updated[] = $row['_uid'] ?? $documentKey; + } + + return $updated; } public function prepareColumnPermissions(Document $collection): bool diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index de0f91def2..11dbca9869 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2486,14 +2486,12 @@ private function permissionStrings(string $type, Document $collection): array } } - $columns = \array_keys($columns); - $permissions = []; foreach ($this->authorization->getRoles() as $role) { $permissions[] = $type . '("' . $role . '")'; - foreach ($columns as $column) { + foreach (\array_keys($columns) as $column) { $permissions[] = $type . '("' . $role . '", "' . $column . '")'; } } @@ -2501,6 +2499,57 @@ private function permissionStrings(string $type, Document $collection): array 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 + { + if (empty($columnPermissions) || !$this->authorization->getStatus()) { + 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 * @@ -2541,6 +2590,8 @@ public function find(Document $collection, array $queries = [], ?int $limit = 25 $filters['_permissions']['$in'] = $this->permissionStrings($forPermission, $collection); } + $filters = $this->applyColumnPermissions($filters, $columnPermissions, Database::PERMISSION_READ); + $options = []; if (!\is_null($limit)) { @@ -2794,6 +2845,8 @@ public function count(Document $collection, array $queries = [], ?int $max = nul $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 @@ -2894,6 +2947,8 @@ public function sum(Document $collection, string $attribute, array $queries = [] $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 @@ -4235,7 +4290,7 @@ public function decodePolygon(string $wkb): array public function getSupportForColumnPermissions(): bool { - return false; + return true; } /** diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index 2dee76700a..dbf1e7aaef 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -256,7 +256,7 @@ public function createCollection(string $name, array $attributes = [], array $in _tenant INTEGER DEFAULT NULL, _type VARCHAR(12) NOT NULL, _permission VARCHAR(255) NOT NULL, - _column VARCHAR(255) NOT NULL DEFAULT '', + _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', _document VARCHAR(255) NOT NULL ); "; @@ -2186,7 +2186,7 @@ public function prepareColumnPermissions(Document $collection): bool try { $this->getPDO()->prepare(" ALTER TABLE {$table} - ADD COLUMN IF NOT EXISTS _column VARCHAR(255) NOT NULL DEFAULT '' + ADD COLUMN IF NOT EXISTS _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' ")->execute(); if (!$hasIndex) { diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 6781da491e..6fb1278c39 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2162,8 +2162,11 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // predicate -- once a batch is repointed or deleted it no longer matches // _column = :_old, so the next pass returns the following batch. 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 DISTINCT _document + SELECT _id, _document FROM {$table} WHERE _column = :_column {$tenantQuery} @@ -2175,13 +2178,16 @@ private function repointColumnPermissions(Document $collection, string $old, ?st } $this->execute($stmt); - $documents = $stmt->fetchAll(\PDO::FETCH_COLUMN); + $rows = $stmt->fetchAll(); $stmt->closeCursor(); - if (empty($documents)) { + if (empty($rows)) { break; } + $sequences = \array_column($rows, '_id'); + $documents = \array_values(\array_unique(\array_column($rows, '_document'))); + $placeholders = \implode(', ', \array_map( fn ($index) => ":_uid_{$index}", \array_keys($documents) @@ -2191,30 +2197,40 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // fact, so they move together, scoped to this batch. $updated = [...$updated, ...$this->repointPermissionsJson($name, $documents, $placeholders, $tenantQuery, $old, $new)]; + if (!\is_null($new)) { + // A document can already hold the same role and action scoped to the + // destination column. Repointing the old scope onto it would then be a + // second identical row, which _index1 refuses -- and by this point the + // physical column has been renamed, so the failure would leave the + // schema renamed with permissions still describing the old state. + // The old scope is redundant once the destination exists, so drop it + // instead of repointing it. + $this->dropCollidingColumnPermissions($table, $documents, $placeholders, $tenantQuery, $old, $new); + } + + // Addressed by primary key. Rows the collision pass above already removed + // simply match nothing. + $sequencePlaceholders = \implode(', ', \array_map( + fn ($index) => ":_id_{$index}", + \array_keys($sequences) + )); + if (\is_null($new)) { $mutate = $this->getPDO()->prepare(" DELETE FROM {$table} - WHERE _column = :_old - AND _document IN ({$placeholders}) - {$tenantQuery} + WHERE _id IN ({$sequencePlaceholders}) "); } else { $mutate = $this->getPDO()->prepare(" UPDATE {$table} SET _column = :_new - WHERE _column = :_old - AND _document IN ({$placeholders}) - {$tenantQuery} + WHERE _id IN ({$sequencePlaceholders}) "); $mutate->bindValue(':_new', $new); } - $mutate->bindValue(':_old', $old); - foreach ($documents as $index => $id) { - $mutate->bindValue(":_uid_{$index}", $id); - } - if ($this->sharedTables) { - $mutate->bindValue(':_tenant', $this->tenant); + foreach ($sequences as $index => $sequence) { + $mutate->bindValue(":_id_{$index}", $sequence); } $this->execute($mutate); } @@ -2222,6 +2238,85 @@ private function repointColumnPermissions(Document $collection, string $old, ?st return $updated; } + /** + * Drop old-column rows whose destination scope already exists on the same + * document, role and action. + * + * @param string $table + * @param array $documents + * @param string $placeholders + * @param string $tenantQuery + * @param string $old + * @param string $new + * @return void + * @throws DatabaseException + */ + private function dropCollidingColumnPermissions( + string $table, + array $documents, + string $placeholders, + string $tenantQuery, + string $old, + string $new + ): void { + $stmt = $this->getPDO()->prepare(" + SELECT _document, _type, _permission, _column + FROM {$table} + WHERE _column IN (:_old, :_new) + AND _document IN ({$placeholders}) + {$tenantQuery} + "); + $stmt->bindValue(':_old', $old); + $stmt->bindValue(':_new', $new); + foreach ($documents as $index => $id) { + $stmt->bindValue(":_uid_{$index}", $id); + } + if ($this->sharedTables) { + $stmt->bindValue(':_tenant', $this->tenant); + } + $this->execute($stmt); + + $rows = $stmt->fetchAll(); + $stmt->closeCursor(); + + // Resolved here rather than in SQL: a DELETE whose subquery reads the table it + // deletes from is rejected by MySQL, and the workarounds differ per engine. + $seen = []; + foreach ($rows as $row) { + if ($row['_column'] === $new) { + $seen[$row['_document'] . "\0" . $row['_type'] . "\0" . $row['_permission']] = true; + } + } + + $delete = $this->getPDO()->prepare(" + DELETE FROM {$table} + WHERE _document = :_document + AND _type = :_type + AND _permission = :_permission + AND _column = :_old + {$tenantQuery} + "); + + foreach ($rows as $row) { + if ($row['_column'] !== $old) { + continue; + } + + if (!isset($seen[$row['_document'] . "\0" . $row['_type'] . "\0" . $row['_permission']])) { + continue; + } + + $delete->bindValue(':_document', $row['_document']); + $delete->bindValue(':_type', $row['_type']); + $delete->bindValue(':_permission', $row['_permission']); + $delete->bindValue(':_old', $old); + if ($this->sharedTables) { + $delete->bindValue(':_tenant', $this->tenant); + } + $this->execute($delete); + } + } + /** * Rewrite the stored $permissions of one batch of documents. * @@ -2305,7 +2400,7 @@ private function repointPermissionsJson( continue; } - $update->bindValue(':_permissions', \json_encode($rewritten)); + $update->bindValue(':_permissions', \json_encode(\array_values(\array_unique($rewritten)))); $update->bindValue(':_uid', $row['_uid']); if ($this->sharedTables) { $update->bindValue(':_tenant', $this->tenant); diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index f3f114fb58..5edb33f81c 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -421,7 +421,7 @@ public function createCollection(string $name, array $attributes = [], array $in {$tenantQuery} `_type` VARCHAR(12) NOT NULL, `_permission` VARCHAR(255) NOT NULL, - `_column` VARCHAR(255) NOT NULL DEFAULT '', + `_column` VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '', `_document` VARCHAR(255) NOT NULL ) "; @@ -1576,7 +1576,7 @@ public function prepareColumnPermissions(Document $collection): bool if (!$hasColumn) { try { $this->getPDO()->prepare(" - ALTER TABLE `{$table}` ADD COLUMN `_column` VARCHAR(255) NOT NULL DEFAULT '' + ALTER TABLE `{$table}` ADD COLUMN `_column` VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' ")->execute(); } catch (PDOException $e) { throw $this->processException($e); diff --git a/src/Database/Database.php b/src/Database/Database.php index 834426edb5..b94efb95fd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -118,6 +118,22 @@ 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; + // Maximum byte capacity for TEXT public const MAX_TEXT_BYTES = 65535; public const MAX_MEDIUMTEXT_BYTES = 16777215; @@ -5425,6 +5441,23 @@ private function getQueriedColumns(array $queries): array 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 diff --git a/src/Database/Validator/Permissions.php b/src/Database/Validator/Permissions.php index d03e997741..6292754569 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -35,7 +35,7 @@ public function __construct(int $length = 0, array $allowed = [...Database::PERM $this->length = $length; $this->allowed = $allowed; $this->columns = $columns; - $this->key = new Key(); + $this->key = new Key(maxLength: Database::MAX_PERMISSION_COLUMN_LENGTH); } /** diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 97e55633fc..dedb4ce598 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -15,6 +15,73 @@ trait PermissionTests { + /** + * 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 */ diff --git a/tests/unit/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php new file mode 100644 index 0000000000..bcd505f59d --- /dev/null +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -0,0 +1,410 @@ +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); + }); + } + + public function testDefaultsToOff(): void + { + $this->collection('plain', false); + + $collection = $this->authorization->skip(fn () => $this->database->getCollection('plain')); + + $this->assertFalse($collection->getAttribute('columnSecurity')); + } + + // ---------------------------------------------------------------- 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', + ]), + ])); + } + + 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 + )); + } + + // ---------------------------------------------------------------- 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 would leave the column half of the permission unwritten and + * unenforced, so the row filter -- which matches on the role alone -- would widen + * it to the whole row. Refuse rather than silently escalate. + */ + public function testDisablingIsRefusedWhileColumnPermissionsExist(): 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->expectException(DependencyException::class); + $this->expectExceptionMessage('Cannot disable column security'); + + $this->authorization->skip(fn () => $this->database->updateCollection('secured', [], true, false)); + } + + 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')); + $this->assertSame(['read("user:hr", "salary")'], $stored->getPermissions()); + } + + /** + * 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/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index e3b41ffa82..fae2419bd5 100644 --- a/tests/unit/MongoPermissionStringsTest.php +++ b/tests/unit/MongoPermissionStringsTest.php @@ -60,36 +60,6 @@ public function testValuesAreStringsNotRegex(): void } } - /** - * Column-scoped permissions name the column inside the string, so the candidate - * list has to carry a variant per column. With no columns the output is exactly - * what it was before column-level permissions existed. - */ - public function testColumnsAddAVariantPerColumnAlongsideTheUnscopedGrant(): void - { - $this->assertSame( - [ - 'read("user:alice")', - 'read("user:alice", "name")', - 'read("user:alice", "salary")', - ], - $this->permissionStrings(['user:alice'], Database::PERMISSION_READ, ['name', 'salary']) - ); - } - - public function testColumnVariantsMatchThePermissionHelperSerialisation(): void - { - $strings = $this->permissionStrings(['user:alice'], Database::PERMISSION_READ, ['salary']); - - $this->assertContains( - \Utopia\Database\Helpers\Permission::read( - \Utopia\Database\Helpers\Role::user('alice'), - 'salary' - ), - $strings - ); - } - /** * @param list $roles * @param list $columns 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; From 099480ac6bbf990ed22fcbbe82fa0f27fd1d17d2 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 12:02:42 +0300 Subject: [PATCH 07/46] Address comments --- src/Database/Adapter/Mongo.php | 179 +++++++++++++++++++++++++++++++-- src/Database/Database.php | 58 +++++++++-- 2 files changed, 223 insertions(+), 14 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 11dbca9869..9fb2aafaea 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; @@ -4302,24 +4303,186 @@ public function getSupportForColumnPermissions(): bool * @param string $new * @return array */ - public function renameColumnPermissions(Document $collection, string $old, string $new): array + /** + * Nothing to prepare: permissions live inline on each document, so this adapter + * has no permissions table to widen. + * + * @param Document $collection + * @return bool + */ + public function prepareColumnPermissions(Document $collection): bool { - return []; + return true; } - public function deleteColumnPermissions(Document $collection, string $column): array + /** + * Is any permission in this collection still scoped to a column? + * + * Read by the guard that refuses to disable column security while such grants + * exist. No index can answer it -- the column is inside an assembled string -- so + * it scans, stopping at the first document that has one. + * + * @param Document $collection + * @return bool + * @throws Exception + */ + public function hasColumnPermissions(Document $collection): bool { - return []; + if (!$collection->getAttribute('columnSecurity', false)) { + return false; + } + + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $cursor = null; + + while (true) { + $filters = []; + + if (!\is_null($cursor)) { + $filters['_uid'] = ['$gt' => $cursor]; + } + + if ($this->sharedTables) { + $filters['_tenant'] = $this->getTenantFilters($collection->getId()); + } + + $found = $this->client->find($name, $filters, [ + 'limit' => Database::DELETE_BATCH_SIZE, + 'sort' => ['_uid' => 1], + 'projection' => ['_uid' => 1, '_permissions' => 1], + ])->cursor->firstBatch ?? []; + + if (empty($found)) { + return false; + } + + foreach ($found as $row) { + $row = $this->client->toArray($row); + $cursor = $row['_uid']; + + foreach ($row['_permissions'] ?? [] as $permission) { + if (!Permission::parse((string)$permission)->isForAllColumns()) { + return true; + } + } + } + } } - public function prepareColumnPermissions(Document $collection): bool + public function renameColumnPermissions(Document $collection, string $old, string $new): array { - return false; + return $this->repointColumnPermissions($collection, $old, $new); } - public function hasColumnPermissions(Document $collection): bool + public function deleteColumnPermissions(Document $collection, string $column): array { - return false; + return $this->repointColumnPermissions($collection, $column, null); + } + + /** + * Move or 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 rename that left them alone would quietly + * revoke access under the old name, and a delete would leave grants for a column + * key to inherit if it were recreated. + * + * Documents are handled in batches. Only those still naming the old column are + * fetched, and rewriting them takes them out of that set, so the next pass + * returns the following batch without needing an offset. + * + * @param Document $collection + * @param string $old + * @param string|null $new new column key, or null to drop the permissions + * @return array ids of documents whose permissions changed + * @throws Exception + */ + private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + { + $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); + $updated = []; + $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()); + } + + $found = $this->client->find($name, $filters, [ + 'limit' => 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; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + $parsed = Permission::parse((string)$permission); + + if ($parsed->getColumn() !== $old) { + $rewritten[] = (string)$permission; + continue; + } + + $changed = true; + + if (\is_null($new)) { + continue; + } + + $rewritten[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); + } + + if (!$changed) { + continue; + } + + $where = ['_uid' => $row['_uid']]; + if ($this->sharedTables) { + $where['_tenant'] = $this->getTenantFilters($collection->getId()); + } + + $this->client->update($name, $where, [ + '$set' => ['_permissions' => \array_values(\array_unique($rewritten))], + ]); + + $updated[] = $row['_uid']; + } + } + + return $updated; } /** diff --git a/src/Database/Database.php b/src/Database/Database.php index b94efb95fd..6c062d313c 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5061,14 +5061,18 @@ public function getDocument(string $collection, string $id, array $queries = [], } } - $document = $this->maskUnreadableColumns($collection, $document, $documentSecurity); - - $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, $documentSecurity); + + $this->trigger(self::EVENT_DOCUMENT_READ, $document); + return $document; } @@ -5548,6 +5552,29 @@ private function assertColumnsQueryable(Document $collection, array $queries, st } } + /** + * 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. @@ -5577,7 +5604,7 @@ private function assertColumnsAllowed(Document $collection, Document $document, } foreach ($document as $key => $value) { - if (\str_starts_with($key, '$') || isset($relationships[$key])) { + if (\str_starts_with($key, '$')) { continue; } @@ -5585,6 +5612,9 @@ private function assertColumnsAllowed(Document $collection, Document $document, 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 . '".'); } @@ -5648,7 +5678,23 @@ private function assertColumnsWritable( continue; } - if (\in_array($key, $columns, true) || isset($relationships[$key])) { + 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; } From baedc929864aacfdff65b038430f1b8fb79a35af Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 17:23:28 +0300 Subject: [PATCH 08/46] documentSecurity --- src/Database/Database.php | 46 +++++++++++++++------------------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 779d8375f2..ab598454cd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5069,7 +5069,7 @@ public function getDocument(string $collection, string $id, array $queries = [], return $this->createDocumentInstance($collection->getId(), []); } - $document = $this->maskUnreadableColumns($collection, $document, $documentSecurity); + $document = $this->maskUnreadableColumns($collection, $document); $this->trigger(self::EVENT_DOCUMENT_READ, $document); @@ -5160,7 +5160,7 @@ public function getDocument(string $collection, string $id, array $queries = [], } } - $document = $this->maskUnreadableColumns($collection, $document, $documentSecurity); + $document = $this->maskUnreadableColumns($collection, $document); $this->trigger(self::EVENT_DOCUMENT_READ, $document); @@ -5176,14 +5176,12 @@ public function getDocument(string $collection, string $id, array $queries = [], * * @param Document $collection * @param Document $document - * @param bool $documentSecurity * @param string $type * @return array|null */ private function getPermittedColumns( Document $collection, Document $document, - bool $documentSecurity, string $type ): ?array { if (!$this->authorization->getStatus()) { @@ -5192,7 +5190,7 @@ private function getPermittedColumns( $permissions = $collection->getPermissionsByTypeWithColumns($type); - if ($documentSecurity) { + if ($collection->getAttribute('documentSecurity', false)) { $permissions = [ ...$permissions, ...$document->getPermissionsByTypeWithColumns($type), @@ -5631,17 +5629,15 @@ private function assertColumnsAllowed(Document $collection, Document $document, * @param Document $collection * @param Document $old stored document, whose permissions govern the write * @param Document $document merged new state - * @param bool $documentSecurity * @return void * @throws AuthorizationException */ private function assertColumnsWritable( Document $collection, Document $old, - Document $document, - bool $documentSecurity + Document $document ): void { - $columns = $this->getPermittedColumns($collection, $old, $documentSecurity, self::PERMISSION_UPDATE); + $columns = $this->getPermittedColumns($collection, $old, self::PERMISSION_UPDATE); if ($columns === null) { return; @@ -5714,16 +5710,15 @@ private function assertColumnsWritable( * * @param Document $collection * @param Document $document - * @param bool $documentSecurity * @return Document */ - private function maskUnreadableColumns(Document $collection, Document $document, bool $documentSecurity): 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, $documentSecurity, self::PERMISSION_READ); + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_READ); if ($columns === null) { return $document; @@ -5780,20 +5775,18 @@ private function maskUnreadableColumns(Document $collection, Document $document, * @param Document $collection * @param Document $old unmasked stored document * @param Document $document incoming document - * @param bool $documentSecurity * @return void */ private function preserveHiddenPermissions( Document $collection, Document $old, - Document $document, - bool $documentSecurity + Document $document ): void { if (!$document->offsetExists('$permissions')) { return; } - $columns = $this->getPermittedColumns($collection, $old, $documentSecurity, self::PERMISSION_READ); + $columns = $this->getPermittedColumns($collection, $old, self::PERMISSION_READ); if ($columns === null) { return; @@ -7063,12 +7056,7 @@ public function updateDocument(string $collection, string $id, Document $documen return new Document(); } - $this->preserveHiddenPermissions( - $collection, - $old, - $document, - $collection->getAttribute('documentSecurity', false) - ); + $this->preserveHiddenPermissions($collection, $old, $document); // Only newly introduced ones are rejected. A document that already carries // a column-scoped permission must stay editable -- otherwise disabling the @@ -7240,7 +7228,7 @@ public function updateDocument(string $collection, string $id, Document $documen throw new AuthorizationException($this->authorization->getDescription()); } - $this->assertColumnsWritable($collection, $old, $document, $documentSecurity); + $this->assertColumnsWritable($collection, $old, $document); } else { if (!$this->authorization->isValid(new Input(self::PERMISSION_READ, $readPermissions))) { throw new AuthorizationException($this->authorization->getDescription()); @@ -7491,7 +7479,7 @@ public function updateDocuments( $currentPermissions = $updates->getPermissions(); sort($currentPermissions); - $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions, $documentSecurity) { + $this->withTransaction(function () use ($collection, $updates, &$batch, $currentPermissions) { foreach ($batch as $index => $document) { $skipPermissionsUpdate = true; @@ -7513,7 +7501,7 @@ public function updateDocuments( // 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, $documentSecurity); + $this->assertColumnsWritable($collection, $document, $new); if ($this->resolveRelationships) { $this->unmasked(fn () => $this->silent(fn () => $this->updateDocumentRelationships($collection, $document, $new))); @@ -8298,7 +8286,7 @@ public function upsertDocumentsWithIncrease( throw new AuthorizationException($this->authorization->getDescription()); } - $this->assertColumnsWritable($collection, $old, $document, $documentSecurity); + $this->assertColumnsWritable($collection, $old, $document); } $updatedAt = $document->getUpdatedAt(); @@ -8554,7 +8542,7 @@ public function increaseDocumentAttribute( // 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, $documentSecurity, self::PERMISSION_UPDATE); + $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 . '".'); @@ -8663,7 +8651,7 @@ public function decreaseDocumentAttribute( // 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, $documentSecurity, self::PERMISSION_UPDATE); + $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 . '".'); @@ -9604,7 +9592,7 @@ public function find(string $collection, array $queries = [], string $forPermiss // 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, $documentSecurity); + $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 From b0b267e4e4a4084fa47b62b782239aad269045ba Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 17:48:04 +0300 Subject: [PATCH 09/46] fix --- src/Database/Adapter/Postgres.php | 9 +- src/Database/Database.php | 16 +++- .../unit/ColumnPermissionEnforcementTest.php | 84 +++++++++++++++++++ tests/unit/ColumnPermissionTest.php | 65 +++++++++----- 4 files changed, 147 insertions(+), 27 deletions(-) diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index dbf1e7aaef..ed69dfdfbe 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2226,13 +2226,20 @@ public function prepareColumnPermissions(Document $collection): bool */ protected function hasColumnPermissionsIndex(string $index): bool { + // Filtered by schema. pg_indexes spans every schema the connection can see, + // and index names are unique only within one -- two projects in their own + // schemas generate the same name for a collection with the same id. Without + // this, one project already migrated would make another look migrated too, + // and its index would silently stay on the narrow shape. $stmt = $this->getPDO()->prepare(" SELECT 1 FROM pg_indexes - WHERE indexname = :index + WHERE schemaname = :schema + AND indexname = :index AND indexdef LIKE '%_column%' LIMIT 1 "); + $stmt->bindValue(':schema', $this->getDatabase()); $stmt->bindValue(':index', $index); $stmt->execute(); diff --git a/src/Database/Database.php b/src/Database/Database.php index ab598454cd..c113bf0abd 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -7322,7 +7322,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->maskUnreadableColumns($collection, $document); } /** @@ -7555,7 +7559,10 @@ public function updateDocuments( $doc = $this->decode($collection, $doc); } try { - $onNext && $onNext($doc, $old[$index]); + $onNext && $onNext( + $this->maskUnreadableColumns($collection, $doc), + $this->maskUnreadableColumns($collection, $old[$index]) + ); } catch (Throwable $th) { $onError ? $onError($th) : throw $th; } @@ -8456,7 +8463,10 @@ public function upsertDocumentsWithIncrease( } try { - $onNext && $onNext($doc, $old->isEmpty() ? null : $old); + $onNext && $onNext( + $this->maskUnreadableColumns($collection, $doc), + $old->isEmpty() ? null : $this->maskUnreadableColumns($collection, $old) + ); } catch (\Throwable $th) { $onError ? $onError($th) : throw $th; } diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 9aa8dd8f03..4cc6fc1a4b 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -11,6 +11,7 @@ use Utopia\Database\Exception\Authorization as AuthorizationException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; /** @@ -308,6 +309,89 @@ public function testUnscopedUpdaterMayRewritePermissions(): void $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', + ])); + + $this->assertSame('bob@example.com', $returned->getAttribute('email')); + $this->assertNull($returned->getAttribute('name'), 'a writable column is not thereby readable'); + $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 + )); + } + ); + + $this->assertSame([['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(); diff --git a/tests/unit/ColumnPermissionTest.php b/tests/unit/ColumnPermissionTest.php index 922177365b..fd6954388a 100644 --- a/tests/unit/ColumnPermissionTest.php +++ b/tests/unit/ColumnPermissionTest.php @@ -3,10 +3,15 @@ namespace Tests\Unit; use PHPUnit\Framework\TestCase; +use Utopia\Cache\Adapter\None as NoCache; +use Utopia\Cache\Cache; +use Utopia\Database\Adapter\Memory; +use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Validator\Authorization; use Utopia\Database\Validator\Permissions; class ColumnPermissionTest extends TestCase @@ -95,34 +100,48 @@ public function testWildcardColumnIsRejected(): void } /** - * A column-scoped permission must still resolve to a bare role, or every - * existing document-level authorization check silently breaks. + * 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 testDocumentPermissionsByTypeReturnsRolesOnly(): void + public function testColumnScopedGrantStillGrantsTheRowToThatRole(): void { - $document = new Document(['$permissions' => [ - 'read("any")', - 'read("user:1", "salary")', - 'update("user:1", "name")', - 'delete("user:1")', - ]]); + $authorization = new Authorization(); - $this->assertSame(['any', 'user:1'], \array_values($document->getRead())); - $this->assertSame(['user:1'], \array_values($document->getUpdate())); - $this->assertSame(['user:1'], \array_values($document->getDelete())); - } + $database = new Database(new Memory(), new Cache(new NoCache())); + $database + ->setAuthorization($authorization) + ->setDatabase('columnPermissions') + ->setNamespace('cpt_' . \uniqid()); - public function testDocumentPermissionsByTypeWithColumns(): void - { - $document = new Document(['$permissions' => [ - 'read("any")', - 'read("user:1", "salary")', - ]]); + $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->assertSame([ - ['role' => 'any', 'column' => Permission::COLUMN_ALL], - ['role' => 'user:1', 'column' => 'salary'], - ], $document->getPermissionsByTypeWithColumns('read')); + $this->assertTrue($database->getDocument('employees', 'e1')->isEmpty()); } public function testValidatorAcceptsColumnScopedReadCreateUpdate(): void From 16a5dc67ee60088d1800896cff77cc2ad7d29264 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 18:26:36 +0300 Subject: [PATCH 10/46] $onNext --- src/Database/Database.php | 61 ++++++++++++++++++- .../unit/ColumnPermissionEnforcementTest.php | 10 ++- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index c113bf0abd..be80b401f0 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5764,6 +5764,57 @@ private function maskUnreadableColumns(Document $collection, Document $document) return $document; } + /** + * 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. Operators are not exempt: + * the caller supplied an instruction, not a value, so the result is something + * they do not already know. + * + * @param Document $collection + * @param Document $document merged result of the write + * @param Document $updates what the caller supplied + * @return Document + */ + private function maskWriteResponse(Document $collection, Document $document, Document $updates): Document + { + $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_READ); + + if ($columns === null) { + return $document; + } + + foreach ($updates->getArrayCopy() as $key => $value) { + if (\str_starts_with($key, '$') || Operator::isOperator($value)) { + continue; + } + + $columns[] = $key; + } + + $columns = \array_values(\array_unique($columns)); + + $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. * @@ -7040,6 +7091,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'); } @@ -7326,7 +7381,7 @@ public function updateDocument(string $collection, string $id, Document $documen // 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->maskUnreadableColumns($collection, $document); + return $this->maskWriteResponse($collection, $document, $supplied); } /** @@ -7560,7 +7615,7 @@ public function updateDocuments( } try { $onNext && $onNext( - $this->maskUnreadableColumns($collection, $doc), + $this->maskWriteResponse($collection, $doc, $updates), $this->maskUnreadableColumns($collection, $old[$index]) ); } catch (Throwable $th) { @@ -8464,7 +8519,7 @@ public function upsertDocumentsWithIncrease( try { $onNext && $onNext( - $this->maskUnreadableColumns($collection, $doc), + $this->maskWriteResponse($collection, $doc, $doc), $old->isEmpty() ? null : $this->maskUnreadableColumns($collection, $old) ); } catch (\Throwable $th) { diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 4cc6fc1a4b..17d5ea9b72 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -337,8 +337,13 @@ public function testUpdateResponseIsMaskedByReadPermissions(): void 'name' => 'Robert', ])); + // readable, so returned $this->assertSame('bob@example.com', $returned->getAttribute('email')); - $this->assertNull($returned->getAttribute('name'), 'a writable column is not thereby readable'); + + // 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 @@ -383,7 +388,8 @@ public function testBulkUpdateCallbackPayloadIsMasked(): void } ); - $this->assertSame([['email']], $seen, 'bulk callback leaked hidden columns'); + // `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') From a7a363eac604abaa3db151d25a23e3ef89438140 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 18:41:40 +0300 Subject: [PATCH 11/46] fix maskWriteResponse --- src/Database/Database.php | 73 ++++++++++++++------ tests/e2e/Adapter/Scopes/PermissionTests.php | 72 +++++++++++++++++++ 2 files changed, 125 insertions(+), 20 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index be80b401f0..9493b808c3 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5764,6 +5764,30 @@ private function maskUnreadableColumns(Document $collection, Document $document) 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. * @@ -5773,16 +5797,18 @@ private function maskUnreadableColumns(Document $collection, Document $document) * * 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. Operators are not exempt: - * the caller supplied an instruction, not a value, so the result is something - * they do not already know. + * 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 Document $updates what the caller supplied + * @param array $supplied column keys this caller provided * @return Document */ - private function maskWriteResponse(Document $collection, Document $document, Document $updates): Document + private function maskWriteResponse(Document $collection, Document $document, array $supplied): Document { $columns = $this->getPermittedColumns($collection, $document, self::PERMISSION_READ); @@ -5790,15 +5816,7 @@ private function maskWriteResponse(Document $collection, Document $document, Doc return $document; } - foreach ($updates->getArrayCopy() as $key => $value) { - if (\str_starts_with($key, '$') || Operator::isOperator($value)) { - continue; - } - - $columns[] = $key; - } - - $columns = \array_values(\array_unique($columns)); + $columns = \array_values(\array_unique([...$columns, ...$supplied])); $document = clone $document; @@ -7381,7 +7399,7 @@ public function updateDocument(string $collection, string $id, Document $documen // 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, $supplied); + return $this->maskWriteResponse($collection, $document, self::suppliedColumns($supplied)); } /** @@ -7615,7 +7633,7 @@ public function updateDocuments( } try { $onNext && $onNext( - $this->maskWriteResponse($collection, $doc, $updates), + $this->maskWriteResponse($collection, $doc, self::suppliedColumns($updates)), $this->maskUnreadableColumns($collection, $old[$index]) ); } catch (Throwable $th) { @@ -8255,9 +8273,15 @@ 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 id because the batches are re-indexed later. + $suppliedColumns[$document->getId()] = self::suppliedColumns($document); + $document = $this->removeUnknownAttributes($collection, $document); // Extract operators early to avoid comparison issues @@ -8518,8 +8542,11 @@ public function upsertDocumentsWithIncrease( } try { + // 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, $doc), + $this->maskWriteResponse($collection, $doc, $suppliedColumns[$doc->getId()] ?? []), $old->isEmpty() ? null : $this->maskUnreadableColumns($collection, $old) ); } catch (\Throwable $th) { @@ -8642,7 +8669,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); } @@ -8751,7 +8781,7 @@ public function decreaseDocumentAttribute( $this->trigger(self::EVENT_DOCUMENT_DECREASE, $document); - return $document; + return $this->maskUnreadableColumns($collection, $document); } /** @@ -9346,7 +9376,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; } diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index dedb4ce598..3a88492994 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -15,6 +15,78 @@ 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. From 59012f98c8f3e098a69a27a5dfbe1c3d289f32bd Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 19:11:06 +0300 Subject: [PATCH 12/46] fix comments --- src/Database/Database.php | 8 ++++--- tests/unit/ColumnPermissionSqlTest.php | 32 ++++++++++++++++++++++---- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 9493b808c3..da8969b3b8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8279,8 +8279,10 @@ public function upsertDocumentsWithIncrease( $old = $existingDocs[$this->tenantKey($document)] ?? new Document(); // Captured here, before encoding materialises every column of the - // collection. Keyed by id because the batches are re-indexed later. - $suppliedColumns[$document->getId()] = self::suppliedColumns($document); + // 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); @@ -8546,7 +8548,7 @@ public function upsertDocumentsWithIncrease( // 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[$doc->getId()] ?? []), + $this->maskWriteResponse($collection, $doc, $suppliedColumns[$this->tenantKey($doc)] ?? []), $old->isEmpty() ? null : $this->maskUnreadableColumns($collection, $old) ); } catch (\Throwable $th) { diff --git a/tests/unit/ColumnPermissionSqlTest.php b/tests/unit/ColumnPermissionSqlTest.php index ad5c661e2d..86194a8fa9 100644 --- a/tests/unit/ColumnPermissionSqlTest.php +++ b/tests/unit/ColumnPermissionSqlTest.php @@ -110,13 +110,37 @@ private function shape(array $rows): array return $shape; } - public function testColumnIsPersistedOnThePermissionsTable(): void + /** + * 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 { - $rows = $this->authorization->skip( - fn () => $this->database->find('employees', [Query::equal('$id', ['e1'])]) + $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')) ); - $this->assertSame(['read("user:hr", "salary")'], $rows[0]->getPermissions()); + // ...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')); } /** From 721643160a0a92b9ea573948065fbaf181cda377 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 17 Sep 2026 19:16:43 +0300 Subject: [PATCH 13/46] fix comments --- tests/unit/ColumnSecurityFlagTest.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/unit/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php index bcd505f59d..4dcf71dd92 100644 --- a/tests/unit/ColumnSecurityFlagTest.php +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -334,7 +334,16 @@ public function testExistingColumnPermissionsDoNotBlockOrdinaryUpdates(): void $stored = $this->authorization->skip(fn () => $this->database->getDocument('secured', 'd1')); $this->assertSame('Robert', $stored->getAttribute('name')); - $this->assertSame(['read("user:hr", "salary")'], $stored->getPermissions()); + + // 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')); } /** From 9898d2806334e664928096e262beaeaffe383414 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 22 Sep 2026 16:39:42 +0300 Subject: [PATCH 14/46] Remove prepareColumnPermissions --- src/Database/Adapter.php | 11 --- src/Database/Adapter/MariaDB.php | 134 ++++-------------------------- src/Database/Adapter/Memory.php | 5 -- src/Database/Adapter/Mongo.php | 21 ----- src/Database/Adapter/Pool.php | 5 -- src/Database/Adapter/Postgres.php | 99 ---------------------- src/Database/Adapter/Redis.php | 5 -- src/Database/Adapter/SQL.php | 16 ++++ src/Database/Adapter/SQLite.php | 101 ++-------------------- src/Database/Database.php | 16 ++-- 10 files changed, 48 insertions(+), 365 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index bc02d2da18..775361c60f 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1040,17 +1040,6 @@ abstract public function renameColumnPermissions(Document $collection, string $o */ abstract public function deleteColumnPermissions(Document $collection, string $column): array; - /** - * Prepare a collection's permissions table to hold column-scoped permissions. - * - * Tables created after column permissions existed are already in this shape, so - * this is a no-op for them; older ones gain the column and a widened unique index. - * - * @param Document $collection - * @return bool - */ - abstract public function prepareColumnPermissions(Document $collection): bool; - /** * Is any permission in this collection still scoped to a column? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 4a8d07259f..fe063fb1c4 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -193,10 +193,11 @@ public function createCollection(string $name, array $attributes = [], array $in // 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 _index1. + // duplicate permission rows slip past the unique index. // // Sized to MAX_UID_DEFAULT_LENGTH rather than the 255 the other string members - // use. _index1 holds four of those, and in utf8mb4 a fifth VARCHAR(255) member + // 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 @@ -214,12 +215,12 @@ public function createCollection(string $name, array $attributes = [], array $in if ($this->sharedTables) { $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, - UNIQUE INDEX _index1 (_document, _tenant, _type, _permission, _column), + UNIQUE INDEX " . static::PERMISSIONS_INDEX . " (_document, _tenant, _type, _permission, _column), INDEX _permission (_tenant, _permission, _type) "; } else { $permissions .= " - UNIQUE INDEX _index1 (_document, _type, _permission, _column), + UNIQUE INDEX " . static::PERMISSIONS_INDEX . " (_document, _type, _permission, _column), INDEX _permission (_permission, _type) "; } @@ -961,13 +962,13 @@ public function createDocument(Document $collection, Document $document): Docume $stmtPermissions->execute(); } catch (PDOException $e) { // Compare the violated key exactly rather than searching the - // message for a substring: '_index1' is contained in plenty of - // other index names, and misreading one would run the cleanup - // below against permissions that were never orphaned. + // 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 - && $this->getViolatedKey($e->getMessage()) === '_index1'; + && $this->isPermissionsIndex($this->getViolatedKey($e->getMessage())); if (!$isOrphanedPermission) { throw $e; @@ -1818,122 +1819,19 @@ public function getSupportForColumnPermissions(): bool } /** - * Give an older permissions table the shape column permissions need. + * Is this the unique index on a permissions table, under either name? * - * Tables created since column permissions existed already have both parts, so - * this does nothing for them. For older ones it runs two changes with very - * different costs: adding the column is metadata-only and instant at any table - * size, while widening the unique index has to read every row. + * 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. * - * Both index changes go in a single statement on purpose. Dropping the old unique - * index first would leave a window with no uniqueness at all, during which - * duplicate permission rows could be inserted -- and the new index would then - * fail to build. - * - * @param Document $collection - * @return bool - * @throws DatabaseException - */ - public function prepareColumnPermissions(Document $collection): bool - { - $name = $this->filter($collection->getId()); - $table = $this->getSQLTable($name . '_perms'); - - $hasColumn = $this->hasColumnPermissionsColumn($name); - $hasIndex = $this->hasColumnPermissionsIndex($name); - - // Both halves are checked separately. A table created since column - // permissions existed already has both, so this is a no-op for it. And the - // two can genuinely disagree: adding the column is instant while rebuilding - // the index reads every row, so a prepare interrupted between them leaves the - // column in place and the index narrow. Keying the whole method off the - // column would then skip the rebuild for good, and two permissions scoped to - // different columns of one document would collide. - if ($hasColumn && $hasIndex) { - return true; - } - - $index = $this->sharedTables - ? '(_document, _tenant, _type, _permission, _column)' - : '(_document, _type, _permission, _column)'; - - try { - if (!$hasColumn) { - $this->getPDO()->prepare(" - ALTER TABLE {$table} - ADD COLUMN _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' - ")->execute(); - } - - if (!$hasIndex) { - // Dropped and added in one statement so uniqueness is never absent: - // splitting them leaves a window in which duplicate permission rows - // can land, and the rebuild then fails on them, leaving no unique - // index at all. - $this->getPDO()->prepare(" - ALTER TABLE {$table} - DROP INDEX _index1, - ADD UNIQUE INDEX _index1 {$index}, - ALGORITHM=INPLACE, LOCK=NONE - ")->execute(); - } - } catch (PDOException $e) { - throw $this->processException($e); - } - - return true; - } - - /** - * @param string $name filtered collection id - * @return bool - * @throws DatabaseException - */ - /** - * Does the unique permissions index already cover _column? - * - * @param string $name filtered collection id + * @param string|null $key * @return bool - * @throws DatabaseException */ - protected function hasColumnPermissionsIndex(string $name): bool + protected function isPermissionsIndex(?string $key): bool { - $stmt = $this->getPDO()->prepare(" - SELECT 1 - FROM INFORMATION_SCHEMA.STATISTICS - WHERE TABLE_SCHEMA = :schema - AND TABLE_NAME = :table - AND INDEX_NAME = '_index1' - AND COLUMN_NAME = '_column' - LIMIT 1 - "); - $stmt->bindValue(':schema', $this->getDatabase()); - $stmt->bindValue(':table', $this->getNamespace() . '_' . $name . '_perms'); - $stmt->execute(); - - $found = $stmt->fetchColumn(); - $stmt->closeCursor(); - - return $found !== false; + return $key === static::PERMISSIONS_INDEX || $key === static::PERMISSIONS_INDEX_LEGACY; } - protected function hasColumnPermissionsColumn(string $name): bool - { - $stmt = $this->getPDO()->prepare(" - SELECT 1 - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_SCHEMA = :schema AND TABLE_NAME = :table AND COLUMN_NAME = '_column' - LIMIT 1 - "); - $stmt->bindValue(':schema', $this->getDatabase()); - $stmt->bindValue(':table', $this->getNamespace() . '_' . $name . '_perms'); - $stmt->execute(); - - $found = $stmt->fetchColumn(); - $stmt->closeCursor(); - - return $found !== false; - } public function getSupportForSchemaAttributes(): bool { @@ -2060,7 +1958,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 c242a7a652..b1fdb263c4 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2176,11 +2176,6 @@ private function repointColumnPermissions(Document $collection, string $old, ?st return $updated; } - public function prepareColumnPermissions(Document $collection): bool - { - return false; - } - public function hasColumnPermissions(Document $collection): bool { return false; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 9fb2aafaea..48fed9d6a6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4294,27 +4294,6 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Column-level permissions are not supported by this adapter, so a rename - * can never have column-scoped permissions to repoint. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return array - */ - /** - * Nothing to prepare: permissions live inline on each document, so this adapter - * has no permissions table to widen. - * - * @param Document $collection - * @return bool - */ - public function prepareColumnPermissions(Document $collection): bool - { - return true; - } - /** * Is any permission in this collection still scoped to a column? * diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 5ab9221bed..05baa711f3 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -537,11 +537,6 @@ public function deleteColumnPermissions(Document $collection, string $column): a return $this->delegate(__FUNCTION__, \func_get_args()); } - public function prepareColumnPermissions(Document $collection): bool - { - return $this->delegate(__FUNCTION__, \func_get_args()); - } - public function hasColumnPermissions(Document $collection): bool { return $this->delegate(__FUNCTION__, \func_get_args()); diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index ed69dfdfbe..25b2a1aaee 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2150,105 +2150,6 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Give an older permissions table the shape column permissions need. - * - * Postgres names indexes per schema rather than per table, so the unique index is - * dropped and recreated under the same generated name the table was built with. - * IF NOT EXISTS keeps this safe to run twice. - * - * @param Document $collection - * @return bool - * @throws DatabaseException - */ - public function prepareColumnPermissions(Document $collection): bool - { - $id = $this->filter($collection->getId()); - $table = $this->getSQLTable($id . '_perms'); - $namespace = $this->getNamespace(); - - if ($this->sharedTables) { - $unique = $this->getShortKey("{$namespace}_{$this->tenant}_{$id}_ukey"); - $columns = '(_tenant,_document,_type,_permission,_column)'; - } else { - $unique = $this->getShortKey("{$namespace}_{$id}_ukey"); - $columns = '(_document COLLATE utf8_ci_ai,_type,_permission,_column)'; - } - - // A table created since column permissions existed already has both the - // column and the widened index, so there is nothing to do. Checked separately - // because a prepare interrupted between them -- the column is instant, the - // index reads every row -- leaves the column present and the index narrow. - $hasIndex = $this->hasColumnPermissionsIndex($unique); - - $staged = $this->getShortKey("{$unique}_staged"); - - try { - $this->getPDO()->prepare(" - ALTER TABLE {$table} - ADD COLUMN IF NOT EXISTS _column VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' - ")->execute(); - - if (!$hasIndex) { - // Build the replacement before dropping what it replaces. Postgres - // cannot drop and create an index in one statement, so doing it in - // that order would leave a window with no uniqueness at all -- and a - // duplicate inserted during that window makes the CREATE fail, - // leaving the table with no unique index rather than the old one. - // - // Safe in this order because the existing index is the stricter of - // the two: it forbids two rows sharing (document, type, permission) - // whatever their column, so nothing it allows can violate the wider - // one being built. The swap itself is metadata only. - $this->getPDO()->prepare("DROP INDEX IF EXISTS \"{$staged}\"")->execute(); - - $this->getPDO()->prepare(" - CREATE UNIQUE INDEX \"{$staged}\" ON {$table} USING btree {$columns} - ")->execute(); - - $this->getPDO()->prepare("DROP INDEX IF EXISTS \"{$unique}\"")->execute(); - - $this->getPDO()->prepare("ALTER INDEX \"{$staged}\" RENAME TO \"{$unique}\"")->execute(); - } - } catch (PDOException $e) { - throw $this->processException($e); - } - - return true; - } - - /** - * Does the named unique index already cover _column? - * - * @param string $index - * @return bool - * @throws DatabaseException - */ - protected function hasColumnPermissionsIndex(string $index): bool - { - // Filtered by schema. pg_indexes spans every schema the connection can see, - // and index names are unique only within one -- two projects in their own - // schemas generate the same name for a collection with the same id. Without - // this, one project already migrated would make another look migrated too, - // and its index would silently stay on the narrow shape. - $stmt = $this->getPDO()->prepare(" - SELECT 1 - FROM pg_indexes - WHERE schemaname = :schema - AND indexname = :index - AND indexdef LIKE '%_column%' - LIMIT 1 - "); - $stmt->bindValue(':schema', $this->getDatabase()); - $stmt->bindValue(':index', $index); - $stmt->execute(); - - $found = $stmt->fetchColumn(); - $stmt->closeCursor(); - - return $found !== false; - } - public function getSupportForSchemaAttributes(): bool { return false; diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 7ae4887dde..f3b0649345 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -795,11 +795,6 @@ public function deleteColumnPermissions(Document $collection, string $column): a return []; } - public function prepareColumnPermissions(Document $collection): bool - { - return false; - } - public function hasColumnPermissions(Document $collection): bool { return false; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 6fb1278c39..1484c8206c 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -23,6 +23,22 @@ 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'; + protected mixed $pdo; /** diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 5edb33f81c..7f25d2e08e 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'; @@ -441,7 +447,7 @@ 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', '_column'], [], []); + $this->createIndex("{$id}_perms", static::PERMISSIONS_INDEX, Database::INDEX_UNIQUE, ['_document', '_type', '_permission', '_column'], [], []); $this->createIndex("{$id}_perms", '_index_2', Database::INDEX_KEY, ['_permission', '_type'], [], []); if ($this->sharedTables) { @@ -1540,99 +1546,6 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Give an older permissions table the shape column permissions need. - * - * SQLite has no INFORMATION_SCHEMA, so the column list comes from PRAGMA, and - * indexes are dropped and recreated rather than altered. - * - * @param Document $collection - * @return bool - * @throws DatabaseException - */ - public function prepareColumnPermissions(Document $collection): bool - { - $id = $this->filter($collection->getId()); - $table = "{$this->getNamespace()}_{$id}_perms"; - - $hasColumn = false; - foreach ($this->getPDO()->query("PRAGMA table_info(`{$table}`)")->fetchAll() as $column) { - if (($column['name'] ?? null) === '_column') { - $hasColumn = true; - break; - } - } - - $hasIndex = $this->hasColumnPermissionsIndex($table); - - // Both are checked, not just the column. A table created since column - // permissions existed has both already; and a prepare interrupted between - // adding the column and rebuilding the index leaves them disagreeing, which - // keying off the column alone would never repair. - if ($hasColumn && $hasIndex) { - return true; - } - - if (!$hasColumn) { - try { - $this->getPDO()->prepare(" - ALTER TABLE `{$table}` ADD COLUMN `_column` VARCHAR(" . Database::MAX_PERMISSION_COLUMN_LENGTH . ") NOT NULL DEFAULT '' - ")->execute(); - } catch (PDOException $e) { - throw $this->processException($e); - } - } - - if ($hasIndex) { - return true; - } - - // One transaction, so uniqueness is never absent. Dropping and recreating as - // two statements leaves a window in which a duplicate permission row can be - // inserted -- and the recreate then fails, leaving the table with no unique - // index at all. SQLite keeps DDL transactional, so the pair is atomic. - $this->startTransaction(); - - try { - $this->deleteIndex("{$id}_perms", '_index_1'); - $this->createIndex("{$id}_perms", '_index_1', Database::INDEX_UNIQUE, ['_document', '_type', '_permission', '_column'], [], []); - } catch (\Throwable $e) { - $this->rollbackTransaction(); - - throw $e; - } - - $this->commitTransaction(); - - return true; - } - - /** - * Does the unique permissions index already cover _column? - * - * Found through PRAGMA rather than by rebuilding the index name, so it cannot - * drift from however createIndex() chose to name it. - * - * @param string $table unprefixed physical table name - * @return bool - */ - protected function hasColumnPermissionsIndex(string $table): bool - { - foreach ($this->getPDO()->query("PRAGMA index_list(`{$table}`)")->fetchAll() as $index) { - if ((int)($index['unique'] ?? 0) !== 1) { - continue; - } - - foreach ($this->getPDO()->query("PRAGMA index_info(`{$index['name']}`)")->fetchAll() as $column) { - if (($column['name'] ?? null) === '_column') { - return true; - } - } - } - - return false; - } - public function getSupportForSchemaAttributes(): bool { return true; diff --git a/src/Database/Database.php b/src/Database/Database.php index da8969b3b8..2bf56a5aee 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5239,11 +5239,15 @@ private function getColumnKeys(Document $collection): array /** * Turn column security on or off for a collection. * - * Enabling prepares the permissions table, which for a collection created before - * column permissions existed means an ALTER. Disabling is refused while any - * permission is still scoped to a column: the row filter matches on the role - * alone, so such a permission would widen to the whole row once the column part - * stops being written and queried. + * Enabling only sets the flag. Every permissions table created since column + * permissions existed already carries _column and the unique index over it, and + * older ones are brought to that shape by a migration rather than on the fly -- + * an ALTER that reads every permission row has no business running inside an + * API request. + * + * Disabling is refused while any permission is still scoped to a column: the row + * filter matches on the role alone, so such a permission would widen to the whole + * row once the column part stops being written and queried. * * @param Document $collection * @param bool $columnSecurity @@ -5257,8 +5261,6 @@ private function setColumnSecurity(Document $collection, bool $columnSecurity): throw new DatabaseException('Column security is not supported by this adapter'); } - $this->adapter->prepareColumnPermissions($collection); - return; } From d731cd1d9d2b722019b576fa9b6dcdfdc7986e17 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 23 Sep 2026 13:28:11 +0300 Subject: [PATCH 15/46] allow toggle perms --- src/Database/Adapter.php | 8 -- src/Database/Adapter/MariaDB.php | 35 ++---- src/Database/Adapter/Memory.php | 5 - src/Database/Adapter/Mongo.php | 54 --------- src/Database/Adapter/Pool.php | 5 - src/Database/Adapter/Postgres.php | 28 ++--- src/Database/Adapter/Redis.php | 5 - src/Database/Adapter/SQL.php | 114 +++--------------- src/Database/Adapter/SQLite.php | 26 ++-- src/Database/Database.php | 88 ++++++-------- src/Database/Mirror.php | 2 +- tests/e2e/Adapter/MirrorTest.php | 3 +- tests/e2e/Adapter/Scopes/CollectionTests.php | 4 +- tests/e2e/Adapter/Scopes/DocumentTests.php | 12 +- tests/e2e/Adapter/Scopes/PermissionTests.php | 2 +- .../e2e/Adapter/Scopes/RelationshipTests.php | 2 +- tests/unit/ColumnSecurityFlagTest.php | 108 +++++++++++++++-- 17 files changed, 195 insertions(+), 306 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 775361c60f..339dedd088 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1040,14 +1040,6 @@ abstract public function renameColumnPermissions(Document $collection, string $o */ abstract public function deleteColumnPermissions(Document $collection, string $column): array; - /** - * Is any permission in this collection still scoped to a column? - * - * @param Document $collection - * @return bool - */ - abstract public function hasColumnPermissions(Document $collection): bool; - /** * Are schema indexes supported? * diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index fe063fb1c4..eca38ecebf 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -843,7 +843,6 @@ public function createDocument(Document $collection, Document $document): Docume { try { $spatialAttributes = $this->getSpatialAttributes($collection); - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -909,9 +908,12 @@ public function createDocument(Document $collection, Document $document): Docume $attributeIndex++; } - // _column is named only when the collection enabled column security, so - // a table that never did is never referenced with it and needs no ALTER. - // Same shape as the _tenant conditional below. + // _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) { @@ -919,23 +921,18 @@ public function createDocument(Document $collection, Document $document): Docume $tenantBind = $this->sharedTables ? ", :_tenant" : ''; $role = \str_replace('"', '', $permission['role']); - if ($columnSecurity) { - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$tenantBind})"; - } else { - $permissions[] = "('{$type}', '{$role}', :_uid {$tenantBind})"; - } + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$tenantBind})"; } } if (!empty($permissions)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $permissions = \implode(', ', $permissions); $sqlPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) VALUES {$permissions}; "; @@ -1010,7 +1007,6 @@ public function updateDocument(Document $collection, string $id, Document $docum { try { $spatialAttributes = $this->getSpatialAttributes($collection); - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1044,12 +1040,8 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantPlaceholder = $this->sharedTables ? ', :_tenant' : ''; - if ($columnSecurity) { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantPlaceholder})"; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; - } else { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$tenantPlaceholder})"; - } + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantPlaceholder})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; $binds[":_add_{$type}_{$i}"] = $permission['role']; } @@ -1057,10 +1049,9 @@ public function updateDocument(Document $collection, string $id, Document $docum if (!empty($values)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} {$tenantColumn}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$tenantColumn}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index b1fdb263c4..426e91b6c8 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2176,11 +2176,6 @@ private function repointColumnPermissions(Document $collection, string $old, ?st return $updated; } - public function hasColumnPermissions(Document $collection): bool - { - return false; - } - public function getTenantQuery(string $collection, string $alias = ''): string { return ''; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 48fed9d6a6..d20a2a25f6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4294,60 +4294,6 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Is any permission in this collection still scoped to a column? - * - * Read by the guard that refuses to disable column security while such grants - * exist. No index can answer it -- the column is inside an assembled string -- so - * it scans, stopping at the first document that has one. - * - * @param Document $collection - * @return bool - * @throws Exception - */ - public function hasColumnPermissions(Document $collection): bool - { - if (!$collection->getAttribute('columnSecurity', false)) { - return false; - } - - $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - $cursor = null; - - while (true) { - $filters = []; - - if (!\is_null($cursor)) { - $filters['_uid'] = ['$gt' => $cursor]; - } - - if ($this->sharedTables) { - $filters['_tenant'] = $this->getTenantFilters($collection->getId()); - } - - $found = $this->client->find($name, $filters, [ - 'limit' => Database::DELETE_BATCH_SIZE, - 'sort' => ['_uid' => 1], - 'projection' => ['_uid' => 1, '_permissions' => 1], - ])->cursor->firstBatch ?? []; - - if (empty($found)) { - return false; - } - - foreach ($found as $row) { - $row = $this->client->toArray($row); - $cursor = $row['_uid']; - - foreach ($row['_permissions'] ?? [] as $permission) { - if (!Permission::parse((string)$permission)->isForAllColumns()) { - return true; - } - } - } - } - } - public function renameColumnPermissions(Document $collection, string $old, string $new): array { return $this->repointColumnPermissions($collection, $old, $new); diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 05baa711f3..59c98b1c77 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -537,11 +537,6 @@ public function deleteColumnPermissions(Document $collection, string $column): a return $this->delegate(__FUNCTION__, \func_get_args()); } - public function hasColumnPermissions(Document $collection): bool - { - 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 25b2a1aaee..ec50c34bc2 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -990,7 +990,6 @@ public function renameIndex(string $collection, string $old, string $new): bool */ public function createDocument(Document $collection, Document $document): Document { - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1053,13 +1052,9 @@ public function createDocument(Document $collection, Document $document): Docume foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $role = \str_replace('"', '', $permission['role']); $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - if ($columnSecurity) { - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$sqlTenant})"; - } else { - $permissions[] = "('{$type}', '{$role}', :_uid {$sqlTenant})"; - } + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid {$sqlTenant})"; } } @@ -1067,10 +1062,9 @@ public function createDocument(Document $collection, Document $document): Docume if (!empty($permissions)) { $permissions = \implode(', ', $permissions); $sqlTenant = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $queryPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$sqlTenant}) VALUES {$permissions} "; @@ -1116,7 +1110,6 @@ public function createDocument(Document $collection, Document $document): Docume public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { $spatialAttributes = $this->getSpatialAttributes($collection); - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1149,12 +1142,8 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $sqlTenant = $this->sharedTables ? ', :_tenant' : ''; - if ($columnSecurity) { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$sqlTenant})"; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; - } else { - $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i} {$sqlTenant})"; - } + $values[] = "( :_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$sqlTenant})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; $binds[":_add_{$type}_{$i}"] = $permission['role']; } @@ -1162,10 +1151,9 @@ public function updateDocument(Document $collection, string $id, Document $docum if (!empty($values)) { $sqlTenant = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} {$sqlTenant}) + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column {$sqlTenant}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); @@ -2409,7 +2397,7 @@ protected function getInsertSuffix(string $table): string return "ON CONFLICT {$conflictTarget} DO NOTHING"; } - protected function getInsertPermissionsSuffix(bool $columnSecurity = false): string + protected function getInsertPermissionsSuffix(): string { if (!$this->skipDuplicates) { return ''; diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index f3b0649345..3b1f87b82c 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -795,11 +795,6 @@ public function deleteColumnPermissions(Document $collection, string $column): a return []; } - public function hasColumnPermissions(Document $collection): bool - { - return false; - } - public function getSupportForSchemaAttributes(): bool { return false; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 1484c8206c..8f7cb7d9c2 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -521,7 +521,6 @@ protected function getSpatialAttributes(Document $collection): array */ public function updateDocuments(Document $collection, Document $updates, array $documents): int { - $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($documents)) { return 0; } @@ -646,10 +645,8 @@ public function updateDocuments(Document $collection, Document $updates, array $ continue; } - $columnSelect = $columnSecurity ? ', _column' : ''; - $sql = " - SELECT _type, _permission{$columnSelect} + SELECT _type, _permission, _column FROM {$this->getSQLTable($name . '_perms')} WHERE _document = :_uid {$this->getTenantQuery($collection)} @@ -715,17 +712,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $removeBindValues[$roleBind] = $role; $removeBindValues[$columnBind] = $column; - $pairs[] = $columnSecurity - ? "(_permission = :{$roleBind} AND _column = :{$columnBind})" - : "(_permission = :{$roleBind})"; - - if (!$columnSecurity) { - unset($removeBindValues[$columnBind]); - $removeBindKeys = \array_values(\array_filter( - $removeBindKeys, - fn ($key) => $key !== ':' . $columnBind - )); - } + $pairs[] = "(_permission = :{$roleBind} AND _column = :{$columnBind})"; } $removeQueries[] = "( @@ -761,12 +748,7 @@ public function updateDocuments(Document $collection, Document $updates, array $ $columnBindKey = 'addcol_' . $type . '_' . $index . '_' . $i; $addBindValues[$columnBindKey] = $column; - if ($columnSecurity) { - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; - } else { - unset($addBindValues[$columnBindKey]); - $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}"; - } + $addQuery .= "(:_uid_{$index}, '{$type}', :{$bindKey}, :{$columnBindKey}"; if ($this->sharedTables) { $addQuery .= ", :_tenant)"; @@ -805,10 +787,8 @@ public function updateDocuments(Document $collection, Document $updates, array $ } if (!empty($addQuery)) { - $columnColumn = $columnSecurity ? ', _column' : ''; - $sqlAddPermissions = " - INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn} + INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column "; if ($this->sharedTables) { @@ -1166,7 +1146,7 @@ protected function getInsertSuffix(string $table): string * Returns a suffix for the permissions INSERT statement when ignoring duplicates. * Override in adapter subclasses for DB-specific syntax. */ - protected function getInsertPermissionsSuffix(bool $columnSecurity = false): string + protected function getInsertPermissionsSuffix(): string { return ''; } @@ -2096,53 +2076,6 @@ public function deleteColumnPermissions(Document $collection, string $column): a return $this->repointColumnPermissions($collection, $column, null); } - /** - * Does any permission in this collection still name a column? - * - * Used to refuse disabling column security while it would still change meaning: - * the row filter matches on the role alone, so a permission left scoped to a - * column would widen to the whole row once masking stops being applied. - * - * _column is the last member of _index1, so this cannot seek -- it scans. The scan - * is index-only, since every column it reads is in that index, and LIMIT 1 stops - * it at the first match. That makes the refusing case cheap and the permitting - * case (nothing scoped, so nothing to find) a full pass over the index. Acceptable - * because it runs once, on a deliberate disable, rather than on any query path; - * an index on _column would make it instant but has to be maintained on every - * permission write to buy that. - * - * @param Document $collection - * @return bool - * @throws DatabaseException - */ - public function hasColumnPermissions(Document $collection): bool - { - if (!$collection->getAttribute('columnSecurity', false)) { - return false; - } - - $name = $this->filter($collection->getId()); - - $stmt = $this->getPDO()->prepare(" - SELECT 1 - FROM {$this->getSQLTable($name . '_perms')} - WHERE {$this->quote('_column')} <> '' - {$this->getTenantQuery($collection->getId())} - LIMIT 1 - "); - - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - - $this->execute($stmt); - - $found = $stmt->fetchColumn(); - $stmt->closeCursor(); - - return $found !== false; - } - /** * Move or drop the permissions scoped to one column. * @@ -2930,7 +2863,6 @@ protected function execute(mixed $stmt): bool */ public function createDocuments(Document $collection, array $documents): array { - $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($documents)) { return $documents; } @@ -3020,13 +2952,9 @@ public function createDocuments(Document $collection, array $documents): array $tenantBind = $this->sharedTables ? ", :_tenant_{$index}" : ''; $role = \str_replace('"', '', $permission['role']); - if ($columnSecurity) { - $columnBind = ":_column_{$type}_{$index}_{$i}"; - $bindValuesPermissions[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid_{$index} {$tenantBind})"; - } else { - $permissions[] = "('{$type}', '{$role}', :_uid_{$index} {$tenantBind})"; - } + $columnBind = ":_column_{$type}_{$index}_{$i}"; + $bindValuesPermissions[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, :_uid_{$index} {$tenantBind})"; $bindValuesPermissions[":_uid_{$index}"] = $document->getId(); if ($this->sharedTables) { @@ -3052,13 +2980,12 @@ public function createDocuments(Document $collection, array $documents): array if (!empty($permissions)) { $tenantColumn = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $permissions = \implode(', ', $permissions); $sqlPermissions = " - {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission{$columnColumn}, _document {$tenantColumn}) + {$this->getInsertKeyword()} {$this->getSQLTable($name . '_perms')} (_type, _permission, _column, _document {$tenantColumn}) VALUES {$permissions} - {$this->getInsertPermissionsSuffix($columnSecurity)} + {$this->getInsertPermissionsSuffix()} "; $stmtPermissions = $this->getPDO()->prepare($sqlPermissions); @@ -3089,7 +3016,6 @@ public function upsertDocuments( string $attribute, array $changes ): array { - $columnSecurity = $collection->getAttribute('columnSecurity', false); if (empty($changes)) { return $changes; } @@ -3369,12 +3295,8 @@ public function upsertDocuments( $pairs = []; foreach (\array_keys($toRemove) as $i) { [$role, $column] = \explode("\0", $toRemove[$i], 2); - if ($columnSecurity) { - $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i} AND _column = :removecol_{$type}_{$index}_{$i})"; - $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; - } else { - $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i})"; - } + $pairs[] = "(_permission = :remove_{$type}_{$index}_{$i} AND _column = :removecol_{$type}_{$index}_{$i})"; + $removeBindValues[":removecol_{$type}_{$index}_{$i}"] = $column; $removeBindValues[":remove_{$type}_{$index}_{$i}"] = $role; } @@ -3398,9 +3320,7 @@ public function upsertDocuments( foreach ($toAdd as $i => $permission) { [$role, $column] = \explode("\0", $permission, 2); - $addQuery = $columnSecurity - ? "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}, :addcol_{$type}_{$index}_{$i}" - : "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}"; + $addQuery = "(:_uid_{$index}, '{$type}', :add_{$type}_{$index}_{$i}, :addcol_{$type}_{$index}_{$i}"; if ($this->sharedTables) { $addQuery .= ", :_tenant_{$index}"; @@ -3410,10 +3330,7 @@ public function upsertDocuments( $addQueries[] = $addQuery; $addBindValues[":_uid_{$index}"] = $document->getId(); $addBindValues[":add_{$type}_{$index}_{$i}"] = $role; - - if ($columnSecurity) { - $addBindValues[":addcol_{$type}_{$index}_{$i}"] = $column; - } + $addBindValues[":addcol_{$type}_{$index}_{$i}"] = $column; if ($this->sharedTables) { $addBindValues[":_tenant_{$index}"] = $document->getTenant(); @@ -3432,8 +3349,7 @@ public function upsertDocuments( } if (!empty($addQueries)) { - $columnColumn = $columnSecurity ? ', _column' : ''; - $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission{$columnColumn}"; + $sqlAddPermissions = "INSERT INTO {$this->getSQLTable($name . '_perms')} (_document, _type, _permission, _column"; if ($this->sharedTables) { $sqlAddPermissions .= ", _tenant"; } diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 7f25d2e08e..cde7dbcc0f 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1152,7 +1152,6 @@ private function escapeLikePattern(string $value): string */ public function createDocument(Document $collection, Document $document): Document { - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1219,22 +1218,17 @@ public function createDocument(Document $collection, Document $document): Docume foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $role = \str_replace('"', '', $permission['role']); $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - if ($columnSecurity) { - $columnBind = ":_column_{$type}_{$i}"; - $permissionBinds[$columnBind] = $permission['column']; - $permissions[] = "('{$type}', '{$role}', {$columnBind}, '{$document->getId()}' {$tenantQuery})"; - } else { - $permissions[] = "('{$type}', '{$role}', '{$document->getId()}' {$tenantQuery})"; - } + $columnBind = ":_column_{$type}_{$i}"; + $permissionBinds[$columnBind] = $permission['column']; + $permissions[] = "('{$type}', '{$role}', {$columnBind}, '{$document->getId()}' {$tenantQuery})"; } } if (!empty($permissions)) { $tenantQuery = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $queryPermissions = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission{$columnColumn}, _document {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_type, _permission, _column, _document {$tenantQuery}) VALUES " . \implode(', ', $permissions); $queryPermissions = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $queryPermissions); @@ -1281,7 +1275,6 @@ public function createDocument(Document $collection, Document $document): Docume public function updateDocument(Document $collection, string $id, Document $document, bool $skipPermissions): Document { $spatialAttributes = $this->getSpatialAttributes($collection); - $columnSecurity = $collection->getAttribute('columnSecurity', false); $collection = $collection->getId(); $attributes = $document->getAttributes(); $attributes['_createdAt'] = $document->getCreatedAt(); @@ -1318,12 +1311,8 @@ public function updateDocument(Document $collection, string $id, Document $docum foreach (Database::PERMISSIONS as $type) { foreach ($document->getPermissionsByTypeWithColumns($type) as $i => $permission) { $tenantQuery = $this->sharedTables ? ', :_tenant' : ''; - if ($columnSecurity) { - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantQuery})"; - $binds[":_addcol_{$type}_{$i}"] = $permission['column']; - } else { - $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i} {$tenantQuery})"; - } + $values[] = "(:_uid, '{$type}', :_add_{$type}_{$i}, :_addcol_{$type}_{$i} {$tenantQuery})"; + $binds[":_addcol_{$type}_{$i}"] = $permission['column']; $binds[":_add_{$type}_{$i}"] = $permission['role']; } @@ -1331,10 +1320,9 @@ public function updateDocument(Document $collection, string $id, Document $docum if (!empty($values)) { $tenantQuery = $this->sharedTables ? ', _tenant' : ''; - $columnColumn = $columnSecurity ? ', _column' : ''; $sql = " - INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission{$columnColumn} {$tenantQuery}) + INSERT INTO `{$this->getNamespace()}_{$name}_perms` (_document, _type, _permission, _column {$tenantQuery}) VALUES " . \implode(', ', $values); $sql = $this->trigger(Database::EVENT_PERMISSIONS_CREATE, $sql); diff --git a/src/Database/Database.php b/src/Database/Database.php index 2bf56a5aee..3c2a6f1dc9 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2028,12 +2028,13 @@ 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, ?bool $columnSecurity = null): Document + public function updateCollection(string $id, array $permissions, bool $documentSecurity, bool $columnSecurity): Document { $collection = $this->silent(fn () => $this->getCollection($id)); @@ -2055,21 +2056,29 @@ public function updateCollection(string $id, array $permissions, bool $documentS throw new NotFoundException('Collection not found'); } - $resolved = $columnSecurity ?? $collection->getAttribute('columnSecurity', false); - + // 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', $resolved), + (new Document($collection->getArrayCopy()))->setAttribute('columnSecurity', $columnSecurity), $permissions ); - if (!\is_null($columnSecurity) && $columnSecurity !== $collection->getAttribute('columnSecurity', false)) { - $this->setColumnSecurity($collection, $columnSecurity); + // 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'); } $collection ->setAttribute('$permissions', $permissions) ->setAttribute('documentSecurity', $documentSecurity) - ->setAttribute('columnSecurity', $columnSecurity ?? $collection->getAttribute('columnSecurity', false)); + ->setAttribute('columnSecurity', $columnSecurity); $collection = $this->silent(fn () => $this->updateDocument(self::METADATA, $collection->getId(), $collection)); @@ -5188,6 +5197,16 @@ private function getPermittedColumns( return null; } + // 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)) { @@ -5236,41 +5255,6 @@ private function getColumnKeys(Document $collection): array return $keys; } - /** - * Turn column security on or off for a collection. - * - * Enabling only sets the flag. Every permissions table created since column - * permissions existed already carries _column and the unique index over it, and - * older ones are brought to that shape by a migration rather than on the fly -- - * an ALTER that reads every permission row has no business running inside an - * API request. - * - * Disabling is refused while any permission is still scoped to a column: the row - * filter matches on the role alone, so such a permission would widen to the whole - * row once the column part stops being written and queried. - * - * @param Document $collection - * @param bool $columnSecurity - * @return void - * @throws DatabaseException - */ - private function setColumnSecurity(Document $collection, bool $columnSecurity): void - { - if ($columnSecurity) { - if (!$this->adapter->getSupportForColumnPermissions()) { - throw new DatabaseException('Column security is not supported by this adapter'); - } - - return; - } - - if ($this->adapter->hasColumnPermissions($collection)) { - throw new DependencyException( - 'Cannot disable column security: permissions scoped to a column still exist. Remove them first.' - ); - } - } - /** * Reject permissions scoped to a column on a collection that has not enabled it. * @@ -7133,14 +7117,14 @@ public function updateDocument(string $collection, string $id, Document $documen $this->preserveHiddenPermissions($collection, $old, $document); - // Only newly introduced ones are rejected. A document that already carries - // a column-scoped permission must stay editable -- otherwise disabling the - // flag, or writing one before it was disabled, would lock the 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, - \array_diff($document->getPermissions(), $old->getPermissions()) - ); + $this->assertColumnSecurityEnabled($collection, $document->getPermissions()); } $skipPermissionsUpdate = true; @@ -8218,6 +8202,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; diff --git a/src/Database/Mirror.php b/src/Database/Mirror.php index 39f9edaa54..e386b261da 100644 --- a/src/Database/Mirror.php +++ b/src/Database/Mirror.php @@ -261,7 +261,7 @@ public function createCollection(string $id, array $attributes = [], array $inde return $result; } - public function updateCollection(string $id, array $permissions, bool $documentSecurity, ?bool $columnSecurity = null): Document + public function updateCollection(string $id, array $permissions, bool $documentSecurity, bool $columnSecurity): Document { $result = $this->source->updateCollection($id, $permissions, $documentSecurity, $columnSecurity); 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 3a88492994..9bb33af680 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1363,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 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/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php index 4dcf71dd92..962be04ab5 100644 --- a/tests/unit/ColumnSecurityFlagTest.php +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -9,7 +9,6 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception as DatabaseException; -use Utopia\Database\Exception\Dependency as DependencyException; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; use Utopia\Database\PDO; @@ -121,6 +120,60 @@ public function testCreateDocumentsBatchWithColumnPermissionIsRejected(): void ])); } + /** + * 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); @@ -181,7 +234,8 @@ public function testUpdateCollectionIntroducingAColumnPermissionIsRejected(): vo $this->authorization->skip(fn () => $this->database->updateCollection( 'plain', [Permission::read(Role::any(), 'name')], - true + true, + false )); } @@ -262,11 +316,11 @@ public function testEnablingLaterPreparesTheTableAndThenAcceptsColumnPermissions } /** - * Disabling would leave the column half of the permission unwritten and - * unenforced, so the row filter -- which matches on the role alone -- would widen - * it to the whole row. Refuse rather than silently escalate. + * 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 testDisablingIsRefusedWhileColumnPermissionsExist(): void + public function testDisablingIsAllowedWhileColumnPermissionsExist(): void { $this->collection('secured', true); @@ -277,10 +331,48 @@ public function testDisablingIsRefusedWhileColumnPermissionsExist(): void 'salary' => 100000, ]))); - $this->expectException(DependencyException::class); - $this->expectExceptionMessage('Cannot disable column security'); + $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 From 827af83be373daa0c1784de60117c1b98fc23c48 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 23 Sep 2026 14:09:14 +0300 Subject: [PATCH 16/46] fix --- README.md | 3 ++- src/Database/Database.php | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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/src/Database/Database.php b/src/Database/Database.php index 00a17f3b7c..764c0c2397 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -8343,6 +8343,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')) { From bebaf2c7b40134e7a654c88d14e9d821539c337c Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 23 Sep 2026 14:15:35 +0300 Subject: [PATCH 17/46] fix --- src/Database/Database.php | 13 ++++++ .../unit/ColumnPermissionEnforcementTest.php | 44 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 764c0c2397..ed2396ca35 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3712,6 +3712,19 @@ public function renameAttribute(string $collection, string $old, string $new): b } } + // A column-scoped permission names its column, in _perms._column and again + // inside the $permissions JSON, so a rename has to repoint both -- the same + // migration updateAttribute() runs for its own rename. Left alone, the grants + // stay attached to the old key: the caller loses the renamed column, and a + // column later created under the old name inherits authority it never earned. + if ($collection->getAttribute('columnSecurity', false)) { + $this->repointCollectionColumnPermissions($collection, $old, $new); + + foreach ($this->adapter->renameColumnPermissions($collection, $old, $new) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); + } + } + $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 17d5ea9b72..b3aa057a76 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -201,6 +201,50 @@ public function testCollectionLevelColumnGrantFollowsARename(): void }); } + /** + * 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'); + } + public function testUpdateOfGrantedColumnIsAllowed(): void { $this->authorization->cleanRoles(); From df025c21346da8d9c1c7723e4bf92c33c0fdbb96 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 23 Sep 2026 15:36:54 +0300 Subject: [PATCH 18/46] fix updateAttribute --- src/Database/Database.php | 34 ++++++++++-------- .../unit/ColumnPermissionEnforcementTest.php | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index ed2396ca35..3233b9655d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3412,9 +3412,15 @@ public function updateAttribute(string $collection, string $id, ?string $type = // again inside the $permissions JSON, so a rename has to repoint both. // There is no stable column id to hang permissions off: this method // rewrites the attribute's '$id' and 'key' together, so the key is the - // only handle there is. The lookup is skipped entirely when no - // permission is scoped to this column, which is the common case. - if (!\is_null($newKey) && $newKey !== $id && $collectionDoc->getAttribute('columnSecurity', false)) { + // only handle there is. + // + // Deliberately not gated on columnSecurity. Disabling the flag leaves + // scoped grants in storage, dormant, so gating here would let a rename + // slip past them -- and re-enabling would then point them at a key that + // no longer exists, or at whatever column later took that name. The + // adapter's first query finds nothing when no grant is scoped to this + // column, which is the common case and costs one lookup. + if (!\is_null($newKey) && $newKey !== $id) { $this->repointCollectionColumnPermissions($collectionDoc, $id, $newKey); foreach ($this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey) as $documentId) { @@ -3570,13 +3576,13 @@ public function deleteAttribute(string $collection, string $id): bool } // Permissions name their column by key, so grants left behind would be - // inherited by any column later created under the same name. - if ($collection->getAttribute('columnSecurity', false)) { - $this->repointCollectionColumnPermissions($collection, $id, null); + // inherited by any column later created under the same name. Runs whatever + // columnSecurity says: disabling it keeps scoped grants in storage rather + // than deleting them, so they still have to be cleaned up here. + $this->repointCollectionColumnPermissions($collection, $id, null); - foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); } $this->updateMetadata( @@ -3717,12 +3723,12 @@ public function renameAttribute(string $collection, string $old, string $new): b // migration updateAttribute() runs for its own rename. Left alone, the grants // stay attached to the old key: the caller loses the renamed column, and a // column later created under the old name inherits authority it never earned. - if ($collection->getAttribute('columnSecurity', false)) { - $this->repointCollectionColumnPermissions($collection, $old, $new); + // Not gated on columnSecurity, for the same reason: the flag controls + // enforcement, not storage, so grants outlive it and still need moving. + $this->repointCollectionColumnPermissions($collection, $old, $new); - foreach ($this->adapter->renameColumnPermissions($collection, $old, $new) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + foreach ($this->adapter->renameColumnPermissions($collection, $old, $new) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); } $collection->setAttribute('attributes', $attributes); diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index b3aa057a76..5c9cf48be4 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -245,6 +245,42 @@ public function testGrantFollowsRenameAttributeAndDoesNotOutliveTheOldKey(): voi $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(); From 263ca1b3236a3d820cda7aea1bc14b945aa83e75 Mon Sep 17 00:00:00 2001 From: fogelito Date: Wed, 23 Sep 2026 15:59:01 +0300 Subject: [PATCH 19/46] Grant Rollback Is Incomplete --- src/Database/Database.php | 42 ++++++++++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 3233b9655d..61942e5401 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3433,16 +3433,36 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->updateMetadata( collection: $collectionDoc, - rollbackOperation: fn () => $this->adapter->updateAttribute( + rollbackOperation: function () use ( $collection, - $newKey ?? $id, + $collectionDoc, + $id, + $newKey, $originalType, - (int)$originalSize, + $originalSize, $originalSigned, $originalArray, $originalKey, $originalRequired - ), + ) { + $this->adapter->updateAttribute( + $collection, + $newKey ?? $id, + $originalType, + (int)$originalSize, + $originalSigned, + $originalArray, + $originalKey, + $originalRequired + ); + + // Only when the rename half actually ran; see the repoint above. + if (!\is_null($newKey) && $newKey !== $id) { + foreach ($this->adapter->renameColumnPermissions($collectionDoc, $newKey, $id) as $documentId) { + $this->purgeCachedDocument($collection, $documentId); + } + } + }, shouldRollback: $updated, operationDescription: "attribute update '{$id}'", silentRollback: true @@ -3734,9 +3754,21 @@ public function renameAttribute(string $collection, string $old, string $new): b $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); + // The grants above are already committed, so the rollback has to walk them + // back alongside the column. Reversing only the schema would leave the schema + // on the old key and the grants on the new one -- access lost now, and + // inherited later by whatever is created under the new name. The collection's + // own grants need no undoing: they live on $collection, which this call is what + // persists, so a failure here means they were never written. $this->updateMetadata( collection: $collection, - rollbackOperation: fn () => $this->adapter->renameAttribute($collection->getId(), $new, $old), + rollbackOperation: function () use ($collection, $old, $new) { + $this->adapter->renameAttribute($collection->getId(), $new, $old); + + foreach ($this->adapter->renameColumnPermissions($collection, $new, $old) as $documentId) { + $this->purgeCachedDocument($collection->getId(), $documentId); + } + }, shouldRollback: $renamed, operationDescription: "attribute rename '{$old}' to '{$new}'" ); From dc6b0585f7fc0f5234ba330b198f286511319376 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 24 Sep 2026 08:49:36 +0300 Subject: [PATCH 20/46] $columnSecurity non optional test --- src/Database/Database.php | 4 ++++ tests/unit/ColumnSecurityFlagTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 61942e5401..ba843779c2 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -2108,6 +2108,10 @@ 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 diff --git a/tests/unit/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php index 962be04ab5..8aa9a6ee51 100644 --- a/tests/unit/ColumnSecurityFlagTest.php +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -77,6 +77,30 @@ private function collection(string $id, bool $columnSecurity, array $permissions }); } + /** + * $columnSecurity is required, like $documentSecurity beside it: the value passed + * is the value stored, in both directions, with no inference from what the + * collection already held. + */ + public function testTheFlagPassedIsTheFlagStored(): void + { + $this->collection('secured', true); + $this->collection('plain', false); + + $this->authorization->skip(function () { + $this->database->updateCollection('secured', [], true, false); + $this->database->updateCollection('plain', [], true, true); + }); + + $collections = $this->authorization->skip(fn () => [ + $this->database->getCollection('secured'), + $this->database->getCollection('plain'), + ]); + + $this->assertFalse($collections[0]->getAttribute('columnSecurity')); + $this->assertTrue($collections[1]->getAttribute('columnSecurity')); + } + public function testDefaultsToOff(): void { $this->collection('plain', false); From 26752590ab4688735c0aa16cfe70e42f9a81ba76 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 24 Sep 2026 12:09:50 +0300 Subject: [PATCH 21/46] Xdebug --- docker-compose.yml | 9 +++++++++ 1 file changed, 9 insertions(+) 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 From d08c61eb292709f00c4dca516a894dcf1b9a1745 Mon Sep 17 00:00:00 2001 From: fogelito Date: Thu, 24 Sep 2026 12:50:52 +0300 Subject: [PATCH 22/46] Add _documentInternalId for future purposes --- src/Database/Adapter/MariaDB.php | 3 +++ src/Database/Adapter/Postgres.php | 9 ++++++++- src/Database/Adapter/SQL.php | 21 +++++++++++++++++++++ src/Database/Adapter/SQLite.php | 4 +++- 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index eca38ecebf..52d3eb349f 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -209,6 +209,7 @@ public function createCollection(string $name, array $attributes = [], array $in _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), "; @@ -216,11 +217,13 @@ public function createCollection(string $name, array $attributes = [], array $in $permissions .= " _tenant INT(11) UNSIGNED DEFAULT NULL, 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 " . static::PERMISSIONS_INDEX . " (_document, _type, _permission, _column), + INDEX " . static::PERMISSIONS_INDEX_DOCUMENT . " (_documentInternalId, _type, _permission, _column), INDEX _permission (_permission, _type) "; } diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index ec50c34bc2..b572264135 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -257,25 +257,32 @@ public function createCollection(string $name, array $attributes = [], array $in _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 + _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,_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,_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); "; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 8f7cb7d9c2..901ec5c330 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -39,6 +39,27 @@ abstract class SQL extends Adapter */ 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'; + protected mixed $pdo; /** diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index cde7dbcc0f..16f5085916 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -428,7 +428,8 @@ public function createCollection(string $name, array $attributes = [], array $in `_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 + `_document` VARCHAR(255) NOT NULL, + `_documentInternalId` BIGINT NOT NULL DEFAULT 0 ) "; @@ -448,6 +449,7 @@ public function createCollection(string $name, array $attributes = [], array $in $this->createIndex($id, '_updated_at', Database::INDEX_KEY, [ '_updatedAt'], [], []); $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) { From 8b8652a03754c96e39bf2a3a41b77395b1d6e66c Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 08:47:55 +0300 Subject: [PATCH 23/46] renameColumnPermissions + deleteColumnPermissions return int --- src/Database/Adapter.php | 8 +- src/Database/Adapter/Memory.php | 14 ++-- src/Database/Adapter/Mongo.php | 12 +-- src/Database/Adapter/Pool.php | 4 +- src/Database/Adapter/Redis.php | 10 +-- src/Database/Adapter/SQL.php | 125 +++++++++++++++++++------------- src/Database/Database.php | 48 ++++++++---- 7 files changed, 133 insertions(+), 88 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 339dedd088..2f45f1c95d 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1027,18 +1027,18 @@ abstract public function getSupportForColumnPermissions(): bool; * @param Document $collection * @param string $old * @param string $new - * @return array ids of documents whose $permissions changed + * @return int documents whose $permissions changed */ - abstract public function renameColumnPermissions(Document $collection, string $old, string $new): array; + abstract public function renameColumnPermissions(Document $collection, string $old, string $new): int; /** * Drop every permission scoped to a column that no longer exists. * * @param Document $collection * @param string $column - * @return array ids of documents whose $permissions changed + * @return int documents whose $permissions changed */ - abstract public function deleteColumnPermissions(Document $collection, string $column): array; + abstract public function deleteColumnPermissions(Document $collection, string $column): int; /** * Are schema indexes supported? diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 426e91b6c8..072becfeff 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2099,14 +2099,14 @@ public function getSupportForColumnPermissions(): bool * @param Document $collection * @param string $old * @param string $new - * @return array + * @return int documents whose permissions changed */ - public function renameColumnPermissions(Document $collection, string $old, string $new): array + public function renameColumnPermissions(Document $collection, string $old, string $new): int { return $this->repointColumnPermissions($collection, $old, $new); } - public function deleteColumnPermissions(Document $collection, string $column): array + public function deleteColumnPermissions(Document $collection, string $column): int { return $this->repointColumnPermissions($collection, $column, null); } @@ -2124,13 +2124,13 @@ public function deleteColumnPermissions(Document $collection, string $column): a * @param Document $collection * @param string $old * @param string|null $new new column key, or null to drop the permissions - * @return array ids of documents whose permissions changed + * @return int documents whose permissions changed * @throws DatabaseException */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + private function repointColumnPermissions(Document $collection, string $old, ?string $new): int { $key = $this->key($collection->getId()); - $updated = []; + $updated = 0; foreach ($this->data[$key]['documents'] ?? [] as $documentKey => $row) { $permissions = $row['_permissions'] ?? []; @@ -2170,7 +2170,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st } $this->data[$key]['documents'][$documentKey]['_permissions'] = \array_values(\array_unique($rewritten)); - $updated[] = $row['_uid'] ?? $documentKey; + $updated++; } return $updated; diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index d20a2a25f6..0c1a17dcb2 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4294,12 +4294,12 @@ public function getSupportForColumnPermissions(): bool return true; } - public function renameColumnPermissions(Document $collection, string $old, string $new): array + public function renameColumnPermissions(Document $collection, string $old, string $new): int { return $this->repointColumnPermissions($collection, $old, $new); } - public function deleteColumnPermissions(Document $collection, string $column): array + public function deleteColumnPermissions(Document $collection, string $column): int { return $this->repointColumnPermissions($collection, $column, null); } @@ -4319,13 +4319,13 @@ public function deleteColumnPermissions(Document $collection, string $column): a * @param Document $collection * @param string $old * @param string|null $new new column key, or null to drop the permissions - * @return array ids of documents whose permissions changed + * @return int documents whose permissions changed * @throws Exception */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + private function repointColumnPermissions(Document $collection, string $old, ?string $new): int { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); - $updated = []; + $updated = 0; $cursor = null; // Paged by _uid rather than by matching the column, because the column lives @@ -4403,7 +4403,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st '$set' => ['_permissions' => \array_values(\array_unique($rewritten))], ]); - $updated[] = $row['_uid']; + $updated++; } } diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 59c98b1c77..d0b9e6a19d 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -527,12 +527,12 @@ public function getSupportForColumnPermissions(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - public function renameColumnPermissions(Document $collection, string $old, string $new): array + public function renameColumnPermissions(Document $collection, string $old, string $new): int { return $this->delegate(__FUNCTION__, \func_get_args()); } - public function deleteColumnPermissions(Document $collection, string $column): array + public function deleteColumnPermissions(Document $collection, string $column): int { return $this->delegate(__FUNCTION__, \func_get_args()); } diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 3b1f87b82c..0e595bec61 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -783,16 +783,16 @@ public function getSupportForColumnPermissions(): bool * @param Document $collection * @param string $old * @param string $new - * @return array + * @return int documents whose permissions changed */ - public function renameColumnPermissions(Document $collection, string $old, string $new): array + public function renameColumnPermissions(Document $collection, string $old, string $new): int { - return []; + return 0; } - public function deleteColumnPermissions(Document $collection, string $column): array + public function deleteColumnPermissions(Document $collection, string $column): int { - return []; + return 0; } public function getSupportForSchemaAttributes(): bool diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 901ec5c330..fca0707992 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -60,6 +60,26 @@ abstract class SQL extends Adapter */ protected const PERMISSIONS_INDEX_DOCUMENT = '_document_internal'; + /** + * 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; /** @@ -2072,10 +2092,10 @@ public function getSupportForColumnPermissions(): bool * @param Document $collection * @param string $old * @param string $new - * @return array ids of documents whose $permissions changed + * @return int documents whose permissions changed * @throws DatabaseException */ - public function renameColumnPermissions(Document $collection, string $old, string $new): array + public function renameColumnPermissions(Document $collection, string $old, string $new): int { return $this->repointColumnPermissions($collection, $old, $new); } @@ -2089,10 +2109,10 @@ public function renameColumnPermissions(Document $collection, string $old, strin * * @param Document $collection * @param string $column - * @return array ids of documents whose $permissions changed + * @return int documents whose permissions changed * @throws DatabaseException */ - public function deleteColumnPermissions(Document $collection, string $column): array + public function deleteColumnPermissions(Document $collection, string $column): int { return $this->repointColumnPermissions($collection, $column, null); } @@ -2112,15 +2132,15 @@ public function deleteColumnPermissions(Document $collection, string $column): a * @param Document $collection * @param string $old * @param string|null $new new column key, or null to drop the permissions - * @return array ids of documents whose $permissions changed + * @return int documents whose permissions changed * @throws DatabaseException */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): array + private function repointColumnPermissions(Document $collection, string $old, ?string $new): int { $name = $this->filter($collection->getId()); $tenantQuery = $this->getTenantQuery($collection->getId()); $table = $this->getSQLTable($name . '_perms'); - $updated = []; + $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 @@ -2128,6 +2148,11 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // 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 needs no offset because the work removes its own rows from the // predicate -- once a batch is repointed or deleted it no longer matches // _column = :_old, so the next pass returns the following batch. @@ -2140,7 +2165,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st FROM {$table} WHERE _column = :_column {$tenantQuery} - LIMIT " . Database::DELETE_BATCH_SIZE . " + LIMIT " . self::SELECT_BATCH_SIZE . " "); $stmt->bindValue(':_column', $old); if ($this->sharedTables) { @@ -2155,54 +2180,56 @@ private function repointColumnPermissions(Document $collection, string $old, ?st break; } - $sequences = \array_column($rows, '_id'); - $documents = \array_values(\array_unique(\array_column($rows, '_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 move together, scoped to this batch. - $updated = [...$updated, ...$this->repointPermissionsJson($name, $documents, $placeholders, $tenantQuery, $old, $new)]; - - if (!\is_null($new)) { - // A document can already hold the same role and action scoped to the - // destination column. Repointing the old scope onto it would then be a - // second identical row, which _index1 refuses -- and by this point the - // physical column has been renamed, so the failure would leave the - // schema renamed with permissions still describing the old state. - // The old scope is redundant once the destination exists, so drop it - // instead of repointing it. - $this->dropCollidingColumnPermissions($table, $documents, $placeholders, $tenantQuery, $old, $new); - } + 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 move together, scoped to this batch. + $updated += $this->repointPermissionsJson($name, $documents, $placeholders, $tenantQuery, $old, $new); + + if (!\is_null($new)) { + // A document can already hold the same role and action scoped to the + // destination column. Repointing the old scope onto it would then be a + // second identical row, which _index1 refuses -- and by this point the + // physical column has been renamed, so the failure would leave the + // schema renamed with permissions still describing the old state. + // The old scope is redundant once the destination exists, so drop it + // instead of repointing it. + $this->dropCollidingColumnPermissions($table, $documents, $placeholders, $tenantQuery, $old, $new); + } - // Addressed by primary key. Rows the collision pass above already removed - // simply match nothing. - $sequencePlaceholders = \implode(', ', \array_map( - fn ($index) => ":_id_{$index}", - \array_keys($sequences) - )); + // Addressed by primary key. Rows the collision pass above already removed + // simply match nothing. + $sequencePlaceholders = \implode(', ', \array_map( + fn ($index) => ":_id_{$index}", + \array_keys($sequences) + )); - if (\is_null($new)) { - $mutate = $this->getPDO()->prepare(" + if (\is_null($new)) { + $mutate = $this->getPDO()->prepare(" DELETE FROM {$table} WHERE _id IN ({$sequencePlaceholders}) "); - } else { - $mutate = $this->getPDO()->prepare(" + } else { + $mutate = $this->getPDO()->prepare(" UPDATE {$table} SET _column = :_new WHERE _id IN ({$sequencePlaceholders}) "); - $mutate->bindValue(':_new', $new); - } + $mutate->bindValue(':_new', $new); + } - foreach ($sequences as $index => $sequence) { - $mutate->bindValue(":_id_{$index}", $sequence); + foreach ($sequences as $index => $sequence) { + $mutate->bindValue(":_id_{$index}", $sequence); + } + $this->execute($mutate); } - $this->execute($mutate); } return $updated; @@ -2296,7 +2323,7 @@ private function dropCollidingColumnPermissions( * @param string $tenantQuery * @param string $old * @param string|null $new - * @return array ids whose $permissions changed + * @return int documents whose $permissions changed * @throws DatabaseException */ private function repointPermissionsJson( @@ -2306,7 +2333,7 @@ private function repointPermissionsJson( string $tenantQuery, string $old, ?string $new - ): array { + ): int { $select = $this->getPDO()->prepare(" SELECT _uid, _permissions FROM {$this->getSQLTable($name)} @@ -2331,7 +2358,7 @@ private function repointPermissionsJson( {$tenantQuery} "); - $updated = []; + $updated = 0; foreach ($rows as $row) { $permissions = \json_decode($row['_permissions'] ?? '[]', true); @@ -2377,7 +2404,7 @@ private function repointPermissionsJson( } $this->execute($update); - $updated[] = $row['_uid']; + $updated++; } return $updated; diff --git a/src/Database/Database.php b/src/Database/Database.php index ba843779c2..43099786a8 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3427,9 +3427,13 @@ public function updateAttribute(string $collection, string $id, ?string $type = if (!\is_null($newKey) && $newKey !== $id) { $this->repointCollectionColumnPermissions($collectionDoc, $id, $newKey); - foreach ($this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey) as $documentId) { - $this->purgeCachedDocument($collection, $documentId); - } + // No per-document purge: purgeCachedCollection() below lists and purges + // every document key under the collection, so purging the affected ones + // here is the same work done twice -- and it would fire an extra + // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact + // sequence of. The rollback path keeps its purge: the collection purge + // runs after updateMetadata(), so a failed write never reaches it. + $this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey); } } @@ -3462,9 +3466,13 @@ public function updateAttribute(string $collection, string $id, ?string $type = // Only when the rename half actually ran; see the repoint above. if (!\is_null($newKey) && $newKey !== $id) { - foreach ($this->adapter->renameColumnPermissions($collectionDoc, $newKey, $id) as $documentId) { - $this->purgeCachedDocument($collection, $documentId); - } + $this->adapter->renameColumnPermissions($collectionDoc, $newKey, $id); + + // The collection purge at the end of this method sits after + // updateMetadata(), so a failed write never reaches it. Purge here + // instead -- by collection, not per document, so nothing has to + // carry a list of ids back for the sake of invalidating them. + $this->purgeCachedCollection($collection); } }, shouldRollback: $updated, @@ -3605,9 +3613,13 @@ public function deleteAttribute(string $collection, string $id): bool // than deleting them, so they still have to be cleaned up here. $this->repointCollectionColumnPermissions($collection, $id, null); - foreach ($this->adapter->deleteColumnPermissions($collection, $id) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + // No per-document purge: purgeCachedCollection() below lists and purges + // every document key under the collection, so purging the affected ones + // here is the same work done twice -- and it would fire an extra + // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact + // sequence of. The rollback path keeps its purge: the collection purge + // runs after updateMetadata(), so a failed write never reaches it. + $this->adapter->deleteColumnPermissions($collection, $id); $this->updateMetadata( collection: $collection, @@ -3751,9 +3763,13 @@ public function renameAttribute(string $collection, string $old, string $new): b // enforcement, not storage, so grants outlive it and still need moving. $this->repointCollectionColumnPermissions($collection, $old, $new); - foreach ($this->adapter->renameColumnPermissions($collection, $old, $new) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + // No per-document purge: purgeCachedCollection() below lists and purges + // every document key under the collection, so purging the affected ones + // here is the same work done twice -- and it would fire an extra + // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact + // sequence of. The rollback path keeps its purge: the collection purge + // runs after updateMetadata(), so a failed write never reaches it. + $this->adapter->renameColumnPermissions($collection, $old, $new); $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); @@ -3769,9 +3785,11 @@ public function renameAttribute(string $collection, string $old, string $new): b rollbackOperation: function () use ($collection, $old, $new) { $this->adapter->renameAttribute($collection->getId(), $new, $old); - foreach ($this->adapter->renameColumnPermissions($collection, $new, $old) as $documentId) { - $this->purgeCachedDocument($collection->getId(), $documentId); - } + $this->adapter->renameColumnPermissions($collection, $new, $old); + + // See updateAttribute(): the collection purge below is unreachable + // once updateMetadata() has thrown, so the rollback does its own. + $this->purgeCachedCollection($collection->getId()); }, shouldRollback: $renamed, operationDescription: "attribute rename '{$old}' to '{$new}'" From 2e29e6ab191ef91bc117f4c74c0bb361268488a0 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 16:30:42 +0300 Subject: [PATCH 24/46] Immutable column --- src/Database/Adapter/Mongo.php | 9 +- src/Database/Database.php | 380 ++++++++++++------ src/Database/Validator/Permissions.php | 8 +- .../unit/ColumnPermissionEnforcementTest.php | 11 +- tests/unit/ColumnPermissionQueryTest.php | 15 +- tests/unit/ColumnPermissionSqlTest.php | 7 +- tests/unit/ColumnPermissionTest.php | 242 ++++++++++- 7 files changed, 538 insertions(+), 134 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 0c1a17dcb2..c802908ad6 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2477,13 +2477,16 @@ protected function getInternalKeyForAttribute(string $attribute): string */ 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) { - $key = $attribute['key'] ?? $attribute['$id'] ?? null; + $internalId = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? null; - if (\is_string($key) && $key !== '') { - $columns[$key] = true; + if (\is_string($internalId) && $internalId !== '') { + $columns[$internalId] = true; } } diff --git a/src/Database/Database.php b/src/Database/Database.php index 43099786a8..070a75afd1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -134,6 +134,27 @@ class Database */ 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; @@ -1864,6 +1885,10 @@ public function delete(?string $database = null): bool public function createCollection(string $id, array $attributes = [], array $indexes = [], ?array $permissions = null, bool $documentSecurity = true, bool $columnSecurity = false): Document { 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)) { @@ -1880,20 +1905,6 @@ public function createCollection(string $id, array $attributes = [], array $inde Permission::create(Role::any()), ]; - if ($this->validate) { - $columns = []; - foreach ($attributes as $attribute) { - $key = $attribute['key'] ?? $attribute['$id'] ?? null; - if (\is_string($key) && $key !== '') { - $columns[] = $key; - } - } - - $validator = new Permissions(columns: $columns); - if (!$validator->isValid($permissions)) { - throw new DatabaseException($validator->getDescription()); - } - } $collection = $this->silent(fn () => $this->getCollection($id)); @@ -1955,8 +1966,18 @@ public function createCollection(string $id, array $attributes = [], array $inde '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, @@ -2131,6 +2152,19 @@ public function updateCollection(string $id, array $permissions, bool $documentS 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) @@ -2475,7 +2509,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, @@ -2679,7 +2713,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( @@ -3412,28 +3446,12 @@ public function updateAttribute(string $collection, string $id, ?string $type = throw new DatabaseException('Failed to update attribute'); } - // A column-scoped permission names its column, in _perms._column and - // again inside the $permissions JSON, so a rename has to repoint both. - // There is no stable column id to hang permissions off: this method - // rewrites the attribute's '$id' and 'key' together, so the key is the - // only handle there is. - // - // Deliberately not gated on columnSecurity. Disabling the flag leaves - // scoped grants in storage, dormant, so gating here would let a rename - // slip past them -- and re-enabling would then point them at a key that - // no longer exists, or at whatever column later took that name. The - // adapter's first query finds nothing when no grant is scoped to this - // column, which is the common case and costs one lookup. + // 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); - - // No per-document purge: purgeCachedCollection() below lists and purges - // every document key under the collection, so purging the affected ones - // here is the same work done twice -- and it would fire an extra - // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact - // sequence of. The rollback path keeps its purge: the collection purge - // runs after updateMetadata(), so a failed write never reaches it. - $this->adapter->renameColumnPermissions($collectionDoc, $id, $newKey); } } @@ -3441,40 +3459,16 @@ public function updateAttribute(string $collection, string $id, ?string $type = $this->updateMetadata( collection: $collectionDoc, - rollbackOperation: function () use ( + rollbackOperation: fn () => $this->adapter->updateAttribute( $collection, - $collectionDoc, - $id, - $newKey, + $newKey ?? $id, $originalType, - $originalSize, + (int)$originalSize, $originalSigned, $originalArray, $originalKey, $originalRequired - ) { - $this->adapter->updateAttribute( - $collection, - $newKey ?? $id, - $originalType, - (int)$originalSize, - $originalSigned, - $originalArray, - $originalKey, - $originalRequired - ); - - // Only when the rename half actually ran; see the repoint above. - if (!\is_null($newKey) && $newKey !== $id) { - $this->adapter->renameColumnPermissions($collectionDoc, $newKey, $id); - - // The collection purge at the end of this method sits after - // updateMetadata(), so a failed write never reaches it. Purge here - // instead -- by collection, not per document, so nothing has to - // carry a list of ids back for the sake of invalidating them. - $this->purgeCachedCollection($collection); - } - }, + ), shouldRollback: $updated, operationDescription: "attribute update '{$id}'", silentRollback: true @@ -3607,19 +3601,26 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } - // Permissions name their column by key, so grants left behind would be - // inherited by any column later created under the same name. Runs whatever - // columnSecurity says: disabling it keeps scoped grants in storage rather - // than deleting them, so they still have to be cleaned up here. + // 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); + $internalId = $attribute instanceof Document + ? $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID) + : null; + // No per-document purge: purgeCachedCollection() below lists and purges // every document key under the collection, so purging the affected ones - // here is the same work done twice -- and it would fire an extra - // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact - // sequence of. The rollback path keeps its purge: the collection purge - // runs after updateMetadata(), so a failed write never reaches it. - $this->adapter->deleteColumnPermissions($collection, $id); + // here is the same work done twice. + if (\is_string($internalId) && $internalId !== '') { + $this->adapter->deleteColumnPermissions($collection, $internalId); + } $this->updateMetadata( collection: $collection, @@ -3754,43 +3755,17 @@ public function renameAttribute(string $collection, string $old, string $new): b } } - // A column-scoped permission names its column, in _perms._column and again - // inside the $permissions JSON, so a rename has to repoint both -- the same - // migration updateAttribute() runs for its own rename. Left alone, the grants - // stay attached to the old key: the caller loses the renamed column, and a - // column later created under the old name inherits authority it never earned. - // Not gated on columnSecurity, for the same reason: the flag controls - // enforcement, not storage, so grants outlive it and still need moving. + // 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); - // No per-document purge: purgeCachedCollection() below lists and purges - // every document key under the collection, so purging the affected ones - // here is the same work done twice -- and it would fire an extra - // EVENT_DOCUMENT_PURGE per document, which testEvents() asserts the exact - // sequence of. The rollback path keeps its purge: the collection purge - // runs after updateMetadata(), so a failed write never reaches it. - $this->adapter->renameColumnPermissions($collection, $old, $new); - $collection->setAttribute('attributes', $attributes); $collection->setAttribute('indexes', $indexes); - // The grants above are already committed, so the rollback has to walk them - // back alongside the column. Reversing only the schema would leave the schema - // on the old key and the grants on the new one -- access lost now, and - // inherited later by whatever is created under the new name. The collection's - // own grants need no undoing: they live on $collection, which this call is what - // persists, so a failure here means they were never written. $this->updateMetadata( collection: $collection, - rollbackOperation: function () use ($collection, $old, $new) { - $this->adapter->renameAttribute($collection->getId(), $new, $old); - - $this->adapter->renameColumnPermissions($collection, $new, $old); - - // See updateAttribute(): the collection purge below is unreachable - // once updateMetadata() has thrown, so the rollback does its own. - $this->purgeCachedCollection($collection->getId()); - }, + rollbackOperation: fn () => $this->adapter->renameAttribute($collection->getId(), $new, $old), shouldRollback: $renamed, operationDescription: "attribute rename '{$old}' to '{$new}'" ); @@ -4090,8 +4065,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 = []; @@ -5365,6 +5340,156 @@ private function getPermittedColumns( * @param Document $collection * @return array */ + /** + * 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 + */ + /** + * 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 Document $collection + * @param Document $document + * @param bool $toIdentity true going to storage, false coming back + * @return void + */ + private function translatePermissionColumns(Document $collection, Document $document, bool $toIdentity): void + { + if (!$document->offsetExists('$permissions')) { + return; + } + + // 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; + } + + $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 === '') { + continue; + } + + $map[$toIdentity ? $key : $internalId] = $toIdentity ? $internalId : $key; + } + + $translated = []; + + foreach ($document->getPermissions() as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->isForAllColumns()) { + $translated[] = $permission; + continue; + } + + $column = $map[$parsed->getColumn()] ?? null; + + 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; + } + + // 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.' + ); + } + + $translated[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $column + ))->toString(); + } + + $document->setAttribute('$permissions', \array_values(\array_unique($translated))); + } + + /** + * 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 []; + } + + $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)); + } + + 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; + } + private function getColumnKeys(Document $collection): array { $keys = []; @@ -6683,15 +6808,24 @@ public function createDocument(string $collection, Document $document): Document } } - $document = $this->encode($collection, $document); - - if ($this->validate) { + // 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, @@ -8586,8 +8720,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 ); } @@ -9798,7 +9941,7 @@ public function find(string $collection, array $queries = [], string $forPermiss $cursor, $cursorDirection, $forPermission, - $columnPermissions + $this->columnIdentities($collection, $columnPermissions) ); $results = $skipAuth ? $this->authorization->skip($getResults) : $getResults(); @@ -10269,7 +10412,7 @@ public function count(string $collection, array $queries = [], ?int $max = null) $queries = $queriesOrNull; - $getCount = fn () => $this->adapter->count($collection, $queries, $max, $columnPermissions); + $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); @@ -10383,7 +10526,7 @@ public function sum(string $collection, string $attribute, array $queries = [], $queries = $queriesOrNull; - $getSum = fn () => $this->adapter->sum($collection, $attribute, $queries, $max, $columnPermissions); + $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); @@ -10477,6 +10620,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) { @@ -10666,6 +10816,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/Validator/Permissions.php b/src/Database/Validator/Permissions.php index 6292754569..58f8d0b3cc 100644 --- a/src/Database/Validator/Permissions.php +++ b/src/Database/Validator/Permissions.php @@ -28,7 +28,11 @@ class Permissions extends Roles * * @param int $length maximum amount of permissions. 0 means unlimited. * @param array $allowed allowed permissions. Defaults to all available. - * @param array $columns known column keys a permission may be scoped to. Empty means any valid key. + * @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], array $columns = []) { @@ -123,7 +127,7 @@ public function isValid($permissions): bool return false; } - if (!empty($this->columns) && !\in_array($column, $this->columns, true)) { + if (!\in_array($column, $this->columns, true)) { $this->message = 'Column "' . $column . '" does not exist.'; return false; } diff --git a/tests/unit/ColumnPermissionEnforcementTest.php b/tests/unit/ColumnPermissionEnforcementTest.php index 5c9cf48be4..e3b98157a1 100644 --- a/tests/unit/ColumnPermissionEnforcementTest.php +++ b/tests/unit/ColumnPermissionEnforcementTest.php @@ -138,14 +138,14 @@ public function testSkippedAuthorizationIsNotMasked(): void public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void { $this->authorization->skip(function () { - $this->database->createCollection('public_employees', documentSecurity: true, columnSecurity: true, permissions: [ - Permission::read(Role::any(), 'name'), - ]); + $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' => [], @@ -177,11 +177,10 @@ public function testCollectionLevelColumnGrantIsStillMaskedInFind(): void public function testCollectionLevelColumnGrantFollowsARename(): void { $this->authorization->skip(function () { - $this->database->createCollection('scoped', documentSecurity: true, columnSecurity: true, permissions: [ - Permission::read(Role::any(), 'name'), - ]); + $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")'], diff --git a/tests/unit/ColumnPermissionQueryTest.php b/tests/unit/ColumnPermissionQueryTest.php index 0e1e9c812c..d3c036a5e0 100644 --- a/tests/unit/ColumnPermissionQueryTest.php +++ b/tests/unit/ColumnPermissionQueryTest.php @@ -43,14 +43,16 @@ protected function setUp(): void } $this->authorization->skip(function () { - $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: [ - Permission::read(Role::any(), 'name'), - Permission::create(Role::any(), 'name'), - ]); + $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' => [ @@ -226,11 +228,10 @@ public function testSelectOfAnUnreadableColumnIsMaskedNotRejected(): void public function testWithoutDocumentSecurityAnUnreadableColumnThrows(): void { $this->authorization->skip(function () { - $this->database->createCollection('strict', documentSecurity: false, columnSecurity: true, permissions: [ - Permission::read(Role::any(), 'name'), - ]); + $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(); diff --git a/tests/unit/ColumnPermissionSqlTest.php b/tests/unit/ColumnPermissionSqlTest.php index 86194a8fa9..4c236602c0 100644 --- a/tests/unit/ColumnPermissionSqlTest.php +++ b/tests/unit/ColumnPermissionSqlTest.php @@ -50,11 +50,12 @@ protected function setUp(): void $this->database->create(); $this->authorization->skip(function () { - $this->database->createCollection('employees', documentSecurity: true, columnSecurity: true, permissions: [ - Permission::read(Role::any(), 'name'), - ]); + // 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([ diff --git a/tests/unit/ColumnPermissionTest.php b/tests/unit/ColumnPermissionTest.php index fd6954388a..75d560bebc 100644 --- a/tests/unit/ColumnPermissionTest.php +++ b/tests/unit/ColumnPermissionTest.php @@ -146,7 +146,7 @@ public function testColumnScopedGrantStillGrantsTheRowToThatRole(): void public function testValidatorAcceptsColumnScopedReadCreateUpdate(): void { - $validator = new Permissions(); + $validator = new Permissions(columns: ['salary', 'name']); $this->assertTrue($validator->isValid([ 'read("user:1", "salary")', @@ -155,6 +155,36 @@ public function testValidatorAcceptsColumnScopedReadCreateUpdate(): void ]), $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(); @@ -187,4 +217,214 @@ public function testValidatorRejectsInvalidColumnKey(): void $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', + ])) + )); + } } From 09f2305f8e38a8cf4f6d2b59b9c06ed70243cc90 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 16:57:06 +0300 Subject: [PATCH 25/46] Address commesnt --- src/Database/Adapter/Mongo.php | 9 +++- tests/e2e/Adapter/Scopes/PermissionTests.php | 51 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c802908ad6..83d8309b9b 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -2538,7 +2538,14 @@ private function columnPermissionStrings(string $type, string $column): array */ private function applyColumnPermissions(array $filters, array $columnPermissions, string $type): array { - if (empty($columnPermissions) || !$this->authorization->getStatus()) { + // 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; } diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 9bb33af680..52320c1b5e 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1573,4 +1573,55 @@ 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(); + } } From 95f63cf464a6005f4e632182f01de7e11297a631 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:01:48 +0300 Subject: [PATCH 26/46] MongoPermissionStringsTest --- tests/unit/MongoPermissionStringsTest.php | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tests/unit/MongoPermissionStringsTest.php b/tests/unit/MongoPermissionStringsTest.php index fae2419bd5..2dabb73631 100644 --- a/tests/unit/MongoPermissionStringsTest.php +++ b/tests/unit/MongoPermissionStringsTest.php @@ -62,10 +62,9 @@ public function testValuesAreStringsNotRegex(): void /** * @param list $roles - * @param list $columns * @return list */ - private function permissionStrings(array $roles, string $type, array $columns = []): array + private function permissionStrings(array $roles, string $type): array { $authorization = new Authorization(); $authorization->enable(); @@ -79,10 +78,9 @@ private function permissionStrings(array $roles, string $type, array $columns = $method = new ReflectionMethod(Mongo::class, 'permissionStrings'); - $collection = new Document([ - '$id' => 'test', - 'attributes' => \array_map(fn (string $column) => ['key' => $column], $columns), - ]); + // 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, $collection); From fa1ce447028fa1680e2cefdc8e57bc76568f84ba Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:09:20 +0300 Subject: [PATCH 27/46] fix --- src/Database/Adapter/Memory.php | 20 +++++- src/Database/Adapter/Mongo.php | 9 ++- tests/e2e/Adapter/Scopes/PermissionTests.php | 64 ++++++++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 072becfeff..797acb4a77 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2093,8 +2093,7 @@ public function getSupportForColumnPermissions(): bool } /** - * Column-level permissions are not supported by this adapter, so a rename - * can never have column-scoped permissions to repoint. + * Repoint every grant scoped to $old at $new. * * @param Document $collection * @param string $old @@ -2132,6 +2131,9 @@ private function repointColumnPermissions(Document $collection, string $old, ?st $key = $this->key($collection->getId()); $updated = 0; + /** @var array> $touched */ + $touched = []; + foreach ($this->data[$key]['documents'] ?? [] as $documentKey => $row) { $permissions = $row['_permissions'] ?? []; @@ -2169,10 +2171,24 @@ private function repointColumnPermissions(Document $collection, string $old, ?st continue; } + $touched[$documentKey] = $permissions; $this->data[$key]['documents'][$documentKey]['_permissions'] = \array_values(\array_unique($rewritten)); $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; } diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index 83d8309b9b..c8ecdbcf96 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4354,11 +4354,14 @@ private function repointColumnPermissions(Document $collection, string $old, ?st $filters['_tenant'] = $this->getTenantFilters($collection->getId()); } - $found = $this->client->find($name, $filters, [ + // 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. + $found = $this->client->find($name, $filters, $this->getTransactionOptions([ 'limit' => Database::DELETE_BATCH_SIZE, 'sort' => ['_uid' => 1], 'projection' => ['_uid' => 1, '_permissions' => 1], - ])->cursor->firstBatch ?? []; + ]))->cursor->firstBatch ?? []; if (empty($found)) { break; @@ -4411,7 +4414,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st $this->client->update($name, $where, [ '$set' => ['_permissions' => \array_values(\array_unique($rewritten))], - ]); + ], $this->getTransactionOptions()); $updated++; } diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 52320c1b5e..ef1a247da4 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1624,4 +1624,68 @@ public function testCollectionGrantDoesNotExposeOtherColumnsToQueries(): void $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() + ); + + try { + $authorization->skip(fn () => $database->withTransaction(function () use ($database) { + $database->deleteAttribute('rollbackGrants', 'salary'); + + throw new DatabaseException('rollback'); + })); + $this->fail('the transaction should have propagated the failure'); + } catch (DatabaseException) { + // expected -- the rollback is what is under test + } + + $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' + ); + } } From 218a2d2bf683dcd5721251b80324096c7200879d Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:26:14 +0300 Subject: [PATCH 28/46] fix --- src/Database/Adapter/Memory.php | 6 ++++-- src/Database/Adapter/Mongo.php | 12 +++++++++++- src/Database/Adapter/Pool.php | 12 +++++++++++- src/Database/Adapter/SQL.php | 28 ++++++++++++++++++++++++---- 4 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 797acb4a77..55cd911bbf 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2093,7 +2093,9 @@ public function getSupportForColumnPermissions(): bool } /** - * Repoint every grant scoped to $old at $new. + * Unreachable: _perms._column holds the attribute's immutable identity, which a + * rename does not change, so Database::renameAttribute() moves no permission rows. + * Kept only to satisfy the Adapter contract until that method is removed from it. * * @param Document $collection * @param string $old @@ -2102,7 +2104,7 @@ public function getSupportForColumnPermissions(): bool */ public function renameColumnPermissions(Document $collection, string $old, string $new): int { - return $this->repointColumnPermissions($collection, $old, $new); + return 0; } public function deleteColumnPermissions(Document $collection, string $column): int diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index c8ecdbcf96..e1e3f7e0ce 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4304,9 +4304,19 @@ public function getSupportForColumnPermissions(): bool return true; } + /** + * Unreachable: _perms._column holds the attribute's immutable identity, which a + * rename does not change, so Database::renameAttribute() moves no permission rows. + * Kept only to satisfy the Adapter contract until that method is removed from it. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return int documents whose permissions changed + */ public function renameColumnPermissions(Document $collection, string $old, string $new): int { - return $this->repointColumnPermissions($collection, $old, $new); + return 0; } public function deleteColumnPermissions(Document $collection, string $column): int diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index d0b9e6a19d..7d6371a305 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -527,9 +527,19 @@ public function getSupportForColumnPermissions(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } + /** + * Unreachable: _perms._column holds the attribute's immutable identity, which a + * rename does not change, so Database::renameAttribute() moves no permission rows. + * Kept only to satisfy the Adapter contract until that method is removed from it. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return int documents whose permissions changed + */ public function renameColumnPermissions(Document $collection, string $old, string $new): int { - return $this->delegate(__FUNCTION__, \func_get_args()); + return 0; } public function deleteColumnPermissions(Document $collection, string $column): int diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index fca0707992..a0d22c85b1 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2095,9 +2095,19 @@ public function getSupportForColumnPermissions(): bool * @return int documents whose permissions changed * @throws DatabaseException */ + /** + * Unreachable: _perms._column holds the attribute's immutable identity, which a + * rename does not change, so Database::renameAttribute() moves no permission rows. + * Kept only to satisfy the Adapter contract until that method is removed from it. + * + * @param Document $collection + * @param string $old + * @param string $new + * @return int documents whose permissions changed + */ public function renameColumnPermissions(Document $collection, string $old, string $new): int { - return $this->repointColumnPermissions($collection, $old, $new); + return 0; } /** @@ -2153,9 +2163,13 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // read cannot seek and scans whatever it returns; taking more per pass is the // only way to scan fewer times. // - // The loop needs no offset because the work removes its own rows from the - // predicate -- once a batch is repointed or deleted it no longer matches - // _column = :_old, so the next pass returns the following batch. + // The loop advances on _id rather than trusting the work to remove its own rows + // from the predicate. A delete does remove them, but a repoint only rewrites + // _column -- and under a case-insensitive collation, which is the default for + // utf8mb4, a rename that changes only case leaves every rewritten row still + // matching _column = :_old. The predicate would return the same batch forever. + $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 @@ -2164,10 +2178,13 @@ private function repointColumnPermissions(Document $collection, string $old, ?st SELECT _id, _document FROM {$table} WHERE _column = :_column + AND _id > :_cursor {$tenantQuery} + ORDER BY _id LIMIT " . self::SELECT_BATCH_SIZE . " "); $stmt->bindValue(':_column', $old); + $stmt->bindValue(':_cursor', $cursor); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); } @@ -2230,6 +2247,9 @@ private function repointColumnPermissions(Document $collection, string $old, ?st } $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; From e6cb5ddc63281405d80d6b94e11fdbb9c1f10c08 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:33:34 +0300 Subject: [PATCH 29/46] fix --- src/Database/Database.php | 11 +++++ tests/e2e/Adapter/Scopes/PermissionTests.php | 43 ++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Database/Database.php b/src/Database/Database.php index 070a75afd1..09bc69fd1e 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -1884,6 +1884,17 @@ public function delete(?string $database = null): bool */ 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. diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index ef1a247da4..b790bca2d6 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1688,4 +1688,47 @@ public function testRollbackRestoresGrantsPurgedByAColumnDelete(): void '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(); + + $collection = $authorization->skip( + fn () => $database->getCollection('columnSupportYes') + ); + + $this->assertTrue($collection->getAttribute('columnSecurity')); + } } From 28ba654223da177e56a696640bb817f70bc13a3e Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:43:55 +0300 Subject: [PATCH 30/46] fix --- src/Database/Database.php | 38 ++++++++++++-- tests/e2e/Adapter/Scopes/PermissionTests.php | 53 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 09bc69fd1e..9eb80b928d 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5396,6 +5396,14 @@ private function translatePermissionColumns(Document $collection, Document $docu $map = []; + // 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 ($collection->getAttribute('attributes', []) as $attribute) { $key = $attribute['key'] ?? $attribute['$id'] ?? null; $internalId = $attribute[self::ATTRIBUTE_INTERNAL_ID] ?? null; @@ -5405,6 +5413,10 @@ private function translatePermissionColumns(Document $collection, Document $docu } $map[$toIdentity ? $key : $internalId] = $toIdentity ? $internalId : $key; + + if ($toIdentity) { + $identities[$internalId] = true; + } } $translated = []; @@ -5419,6 +5431,10 @@ private function translatePermissionColumns(Document $collection, Document $docu $column = $map[$parsed->getColumn()] ?? null; + if ($column === null && isset($identities[$parsed->getColumn()])) { + $column = $parsed->getColumn(); + } + 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 @@ -5895,8 +5911,17 @@ private function assertColumnsWritable( // otherwise "may update name" is enough to add read+update on every other // column, which makes column-level permissions unenforceable. if ($document->offsetExists('$permissions')) { - $before = $old->getPermissions(); - $after = $document->getPermissions(); + // 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); @@ -7821,7 +7846,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); diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index b790bca2d6..3c534338af 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1731,4 +1731,57 @@ public function testColumnSecurityIsRefusedWhenTheAdapterCannotEnforceIt(): void $this->assertTrue($collection->getAttribute('columnSecurity')); } + + /** + * 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); + } + } } From ee2f2e2314e1c6e78dc1653a15bee2514ddeab66 Mon Sep 17 00:00:00 2001 From: fogelito Date: Sun, 27 Sep 2026 17:49:06 +0300 Subject: [PATCH 31/46] fix --- src/Database/Database.php | 30 ++++++++-- tests/e2e/Adapter/Scopes/PermissionTests.php | 63 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 9eb80b928d..4f306cc042 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -5465,6 +5465,26 @@ private function translatePermissionColumns(Document $collection, Document $docu $document->setAttribute('$permissions', \array_values(\array_unique($translated))); } + /** + * 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() + )); + } + /** * Resolve column keys to the identities storage holds them under. * @@ -7275,7 +7295,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); @@ -8100,7 +8120,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(), @@ -8189,7 +8209,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(), @@ -8229,7 +8249,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(), @@ -8302,7 +8322,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(), diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 3c534338af..4c52847602 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1784,4 +1784,67 @@ public function testBulkUpdateAcceptsColumnScopedPermissions(): void $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' + ); + } } From c4e13af520c3f764ea04eff8b5c3e700e3797152 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 08:27:56 +0300 Subject: [PATCH 32/46] fix --- src/Database/Database.php | 64 +++++++++++--------- tests/e2e/Adapter/Scopes/PermissionTests.php | 7 ++- tests/unit/HashAwareMemoryCache.php | 5 +- 3 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index 4f306cc042..e8cb594f64 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3622,17 +3622,6 @@ public function deleteAttribute(string $collection, string $id): bool // holds identities. $this->repointCollectionColumnPermissions($collection, $id, null); - $internalId = $attribute instanceof Document - ? $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID) - : null; - - // No per-document purge: purgeCachedCollection() below lists and purges - // every document key under the collection, so purging the affected ones - // here is the same work done twice. - if (\is_string($internalId) && $internalId !== '') { - $this->adapter->deleteColumnPermissions($collection, $internalId); - } - $this->updateMetadata( collection: $collection, rollbackOperation: fn () => $this->adapter->createAttribute( @@ -3649,6 +3638,23 @@ public function deleteAttribute(string $collection, string $id): bool silentRollback: true ); + // After the metadata write, not before. updateMetadata() rethrows on failure and + // its rollback puts the physical column back -- but nothing puts these rows back, + // so purging first would leave the column restored and every grant on it gone. + // Afterwards, the worst case is dead rows, which is what this purge exists to + // tidy and which confer nothing in the meantime. + // + // No per-document purge either: purgeCachedCollection() below lists and purges + // every document key under the collection, so purging the affected ones here is + // the same work done twice. + $internalId = $attribute instanceof Document + ? $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID) + : null; + + if (\is_string($internalId) && $internalId !== '') { + $this->adapter->deleteColumnPermissions($collection, $internalId); + } + $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); @@ -5345,24 +5351,6 @@ private function getPermittedColumns( return \array_keys($columns); } - /** - * Column keys defined on a collection. - * - * @param Document $collection - * @return array - */ - /** - * 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 - */ /** * Rewrite a document's column-scoped permissions between key and identity. * @@ -5526,6 +5514,18 @@ private function columnIdentities(Document $collection, array $keys): array 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); @@ -5537,6 +5537,12 @@ private function stampAttributeIdentity(Document $attribute): Document return $attribute; } + /** + * Column keys defined on a collection. + * + * @param Document $collection + * @return array + */ private function getColumnKeys(Document $collection): array { $keys = []; diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 4c52847602..b18f91a5fb 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1664,17 +1664,20 @@ public function testRollbackRestoresGrantsPurgedByAColumnDelete(): void 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'); })); - $this->fail('the transaction should have propagated the failure'); } catch (DatabaseException) { - // expected -- the rollback is what is under test + $rolledBack = true; } + $this->assertTrue($rolledBack, 'the transaction should have propagated the failure'); + $attributes = $authorization->skip( fn () => $database->getCollection('rollbackGrants')->getAttribute('attributes', []) ); 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); } From 422886e3d906e9ec5e2bc3a976e583e6b48c2a3e Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 08:42:14 +0300 Subject: [PATCH 33/46] fix --- src/Database/Adapter/Mongo.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index e1e3f7e0ce..facfacf71e 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4367,8 +4367,20 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // 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 ?? []; From 37e9caebb5db4ccb745a47b2a4a4c93d6fef7b6e Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:00:42 +0300 Subject: [PATCH 34/46] fix --- src/Database/Adapter/Mongo.php | 54 ++++------- src/Database/Adapter/Postgres.php | 9 ++ src/Database/Adapter/SQL.php | 155 ++++++++++++++++++++++++------ 3 files changed, 155 insertions(+), 63 deletions(-) diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index facfacf71e..ee493b347d 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4321,28 +4321,26 @@ public function renameColumnPermissions(Document $collection, string $old, strin public function deleteColumnPermissions(Document $collection, string $column): int { - return $this->repointColumnPermissions($collection, $column, null); + return $this->repointColumnPermissions($collection, $column); } /** - * Move or drop the permissions scoped to one 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 rename that left them alone would quietly - * revoke access under the old name, and a delete would leave grants for a column - * key to inherit if it were recreated. + * access from those same strings, so a dropped column leaves grants a recreated + * one could inherit if they were not cleared. * - * Documents are handled in batches. Only those still naming the old column are - * fetched, and rewriting them takes them out of that set, so the next pass - * returns the following batch without needing an offset. + * 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 $old - * @param string|null $new new column key, or null to drop the permissions + * @param string $column * @return int documents whose permissions changed * @throws Exception */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): int + private function repointColumnPermissions(Document $collection, string $column): int { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $updated = 0; @@ -4399,33 +4397,23 @@ private function repointColumnPermissions(Document $collection, string $old, ?st continue; } - $rewritten = []; - $changed = false; + // 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) { - $parsed = Permission::parse((string)$permission); - - if ($parsed->getColumn() !== $old) { - $rewritten[] = (string)$permission; - continue; - } - - $changed = true; + $permission = (string)$permission; - if (\is_null($new)) { - continue; + if (Permission::parse($permission)->getColumn() === $column) { + $remove[] = $permission; } - - $rewritten[] = (new Permission( - $parsed->getPermission(), - $parsed->getRole(), - $parsed->getIdentifier(), - $parsed->getDimension(), - $new - ))->toString(); } - if (!$changed) { + if (empty($remove)) { continue; } @@ -4435,7 +4423,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st } $this->client->update($name, $where, [ - '$set' => ['_permissions' => \array_values(\array_unique($rewritten))], + '$pull' => ['_permissions' => ['$in' => \array_values(\array_unique($remove))]], ], $this->getTransactionOptions()); $updated++; diff --git a/src/Database/Adapter/Postgres.php b/src/Database/Adapter/Postgres.php index b572264135..b97feac958 100644 --- a/src/Database/Adapter/Postgres.php +++ b/src/Database/Adapter/Postgres.php @@ -2140,6 +2140,15 @@ 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; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index a0d22c85b1..c823936d86 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -60,6 +60,13 @@ abstract class SQL extends Adapter */ 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. * @@ -2371,63 +2378,151 @@ private function repointPermissionsJson( $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) { - $permissions = \json_decode($row['_permissions'] ?? '[]', true); + $current = $row['_permissions'] ?? '[]'; - if (!\is_array($permissions)) { - continue; - } + for ($attempt = 0; $attempt < self::REWRITE_MAX_ATTEMPTS; $attempt++) { + $rewritten = $this->withoutColumnPermissions($current, $old, $new); - $rewritten = []; - $changed = false; + if ($rewritten === null) { + // Nothing on this row names the column any more. + break; + } - foreach ($permissions as $permission) { - $parsed = Permission::parse($permission); + $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 ($parsed->getColumn() !== $old) { - $rewritten[] = $permission; - continue; + if ($update->rowCount() > 0) { + $updated++; + break; } - $changed = true; + // 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 (\is_null($new)) { - continue; + if ($current === null) { + // Row is gone; nothing left to rewrite. + break; } - - $rewritten[] = (new Permission( - $parsed->getPermission(), - $parsed->getRole(), - $parsed->getIdentifier(), - $parsed->getDimension(), - $new - ))->toString(); } + } + + return $updated; + } - if (!$changed) { + /** + * 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 $old the column whose grants are going + * @param string|null $new new column key, or null to drop the grants + * @return string|null the new JSON, or null when nothing names $old + */ + private function withoutColumnPermissions(string $stored, string $old, ?string $new): ?string + { + $permissions = \json_decode($stored, true); + + if (!\is_array($permissions)) { + return null; + } + + $rewritten = []; + $changed = false; + + foreach ($permissions as $permission) { + $parsed = Permission::parse($permission); + + if ($parsed->getColumn() !== $old) { + $rewritten[] = $permission; continue; } - $update->bindValue(':_permissions', \json_encode(\array_values(\array_unique($rewritten)))); - $update->bindValue(':_uid', $row['_uid']); - if ($this->sharedTables) { - $update->bindValue(':_tenant', $this->tenant); + $changed = true; + + if (\is_null($new)) { + continue; } - $this->execute($update); - $updated++; + $rewritten[] = (new Permission( + $parsed->getPermission(), + $parsed->getRole(), + $parsed->getIdentifier(), + $parsed->getDimension(), + $new + ))->toString(); } - return $updated; + 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. + * + * Most adapters store it as text and compare it as given. Postgres stores JSONB, + * which has no equality against text, so it overrides this to cast. + */ + protected function getJsonBind(string $placeholder): string + { + return $placeholder; } /** From 390aee1591cce6834d859d519bc3cb007ae58b93 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:10:56 +0300 Subject: [PATCH 35/46] fix --- src/Database/Database.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index e8cb594f64..efdfebf9c1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3652,7 +3652,16 @@ public function deleteAttribute(string $collection, string $id): bool : null; if (\is_string($internalId) && $internalId !== '') { - $this->adapter->deleteColumnPermissions($collection, $internalId); + try { + $this->adapter->deleteColumnPermissions($collection, $internalId); + } catch (\Throwable $e) { + // Swallowed on purpose. By this point the deletion is durable -- the + // metadata no longer lists the attribute -- so failing here would report + // an error for work that succeeded and, worse, skip the cache purge + // below, leaving readers a cached collection that still has the column. + // What is left behind is dead rows, which confer nothing and shed on the + // next read-modify-write. + } } $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); From ee1e068efab8febe5dda2c232259fa8ef9e3f927 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:21:40 +0300 Subject: [PATCH 36/46] fix --- src/Database/Adapter/MariaDB.php | 11 +++++++++++ src/Database/Adapter/SQL.php | 9 +++++++-- src/Database/Adapter/SQLite.php | 11 +++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/Database/Adapter/MariaDB.php b/src/Database/Adapter/MariaDB.php index 52d3eb349f..df7c5f3c8b 100644 --- a/src/Database/Adapter/MariaDB.php +++ b/src/Database/Adapter/MariaDB.php @@ -1807,6 +1807,17 @@ 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; diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index c823936d86..343c0b430a 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2517,8 +2517,13 @@ private function currentPermissions(string $name, string $uid, string $tenantQue /** * Bind a JSON value for comparison against the _permissions column. * - * Most adapters store it as text and compare it as given. Postgres stores JSONB, - * which has no equality against text, so it overrides this to cast. + * 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 { diff --git a/src/Database/Adapter/SQLite.php b/src/Database/Adapter/SQLite.php index 16f5085916..0b6c1c8d57 100644 --- a/src/Database/Adapter/SQLite.php +++ b/src/Database/Adapter/SQLite.php @@ -1531,6 +1531,17 @@ 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; From c91ea95d9b4aaded661dda9888b9bb90b0e8ced8 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:25:20 +0300 Subject: [PATCH 37/46] fixed --- src/Database/Database.php | 52 +++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index efdfebf9c1..f5bfc45fa1 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3638,32 +3638,6 @@ public function deleteAttribute(string $collection, string $id): bool silentRollback: true ); - // After the metadata write, not before. updateMetadata() rethrows on failure and - // its rollback puts the physical column back -- but nothing puts these rows back, - // so purging first would leave the column restored and every grant on it gone. - // Afterwards, the worst case is dead rows, which is what this purge exists to - // tidy and which confer nothing in the meantime. - // - // No per-document purge either: purgeCachedCollection() below lists and purges - // every document key under the collection, so purging the affected ones here is - // the same work done twice. - $internalId = $attribute instanceof Document - ? $attribute->getAttribute(self::ATTRIBUTE_INTERNAL_ID) - : null; - - if (\is_string($internalId) && $internalId !== '') { - try { - $this->adapter->deleteColumnPermissions($collection, $internalId); - } catch (\Throwable $e) { - // Swallowed on purpose. By this point the deletion is durable -- the - // metadata no longer lists the attribute -- so failing here would report - // an error for work that succeeded and, worse, skip the cache purge - // below, leaving readers a cached collection that still has the column. - // What is left behind is dead rows, which confer nothing and shed on the - // next read-modify-write. - } - } - $this->withRetries(fn () => $this->purgeCachedCollection($collection->getId())); $this->withRetries(fn () => $this->purgeCachedDocumentInternal(self::METADATA, $collection->getId())); @@ -3682,6 +3656,32 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } + // Last, and allowed to fail loudly. + // + // Not before updateMetadata(): its rollback puts the physical column back, but + // nothing puts these rows back, so purging first would leave the column restored + // and every grant on it gone. + // + // Not before the cache purge and the events either: those describe a deletion + // that has already happened, and throwing ahead of them would leave readers a + // cached collection still listing the column and listeners never told it went. + // + // But not swallowed. A grant scoped only to this column keeps matching the row + // gate, which authorizes by role and does not look at _column -- so while these + // rows survive, count() still reports a document whose every column the caller + // has lost access to. That is an existence disclosure, not untidiness, so the + // caller is told rather than left to assume the cleanup ran. + // + // 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; + + if (\is_string($internalId) && $internalId !== '') { + $this->withRetries(fn () => $this->adapter->deleteColumnPermissions($collection, $internalId)); + } + return true; } From a14adf0583af90ab5005abbc274cd4c24c001967 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:35:21 +0300 Subject: [PATCH 38/46] fixed --- tests/unit/ColumnSecurityFlagTest.php | 62 +++++++++++++++++++-------- 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/tests/unit/ColumnSecurityFlagTest.php b/tests/unit/ColumnSecurityFlagTest.php index 8aa9a6ee51..3c41965aa6 100644 --- a/tests/unit/ColumnSecurityFlagTest.php +++ b/tests/unit/ColumnSecurityFlagTest.php @@ -78,36 +78,62 @@ private function collection(string $id, bool $columnSecurity, array $permissions } /** - * $columnSecurity is required, like $documentSecurity beside it: the value passed - * is the value stored, in both directions, with no inference from what the - * collection already held. + * 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 testTheFlagPassedIsTheFlagStored(): void + public function testTurningTheFlagOffAndOnChangesWhatAColumnGrantDoes(): void { $this->collection('secured', true); - $this->collection('plain', false); - $this->authorization->skip(function () { - $this->database->updateCollection('secured', [], true, false); - $this->database->updateCollection('plain', [], true, 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'); - $collections = $this->authorization->skip(fn () => [ - $this->database->getCollection('secured'), - $this->database->getCollection('plain'), - ]); + $this->authorization->skip(fn () => $this->database->updateCollection('secured', [], true, false)); - $this->assertFalse($collections[0]->getAttribute('columnSecurity')); - $this->assertTrue($collections[1]->getAttribute('columnSecurity')); + $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->collection('plain', false); + $this->authorization->skip(function () { + $this->database->createCollection('implicit', documentSecurity: true, permissions: []); + $this->database->createAttribute('implicit', 'salary', Database::VAR_INTEGER, 8, false); + }); - $collection = $this->authorization->skip(fn () => $this->database->getCollection('plain')); + $this->expectException(DatabaseException::class); + $this->expectExceptionMessage('column security is not enabled'); - $this->assertFalse($collection->getAttribute('columnSecurity')); + $this->authorization->skip(fn () => $this->database->createDocument('implicit', new Document([ + '$id' => 'd1', + '$permissions' => [Permission::read(Role::any(), 'salary')], + 'salary' => 1, + ]))); } // ---------------------------------------------------------------- writes blocked From a1b19858bad13bd4ec479cc3f3b682bd1ad1b8ed Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:42:37 +0300 Subject: [PATCH 39/46] fixed --- src/Database/Database.php | 56 ++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/src/Database/Database.php b/src/Database/Database.php index f5bfc45fa1..488a6df38a 100644 --- a/src/Database/Database.php +++ b/src/Database/Database.php @@ -3641,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(), @@ -3656,30 +3686,8 @@ public function deleteAttribute(string $collection, string $id): bool // Ignore } - // Last, and allowed to fail loudly. - // - // Not before updateMetadata(): its rollback puts the physical column back, but - // nothing puts these rows back, so purging first would leave the column restored - // and every grant on it gone. - // - // Not before the cache purge and the events either: those describe a deletion - // that has already happened, and throwing ahead of them would leave readers a - // cached collection still listing the column and listeners never told it went. - // - // But not swallowed. A grant scoped only to this column keeps matching the row - // gate, which authorizes by role and does not look at _column -- so while these - // rows survive, count() still reports a document whose every column the caller - // has lost access to. That is an existence disclosure, not untidiness, so the - // caller is told rather than left to assume the cleanup ran. - // - // 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; - - if (\is_string($internalId) && $internalId !== '') { - $this->withRetries(fn () => $this->adapter->deleteColumnPermissions($collection, $internalId)); + if ($purgeFailure !== null) { + throw $purgeFailure; } return true; From 101c177980019209cb97b2a71c0701f470a7d131 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 09:52:39 +0300 Subject: [PATCH 40/46] fixed --- tests/e2e/Adapter/Scopes/PermissionTests.php | 33 +++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index b18f91a5fb..4fd16b206e 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1728,11 +1728,36 @@ public function testColumnSecurityIsRefusedWhenTheAdapterCannotEnforceIt(): void $create(); - $collection = $authorization->skip( - fn () => $database->getCollection('columnSupportYes') - ); + // 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->assertTrue($collection->getAttribute('columnSecurity')); + $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(); } /** From f877b3f475a61c616f2129edf622f91a9cf8bd00 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 13:50:51 +0300 Subject: [PATCH 41/46] testDeletingAColumnRevokesTheGrantsScopedToIt --- src/Database/Adapter/Memory.php | 29 ++- tests/e2e/Adapter/Scopes/PermissionTests.php | 235 +++++++++++++++++++ 2 files changed, 257 insertions(+), 7 deletions(-) diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 55cd911bbf..0dc67963ee 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2115,12 +2115,13 @@ public function deleteColumnPermissions(Document $collection, string $column): i /** * Move or drop the permissions scoped to one column. * - * Only the stored _permissions of each row are rewritten. The permission indexes - * this adapter keeps are built from getPermissionsByType(), which strips the - * column, so they hold roles alone and nothing in them refers to a column name. - * Column access is decided from the row's own permissions by rowGrantsColumns(), - * which is why leaving these unrewritten would silently revoke access after a - * rename, and let a recreated column inherit grants after a delete. + * 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 $old @@ -2174,7 +2175,21 @@ private function repointColumnPermissions(Document $collection, string $old, ?st } $touched[$documentKey] = $permissions; - $this->data[$key]['documents'][$documentKey]['_permissions'] = \array_values(\array_unique($rewritten)); + $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++; } diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 4fd16b206e..cacc964e15 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1875,4 +1875,239 @@ public function testRelatedDocumentInheritsOnlyUnscopedPermissions(): void '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 its grants, because _column carries the attribute's + * identity and a rename does not change it. A regression that put the key back + * there would leave every other test passing and quietly revoke access here. + */ + public function testRenamingAColumnKeepsItsGrantsAndIdentity(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForSchemaAttributes()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $identity = $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, + ])); + + foreach ($database->getCollection('renameKeepsGrants')->getAttribute('attributes', []) as $attribute) { + if ($attribute['key'] === 'salary') { + return $attribute[Database::ATTRIBUTE_INTERNAL_ID]; + } + } + + return null; + }); + + $this->assertIsString($identity); + + $authorization->skip(fn () => $database->updateAttribute('renameKeepsGrants', 'salary', newKey: 'pay')); + + $renamed = $authorization->skip(function () use ($database) { + foreach ($database->getCollection('renameKeepsGrants')->getAttribute('attributes', []) as $attribute) { + if ($attribute['key'] === 'pay') { + return $attribute[Database::ATTRIBUTE_INTERNAL_ID]; + } + } + + return null; + }); + + $this->assertSame($identity, $renamed, 'the rename moved the key, not the identity'); + + $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(); + } } From 30187d640275b15e874c41adfd35dc0d71199819 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 14:10:54 +0300 Subject: [PATCH 42/46] Add tests --- tests/e2e/Adapter/Scopes/PermissionTests.php | 376 ++++++++++++++++++- 1 file changed, 359 insertions(+), 17 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index cacc964e15..e3ac27f023 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1949,7 +1949,7 @@ public function testRenamingAColumnKeepsItsGrantsAndIdentity(): void $authorization = $database->getAuthorization(); - $identity = $authorization->skip(function () use ($database) { + $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); @@ -1960,31 +1960,27 @@ public function testRenamingAColumnKeepsItsGrantsAndIdentity(): void 'name' => 'Bob', 'salary' => 100, ])); + }); + + $identities = fn (): array => $authorization->skip(function () use ($database): array { + $map = []; foreach ($database->getCollection('renameKeepsGrants')->getAttribute('attributes', []) as $attribute) { - if ($attribute['key'] === 'salary') { - return $attribute[Database::ATTRIBUTE_INTERNAL_ID]; - } + $map[$attribute['key']] = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? ''; } - return null; + return $map; }); - $this->assertIsString($identity); + $before = $identities(); + $this->assertArrayHasKey('salary', $before); + $this->assertNotSame('', $before['salary']); $authorization->skip(fn () => $database->updateAttribute('renameKeepsGrants', 'salary', newKey: 'pay')); - $renamed = $authorization->skip(function () use ($database) { - foreach ($database->getCollection('renameKeepsGrants')->getAttribute('attributes', []) as $attribute) { - if ($attribute['key'] === 'pay') { - return $attribute[Database::ATTRIBUTE_INTERNAL_ID]; - } - } - - return null; - }); - - $this->assertSame($identity, $renamed, 'the rename moved the key, not the identity'); + $after = $identities(); + $this->assertArrayNotHasKey('salary', $after); + $this->assertSame($before['salary'], $after['pay'] ?? null, 'the rename moved the key, not the identity'); $authorization->cleanRoles(); $authorization->addRole('user:hr'); @@ -2110,4 +2106,350 @@ public function testUpsertKeepsColumnScopedPermissionsReadable(): void $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' + ); + + $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'), + '$permissions' => [Permission::read(Role::user('hr'), 'label')], + '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'); + + $child = $authorization->skip(fn () => $database->getDocument('maskChild', 'c1')); + $this->assertSame('not', $child->getAttribute('hidden'), 'stored intact'); + + $seen = $database->getDocument('maskChild', 'c1'); + $this->assertSame('visible', $seen->getAttribute('label')); + $this->assertNull($seen->getAttribute('hidden'), 'and the child hides what the child hides'); + + $authorization->cleanRoles(); + } } From b770258e07e5e4227ee9d734f4b7b97514276b48 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 14:30:25 +0300 Subject: [PATCH 43/46] fix Test mirrors internal identity --- tests/e2e/Adapter/Scopes/PermissionTests.php | 79 ++++++++++++++------ 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index e3ac27f023..7be2cb4fd8 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -1931,17 +1931,16 @@ public function testDeletingAColumnRevokesTheGrantsScopedToIt(): void } /** - * Renaming a column keeps its grants, because _column carries the attribute's - * identity and a rename does not change it. A regression that put the key back - * there would leave every other test passing and quietly revoke access here. + * 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 testRenamingAColumnKeepsItsGrantsAndIdentity(): void + public function testRenamingAColumnKeepsItsGrants(): void { /** @var Database $database */ $database = $this->getDatabase(); if (!$database->getAdapter()->getSupportForColumnPermissions() - || !$database->getAdapter()->getSupportForSchemaAttributes()) { + || !$database->getAdapter()->getSupportForAttributes()) { $this->expectNotToPerformAssertions(); return; @@ -1962,26 +1961,8 @@ public function testRenamingAColumnKeepsItsGrantsAndIdentity(): void ])); }); - $identities = fn (): array => $authorization->skip(function () use ($database): array { - $map = []; - - foreach ($database->getCollection('renameKeepsGrants')->getAttribute('attributes', []) as $attribute) { - $map[$attribute['key']] = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? ''; - } - - return $map; - }); - - $before = $identities(); - $this->assertArrayHasKey('salary', $before); - $this->assertNotSame('', $before['salary']); - $authorization->skip(fn () => $database->updateAttribute('renameKeepsGrants', 'salary', newKey: 'pay')); - $after = $identities(); - $this->assertArrayNotHasKey('salary', $after); - $this->assertSame($before['salary'], $after['pay'] ?? null, 'the rename moved the key, not the identity'); - $authorization->cleanRoles(); $authorization->addRole('user:hr'); @@ -2452,4 +2433,56 @@ public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void $authorization->cleanRoles(); } + + /** + * A rename changes the key and leaves the attribute's identity alone. + * + * This pins a design decision rather than a caller-visible behaviour, and is + * deliberately separate from testRenamingAColumnKeepsItsGrants() for that reason. + * Grants surviving a rename is what callers need, and more than one design delivers + * it -- storing the key in _column and migrating every permission row on rename does + * too. This is the assertion that says which one is in force: _column holds an + * identity a rename cannot change, so the rename moves no permission rows at all. + * + * If that design is ever traded for another, this test is the one to delete. + */ + public function testRenamingAColumnDoesNotChangeItsIdentity(): void + { + /** @var Database $database */ + $database = $this->getDatabase(); + + if (!$database->getAdapter()->getSupportForColumnPermissions() + || !$database->getAdapter()->getSupportForAttributes()) { + $this->expectNotToPerformAssertions(); + + return; + } + + $authorization = $database->getAuthorization(); + + $identities = fn (): array => $authorization->skip(function () use ($database): array { + $map = []; + + foreach ($database->getCollection('renameKeepsIdentity')->getAttribute('attributes', []) as $attribute) { + $map[$attribute['key']] = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? ''; + } + + return $map; + }); + + $authorization->skip(function () use ($database) { + $database->createCollection('renameKeepsIdentity', documentSecurity: true, columnSecurity: true, permissions: []); + $database->createAttribute('renameKeepsIdentity', 'salary', Database::VAR_INTEGER, 8, false); + }); + + $before = $identities(); + $this->assertArrayHasKey('salary', $before); + $this->assertNotSame('', $before['salary']); + + $authorization->skip(fn () => $database->updateAttribute('renameKeepsIdentity', 'salary', newKey: 'pay')); + + $after = $identities(); + $this->assertArrayNotHasKey('salary', $after, 'the key moved'); + $this->assertSame($before['salary'], $after['pay'] ?? null, 'the identity did not'); + } } From f28a2062515c19bbe27601625849ddb863ad2dc1 Mon Sep 17 00:00:00 2001 From: fogelito Date: Mon, 28 Sep 2026 14:42:12 +0300 Subject: [PATCH 44/46] fix Related child goes unchecked --- tests/e2e/Adapter/Scopes/PermissionTests.php | 28 ++++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index 7be2cb4fd8..f835d24217 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -2396,7 +2396,8 @@ public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void collection: 'maskParent', relatedCollection: 'maskChild', type: Database::RELATION_ONE_TO_MANY, - id: 'kids' + id: 'kids', + twoWayKey: 'parent' ); $database->createDocument('maskParent', new Document([ @@ -2409,7 +2410,15 @@ public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void 'secret' => 'S', 'kids' => [new Document([ '$id' => ID::custom('c1'), - '$permissions' => [Permission::read(Role::user('hr'), 'label')], + // 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', ])], @@ -2424,12 +2433,21 @@ public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void $this->assertSame('T', $parent->getAttribute('title')); $this->assertNull($parent->getAttribute('secret'), 'the parent hides what the parent hides'); - $child = $authorization->skip(fn () => $database->getDocument('maskChild', 'c1')); - $this->assertSame('not', $child->getAttribute('hidden'), 'stored intact'); + // 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'), 'and the child hides what the child hides'); + $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(); } From b312af86827ee12ecf55484bdf0c89280bfe34f6 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 29 Sep 2026 09:17:34 +0300 Subject: [PATCH 45/46] fix testRenamingAColumnDoesNotChangeItsIdentity --- tests/e2e/Adapter/Scopes/PermissionTests.php | 52 -------------------- tests/unit/ColumnPermissionTest.php | 43 ++++++++++++++++ 2 files changed, 43 insertions(+), 52 deletions(-) diff --git a/tests/e2e/Adapter/Scopes/PermissionTests.php b/tests/e2e/Adapter/Scopes/PermissionTests.php index f835d24217..4daa97f1db 100644 --- a/tests/e2e/Adapter/Scopes/PermissionTests.php +++ b/tests/e2e/Adapter/Scopes/PermissionTests.php @@ -2451,56 +2451,4 @@ public function testRelatedDocumentsAreMaskedByTheirOwnCollection(): void $authorization->cleanRoles(); } - - /** - * A rename changes the key and leaves the attribute's identity alone. - * - * This pins a design decision rather than a caller-visible behaviour, and is - * deliberately separate from testRenamingAColumnKeepsItsGrants() for that reason. - * Grants surviving a rename is what callers need, and more than one design delivers - * it -- storing the key in _column and migrating every permission row on rename does - * too. This is the assertion that says which one is in force: _column holds an - * identity a rename cannot change, so the rename moves no permission rows at all. - * - * If that design is ever traded for another, this test is the one to delete. - */ - public function testRenamingAColumnDoesNotChangeItsIdentity(): void - { - /** @var Database $database */ - $database = $this->getDatabase(); - - if (!$database->getAdapter()->getSupportForColumnPermissions() - || !$database->getAdapter()->getSupportForAttributes()) { - $this->expectNotToPerformAssertions(); - - return; - } - - $authorization = $database->getAuthorization(); - - $identities = fn (): array => $authorization->skip(function () use ($database): array { - $map = []; - - foreach ($database->getCollection('renameKeepsIdentity')->getAttribute('attributes', []) as $attribute) { - $map[$attribute['key']] = $attribute[Database::ATTRIBUTE_INTERNAL_ID] ?? ''; - } - - return $map; - }); - - $authorization->skip(function () use ($database) { - $database->createCollection('renameKeepsIdentity', documentSecurity: true, columnSecurity: true, permissions: []); - $database->createAttribute('renameKeepsIdentity', 'salary', Database::VAR_INTEGER, 8, false); - }); - - $before = $identities(); - $this->assertArrayHasKey('salary', $before); - $this->assertNotSame('', $before['salary']); - - $authorization->skip(fn () => $database->updateAttribute('renameKeepsIdentity', '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/ColumnPermissionTest.php b/tests/unit/ColumnPermissionTest.php index 75d560bebc..cf3b246080 100644 --- a/tests/unit/ColumnPermissionTest.php +++ b/tests/unit/ColumnPermissionTest.php @@ -427,4 +427,47 @@ public function testUpdateDocumentRejectsGrantOnMissingColumnWithoutValidation() ])) )); } + + /** + * 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'); + } } From 21c5ff07533377e9696f7e60f781eeb7ab4adf64 Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 29 Sep 2026 11:08:17 +0300 Subject: [PATCH 46/46] Remove renameColumnPermissions --- src/Database/Adapter.php | 9 -- src/Database/Adapter/Memory.php | 43 ++----- src/Database/Adapter/Mongo.php | 18 +-- src/Database/Adapter/Pool.php | 14 --- src/Database/Adapter/Redis.php | 13 --- src/Database/Adapter/SQL.php | 191 ++++---------------------------- 6 files changed, 31 insertions(+), 257 deletions(-) diff --git a/src/Database/Adapter.php b/src/Database/Adapter.php index 2f45f1c95d..ab805d48bf 100644 --- a/src/Database/Adapter.php +++ b/src/Database/Adapter.php @@ -1021,15 +1021,6 @@ abstract public function getSupportForSchemaAttributes(): bool; */ abstract public function getSupportForColumnPermissions(): bool; - /** - * Repoint column-scoped permissions at a renamed column. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose $permissions changed - */ - abstract public function renameColumnPermissions(Document $collection, string $old, string $new): int; /** * Drop every permission scoped to a column that no longer exists. diff --git a/src/Database/Adapter/Memory.php b/src/Database/Adapter/Memory.php index 0dc67963ee..8d114a7a07 100644 --- a/src/Database/Adapter/Memory.php +++ b/src/Database/Adapter/Memory.php @@ -2092,28 +2092,14 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Unreachable: _perms._column holds the attribute's immutable identity, which a - * rename does not change, so Database::renameAttribute() moves no permission rows. - * Kept only to satisfy the Adapter contract until that method is removed from it. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - */ - public function renameColumnPermissions(Document $collection, string $old, string $new): int - { - return 0; - } public function deleteColumnPermissions(Document $collection, string $column): int { - return $this->repointColumnPermissions($collection, $column, null); + return $this->deleteColumnPermissionRows($collection, $column); } /** - * Move or drop the permissions scoped to one 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 @@ -2124,12 +2110,11 @@ public function deleteColumnPermissions(Document $collection, string $column): i * them settles both at once; here they are separate and both have to be told. * * @param Document $collection - * @param string $old - * @param string|null $new new column key, or null to drop the permissions + * @param string $column * @return int documents whose permissions changed * @throws DatabaseException */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): int + private function deleteColumnPermissionRows(Document $collection, string $column): int { $key = $this->key($collection->getId()); $updated = 0; @@ -2148,26 +2133,12 @@ private function repointColumnPermissions(Document $collection, string $old, ?st $changed = false; foreach ($permissions as $permission) { - $parsed = Permission::parse($permission); - - if ($parsed->getColumn() !== $old) { - $rewritten[] = $permission; - continue; - } - - $changed = true; - - if (\is_null($new)) { + if (Permission::parse($permission)->getColumn() === $column) { + $changed = true; continue; } - $rewritten[] = (new Permission( - $parsed->getPermission(), - $parsed->getRole(), - $parsed->getIdentifier(), - $parsed->getDimension(), - $new - ))->toString(); + $rewritten[] = $permission; } if (!$changed) { diff --git a/src/Database/Adapter/Mongo.php b/src/Database/Adapter/Mongo.php index ee493b347d..47f5a6c06f 100644 --- a/src/Database/Adapter/Mongo.php +++ b/src/Database/Adapter/Mongo.php @@ -4304,24 +4304,10 @@ public function getSupportForColumnPermissions(): bool return true; } - /** - * Unreachable: _perms._column holds the attribute's immutable identity, which a - * rename does not change, so Database::renameAttribute() moves no permission rows. - * Kept only to satisfy the Adapter contract until that method is removed from it. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - */ - public function renameColumnPermissions(Document $collection, string $old, string $new): int - { - return 0; - } public function deleteColumnPermissions(Document $collection, string $column): int { - return $this->repointColumnPermissions($collection, $column); + return $this->deleteColumnPermissionRows($collection, $column); } /** @@ -4340,7 +4326,7 @@ public function deleteColumnPermissions(Document $collection, string $column): i * @return int documents whose permissions changed * @throws Exception */ - private function repointColumnPermissions(Document $collection, string $column): int + private function deleteColumnPermissionRows(Document $collection, string $column): int { $name = $this->getNamespace() . '_' . $this->filter($collection->getId()); $updated = 0; diff --git a/src/Database/Adapter/Pool.php b/src/Database/Adapter/Pool.php index 7d6371a305..01e8ec381f 100644 --- a/src/Database/Adapter/Pool.php +++ b/src/Database/Adapter/Pool.php @@ -527,20 +527,6 @@ public function getSupportForColumnPermissions(): bool return $this->delegate(__FUNCTION__, \func_get_args()); } - /** - * Unreachable: _perms._column holds the attribute's immutable identity, which a - * rename does not change, so Database::renameAttribute() moves no permission rows. - * Kept only to satisfy the Adapter contract until that method is removed from it. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - */ - public function renameColumnPermissions(Document $collection, string $old, string $new): int - { - return 0; - } public function deleteColumnPermissions(Document $collection, string $column): int { diff --git a/src/Database/Adapter/Redis.php b/src/Database/Adapter/Redis.php index 0e595bec61..11c8fa97ba 100644 --- a/src/Database/Adapter/Redis.php +++ b/src/Database/Adapter/Redis.php @@ -776,19 +776,6 @@ public function getSupportForColumnPermissions(): bool return false; } - /** - * Column-level permissions are not supported by this adapter, so a rename - * can never have column-scoped permissions to repoint. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - */ - public function renameColumnPermissions(Document $collection, string $old, string $new): int - { - return 0; - } public function deleteColumnPermissions(Document $collection, string $column): int { diff --git a/src/Database/Adapter/SQL.php b/src/Database/Adapter/SQL.php index 343c0b430a..8c53a1d31e 100644 --- a/src/Database/Adapter/SQL.php +++ b/src/Database/Adapter/SQL.php @@ -2094,35 +2094,7 @@ public function getSupportForColumnPermissions(): bool } /** - * Repoint column-scoped permissions at a renamed column. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - * @throws DatabaseException - */ - /** - * Unreachable: _perms._column holds the attribute's immutable identity, which a - * rename does not change, so Database::renameAttribute() moves no permission rows. - * Kept only to satisfy the Adapter contract until that method is removed from it. - * - * @param Document $collection - * @param string $old - * @param string $new - * @return int documents whose permissions changed - */ - public function renameColumnPermissions(Document $collection, string $old, string $new): int - { - return 0; - } - - /** - * Drop every permission scoped to a column that no longer exists. - * - * Required, not hygiene: because permissions name the column by key, leaving - * rows behind means re-creating a column under the same name inherits the old - * column's grants. + * Drop the permissions scoped to one column. * * @param Document $collection * @param string $column @@ -2131,7 +2103,7 @@ public function renameColumnPermissions(Document $collection, string $old, strin */ public function deleteColumnPermissions(Document $collection, string $column): int { - return $this->repointColumnPermissions($collection, $column, null); + return $this->deleteColumnPermissionRows($collection, $column); } /** @@ -2147,12 +2119,11 @@ public function deleteColumnPermissions(Document $collection, string $column): i * targeted instead of scanning the whole collection. * * @param Document $collection - * @param string $old - * @param string|null $new new column key, or null to drop the permissions + * @param string $column * @return int documents whose permissions changed * @throws DatabaseException */ - private function repointColumnPermissions(Document $collection, string $old, ?string $new): int + private function deleteColumnPermissionRows(Document $collection, string $column): int { $name = $this->filter($collection->getId()); $tenantQuery = $this->getTenantQuery($collection->getId()); @@ -2170,11 +2141,9 @@ private function repointColumnPermissions(Document $collection, string $old, ?st // 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 work to remove its own rows - // from the predicate. A delete does remove them, but a repoint only rewrites - // _column -- and under a case-insensitive collation, which is the default for - // utf8mb4, a rename that changes only case leaves every rewritten row still - // matching _column = :_old. The predicate would return the same batch forever. + // 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) { @@ -2190,7 +2159,7 @@ private function repointColumnPermissions(Document $collection, string $old, ?st ORDER BY _id LIMIT " . self::SELECT_BATCH_SIZE . " "); - $stmt->bindValue(':_column', $old); + $stmt->bindValue(':_column', $column); $stmt->bindValue(':_cursor', $cursor); if ($this->sharedTables) { $stmt->bindValue(':_tenant', $this->tenant); @@ -2214,40 +2183,19 @@ private function repointColumnPermissions(Document $collection, string $old, ?st )); // The stored $permissions on the row and the _perms rows hold the same - // fact, so they move together, scoped to this batch. - $updated += $this->repointPermissionsJson($name, $documents, $placeholders, $tenantQuery, $old, $new); - - if (!\is_null($new)) { - // A document can already hold the same role and action scoped to the - // destination column. Repointing the old scope onto it would then be a - // second identical row, which _index1 refuses -- and by this point the - // physical column has been renamed, so the failure would leave the - // schema renamed with permissions still describing the old state. - // The old scope is redundant once the destination exists, so drop it - // instead of repointing it. - $this->dropCollidingColumnPermissions($table, $documents, $placeholders, $tenantQuery, $old, $new); - } + // fact, so they go together, scoped to this batch. + $updated += $this->removePermissionsJson($name, $documents, $placeholders, $tenantQuery, $column); - // Addressed by primary key. Rows the collision pass above already removed - // simply match nothing. + // Addressed by primary key. $sequencePlaceholders = \implode(', ', \array_map( fn ($index) => ":_id_{$index}", \array_keys($sequences) )); - if (\is_null($new)) { - $mutate = $this->getPDO()->prepare(" + $mutate = $this->getPDO()->prepare(" DELETE FROM {$table} WHERE _id IN ({$sequencePlaceholders}) "); - } else { - $mutate = $this->getPDO()->prepare(" - UPDATE {$table} - SET _column = :_new - WHERE _id IN ({$sequencePlaceholders}) - "); - $mutate->bindValue(':_new', $new); - } foreach ($sequences as $index => $sequence) { $mutate->bindValue(":_id_{$index}", $sequence); @@ -2262,84 +2210,6 @@ private function repointColumnPermissions(Document $collection, string $old, ?st return $updated; } - /** - * Drop old-column rows whose destination scope already exists on the same - * document, role and action. - * - * @param string $table - * @param array $documents - * @param string $placeholders - * @param string $tenantQuery - * @param string $old - * @param string $new - * @return void - * @throws DatabaseException - */ - private function dropCollidingColumnPermissions( - string $table, - array $documents, - string $placeholders, - string $tenantQuery, - string $old, - string $new - ): void { - $stmt = $this->getPDO()->prepare(" - SELECT _document, _type, _permission, _column - FROM {$table} - WHERE _column IN (:_old, :_new) - AND _document IN ({$placeholders}) - {$tenantQuery} - "); - $stmt->bindValue(':_old', $old); - $stmt->bindValue(':_new', $new); - foreach ($documents as $index => $id) { - $stmt->bindValue(":_uid_{$index}", $id); - } - if ($this->sharedTables) { - $stmt->bindValue(':_tenant', $this->tenant); - } - $this->execute($stmt); - - $rows = $stmt->fetchAll(); - $stmt->closeCursor(); - - // Resolved here rather than in SQL: a DELETE whose subquery reads the table it - // deletes from is rejected by MySQL, and the workarounds differ per engine. - $seen = []; - foreach ($rows as $row) { - if ($row['_column'] === $new) { - $seen[$row['_document'] . "\0" . $row['_type'] . "\0" . $row['_permission']] = true; - } - } - - $delete = $this->getPDO()->prepare(" - DELETE FROM {$table} - WHERE _document = :_document - AND _type = :_type - AND _permission = :_permission - AND _column = :_old - {$tenantQuery} - "); - - foreach ($rows as $row) { - if ($row['_column'] !== $old) { - continue; - } - - if (!isset($seen[$row['_document'] . "\0" . $row['_type'] . "\0" . $row['_permission']])) { - continue; - } - - $delete->bindValue(':_document', $row['_document']); - $delete->bindValue(':_type', $row['_type']); - $delete->bindValue(':_permission', $row['_permission']); - $delete->bindValue(':_old', $old); - if ($this->sharedTables) { - $delete->bindValue(':_tenant', $this->tenant); - } - $this->execute($delete); - } - } /** * Rewrite the stored $permissions of one batch of documents. @@ -2348,18 +2218,16 @@ private function dropCollidingColumnPermissions( * @param array $documents * @param string $placeholders * @param string $tenantQuery - * @param string $old - * @param string|null $new + * @param string $column * @return int documents whose $permissions changed * @throws DatabaseException */ - private function repointPermissionsJson( + private function removePermissionsJson( string $name, array $documents, string $placeholders, string $tenantQuery, - string $old, - ?string $new + string $column ): int { $select = $this->getPDO()->prepare(" SELECT _uid, _permissions @@ -2399,7 +2267,7 @@ private function repointPermissionsJson( $current = $row['_permissions'] ?? '[]'; for ($attempt = 0; $attempt < self::REWRITE_MAX_ATTEMPTS; $attempt++) { - $rewritten = $this->withoutColumnPermissions($current, $old, $new); + $rewritten = $this->withoutColumnPermissions($current, $column); if ($rewritten === null) { // Nothing on this row names the column any more. @@ -2437,11 +2305,10 @@ private function repointPermissionsJson( * 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 $old the column whose grants are going - * @param string|null $new new column key, or null to drop the grants - * @return string|null the new JSON, or null when nothing names $old + * @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 $old, ?string $new): ?string + private function withoutColumnPermissions(string $stored, string $column): ?string { $permissions = \json_decode($stored, true); @@ -2453,26 +2320,12 @@ private function withoutColumnPermissions(string $stored, string $old, ?string $ $changed = false; foreach ($permissions as $permission) { - $parsed = Permission::parse($permission); - - if ($parsed->getColumn() !== $old) { - $rewritten[] = $permission; - continue; - } - - $changed = true; - - if (\is_null($new)) { + if (Permission::parse($permission)->getColumn() === $column) { + $changed = true; continue; } - $rewritten[] = (new Permission( - $parsed->getPermission(), - $parsed->getRole(), - $parsed->getIdentifier(), - $parsed->getDimension(), - $new - ))->toString(); + $rewritten[] = $permission; } if (!$changed) {