From 635a5941f4d34872512dcabaac0d2bd51643f86d Mon Sep 17 00:00:00 2001 From: fogelito Date: Tue, 8 Sep 2026 08:40:31 +0300 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] $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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] $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/22] 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/22] 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) {