Skip to content

Commit b365a3c

Browse files
committed
feat: enhance relation permissions and accessibility checks
Signed-off-by: Enjeck C. <patrathewhiz@gmail.com>
1 parent 7aac165 commit b365a3c

6 files changed

Lines changed: 226 additions & 31 deletions

File tree

‎lib/AppInfo/Application.php‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class Application extends App implements IBootstrap {
4949
public const NODE_TYPE_TABLE = 0;
5050
public const NODE_TYPE_VIEW = 1;
5151

52+
public const NODE_TYPE_NAME_TABLE = 'table';
53+
public const NODE_TYPE_NAME_VIEW = 'view';
54+
public const NODE_TYPE_NAME_CONTEXT = 'context';
55+
5256
public const OWNER_TYPE_USER = 0;
5357

5458
public const NAV_ENTRY_MODE_HIDDEN = 0;

‎lib/Service/ColumnService.php‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use DateTime;
1111
use Exception;
12+
use OCA\Tables\AppInfo\Application;
1213
use OCA\Tables\Constants\ColumnType;
1314
use OCA\Tables\Db\Column;
1415
use OCA\Tables\Db\ColumnMapper;
@@ -304,6 +305,9 @@ public function create(
304305
}
305306

306307
$this->validateCustomSettings($columnDto->getCustomSettings());
308+
if ($columnDto->getType() === Column::TYPE_RELATION) {
309+
$this->validateRelationTargetAccess($columnDto->getCustomSettings(), $userId);
310+
}
307311

308312
$item = Column::fromDto($columnDto);
309313
$item->setTitle($newTitle);
@@ -408,6 +412,9 @@ public function update(
408412
$item->setUsergroupSelectTeams($columnDto->getUsergroupSelectTeams());
409413
$item->setShowUserStatus($columnDto->getShowUserStatus());
410414
$this->validateCustomSettings($columnDto->getCustomSettings());
415+
if ($columnDto->getType() === Column::TYPE_RELATION || $item->getType() === Column::TYPE_RELATION) {
416+
$this->validateRelationTargetAccess($columnDto->getCustomSettings(), $userId);
417+
}
411418
$item->setCustomSettings($columnDto->getCustomSettings());
412419

413420
$this->updateMetadata($item, $userId);
@@ -450,6 +457,41 @@ private function validateCustomSettings(?string $customSettings): void {
450457
}
451458
}
452459

460+
/**
461+
* Ensure the user configuring a relation column can actually read the target
462+
*
463+
* @param string|null $customSettings
464+
* @throws BadRequestError
465+
*/
466+
private function validateRelationTargetAccess(?string $customSettings, ?string $userId): void {
467+
if ($customSettings === null) {
468+
return;
469+
}
470+
$settings = json_decode($customSettings, true);
471+
if (!is_array($settings)) {
472+
return;
473+
}
474+
475+
$relationType = $settings[Column::RELATION_TYPE] ?? null;
476+
$targetId = isset($settings[Column::RELATION_TARGET_ID]) ? (int)$settings[Column::RELATION_TARGET_ID] : null;
477+
if (empty($relationType) || empty($targetId)) {
478+
return;
479+
}
480+
481+
if ($relationType === Application::NODE_TYPE_NAME_VIEW) {
482+
$canRead = $this->permissionsService->canReadColumnsByViewId($targetId, $userId);
483+
} elseif ($relationType === Application::NODE_TYPE_NAME_TABLE) {
484+
$canRead = $this->permissionsService->canReadColumnsByTableId($targetId, $userId);
485+
} else {
486+
$canRead = false;
487+
}
488+
489+
if (!$canRead) {
490+
$translatedMessage = $this->l->t('You can only link to a table or view that you have access to.');
491+
throw new BadRequestError($translatedMessage, 0, null, $translatedMessage);
492+
}
493+
}
494+
453495
private function normalizeTitle(?string $title, bool $required): ?string {
454496
if ($title === null) {
455497
if ($required) {

‎lib/Service/RelationService.php‎

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@
77

88
namespace OCA\Tables\Service;
99

10+
use OCA\Tables\AppInfo\Application;
1011
use OCA\Tables\Db\Column;
1112
use OCA\Tables\Db\ColumnMapper;
1213
use OCA\Tables\Db\Row2Mapper;
14+
use OCA\Tables\Db\TableMapper;
1315
use OCA\Tables\Db\ViewMapper;
1416
use OCA\Tables\Errors\InternalError;
1517
use OCA\Tables\Errors\NotFoundError;
@@ -20,11 +22,17 @@ class RelationService {
2022
/** @var array<string, array> Cache for relation data */
2123
private array $cacheRelationData = [];
2224

25+
/** @var array<string, bool> Cache for manager accessibility decisions, keyed by host table + target */
26+
private array $cacheManagerAccess = [];
27+
2328
public function __construct(
2429
private ColumnMapper $columnMapper,
2530
private ViewMapper $viewMapper,
31+
private TableMapper $tableMapper,
2632
private Row2Mapper $row2Mapper,
2733
private ColumnService $columnService,
34+
private PermissionsService $permissionsService,
35+
private ShareService $shareService,
2836
private ?string $userId,
2937
) {
3038
}
@@ -83,9 +91,10 @@ private function getRelationsForColumns(array $relationColumns): array {
8391
foreach ($groupedColumns as $target => $columns) {
8492
$relationData = $this->getRelationDataForTarget($target, $columns[0]);
8593

86-
// Assign the same data to all columns with this target
94+
// Assign the same data to all columns with this target, but only when
95+
// the relation is still backed by a manager of the hosting table.
8796
foreach ($columns as $column) {
88-
$result[$column->getId()] = $relationData;
97+
$result[$column->getId()] = $this->isTargetAccessibleByManager($column) ? $relationData : [];
8998
}
9099
}
91100

@@ -133,11 +142,72 @@ public function getRelationData(Column $column): array {
133142
return [];
134143
}
135144

145+
if (!$this->isTargetAccessibleByManager($column)) {
146+
return [];
147+
}
148+
136149
$target = sprintf('%s_%s_%s', $settings['relationType'], $settings['targetId'], $settings['labelColumn']);
137150

138151
return $this->getRelationDataForTarget($target, $column);
139152
}
140153

154+
public function isTargetAccessibleByManager(Column $relationColumn): bool {
155+
$settings = $relationColumn->getCustomSettingsArray();
156+
$relationType = $settings[Column::RELATION_TYPE] ?? null;
157+
$targetId = isset($settings[Column::RELATION_TARGET_ID]) ? (int)$settings[Column::RELATION_TARGET_ID] : null;
158+
if (empty($relationType) || empty($targetId)) {
159+
return false;
160+
}
161+
162+
$hostTableId = $relationColumn->getTableId();
163+
$cacheKey = sprintf('%s_%s_%s', $hostTableId, $relationType, $targetId);
164+
if (isset($this->cacheManagerAccess[$cacheKey])) {
165+
return $this->cacheManagerAccess[$cacheKey];
166+
}
167+
168+
$candidateUserIds = [];
169+
try {
170+
$hostTable = $this->tableMapper->find($hostTableId);
171+
if ($hostTable->getOwnership() !== null && $hostTable->getOwnership() !== '') {
172+
$candidateUserIds[] = $hostTable->getOwnership();
173+
}
174+
} catch (DoesNotExistException|\OCP\AppFramework\Db\MultipleObjectsReturnedException|\OCP\DB\Exception $e) {
175+
// host table gone, so nothing to expose
176+
$this->cacheManagerAccess[$cacheKey] = false;
177+
return false;
178+
}
179+
180+
try {
181+
$candidateUserIds = array_unique(array_merge(
182+
$candidateUserIds,
183+
$this->shareService->findManagerUserIds($hostTableId, Application::NODE_TYPE_NAME_TABLE),
184+
));
185+
} catch (InternalError $e) {
186+
// fall back to the owner only
187+
}
188+
189+
$accessible = false;
190+
foreach ($candidateUserIds as $candidateUserId) {
191+
if ($candidateUserId === null || $candidateUserId === '') {
192+
continue;
193+
}
194+
if ($relationType === Application::NODE_TYPE_NAME_VIEW) {
195+
$canRead = $this->permissionsService->canReadColumnsByViewId($targetId, $candidateUserId);
196+
} elseif ($relationType === Application::NODE_TYPE_NAME_TABLE) {
197+
$canRead = $this->permissionsService->canReadColumnsByTableId($targetId, $candidateUserId);
198+
} else {
199+
$canRead = false;
200+
}
201+
if ($canRead) {
202+
$accessible = true;
203+
break;
204+
}
205+
}
206+
207+
$this->cacheManagerAccess[$cacheKey] = $accessible;
208+
return $accessible;
209+
}
210+
141211
/**
142212
* Get relation data for a specific target
143213
*
@@ -159,7 +229,7 @@ private function getRelationDataForTarget(string $target, Column $column): array
159229
return [];
160230
}
161231

162-
$isView = $settings[Column::RELATION_TYPE] === 'view';
232+
$isView = $settings[Column::RELATION_TYPE] === Application::NODE_TYPE_NAME_VIEW;
163233
$targetId = $settings[Column::RELATION_TARGET_ID] ?? null;
164234

165235
try {

‎lib/Service/ShareService.php‎

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -794,29 +794,57 @@ public function transferSharesForContext(int $contextId, string $newOwnerId, str
794794
public function findSharedWithUserIds(int $elementId, string $elementType): array {
795795
try {
796796
$shares = $this->mapper->findAllSharesForNode($elementType, $elementId, '');
797-
$sharedWithUserIds = [];
798-
799-
foreach ($shares as $share) {
800-
if ($share->getReceiverType() === ShareReceiverType::USER) {
801-
$sharedWithUserIds[$share->getReceiver()] = 1;
802-
}
803-
if ($share->getReceiverType() === ShareReceiverType::CIRCLE && $this->circleHelper->isCirclesEnabled()) {
804-
$userIds = $this->circleHelper->getUserIdsInCircle($share->getReceiver());
805-
$sharedWithUserIds += array_fill_keys($userIds, 1);
806-
}
807-
if ($share->getReceiverType() === ShareReceiverType::GROUP) {
808-
$userIds = $this->groupHelper->getUserIdsInGroup($share->getReceiver());
809-
$sharedWithUserIds += array_fill_keys($userIds, 1);
810-
}
811-
}
812-
813-
return array_keys($sharedWithUserIds);
797+
return $this->resolveShareReceiversToUserIds($shares);
814798
} catch (Exception $e) {
815799
$this->logger->error('Could not find shared with users: ' . $e->getMessage(), ['exception' => $e]);
816800
throw new InternalError('Could not find shared with users');
817801
}
818802
}
819803

804+
/**
805+
* Returns the IDs of all users that hold the manage permission on the given node
806+
*
807+
* @param int $elementId
808+
* @param string $elementType
809+
* @return string[]
810+
* @throws InternalError
811+
*/
812+
public function findManagerUserIds(int $elementId, string $elementType): array {
813+
try {
814+
$shares = $this->mapper->findAllSharesForNode($elementType, $elementId, '');
815+
return $this->resolveShareReceiversToUserIds($shares, true);
816+
} catch (Exception $e) {
817+
$this->logger->error('Could not find managing users: ' . $e->getMessage(), ['exception' => $e]);
818+
throw new InternalError('Could not find managing users');
819+
}
820+
}
821+
822+
/**
823+
* Resolves a list of shares into the set of user IDs they grant access to
824+
*
825+
* @param Share[] $shares
826+
* @param bool $requireManage when true, only shares carrying the manage permission are considered
827+
* @return string[]
828+
*/
829+
private function resolveShareReceiversToUserIds(array $shares, bool $requireManage = false): array {
830+
$userIds = [];
831+
832+
foreach ($shares as $share) {
833+
if ($requireManage && !$share->getPermissionManage()) {
834+
continue;
835+
}
836+
if ($share->getReceiverType() === ShareReceiverType::USER) {
837+
$userIds[$share->getReceiver()] = 1;
838+
} elseif ($share->getReceiverType() === ShareReceiverType::CIRCLE && $this->circleHelper->isCirclesEnabled()) {
839+
$userIds += array_fill_keys($this->circleHelper->getUserIdsInCircle($share->getReceiver()), 1);
840+
} elseif ($share->getReceiverType() === ShareReceiverType::GROUP) {
841+
$userIds += array_fill_keys($this->groupHelper->getUserIdsInGroup($share->getReceiver()), 1);
842+
}
843+
}
844+
845+
return array_keys($userIds);
846+
}
847+
820848
/**
821849
* @param int $nodeId
822850
* @param array $share

‎src/shared/components/ncTable/partials/columnTypePartials/forms/RelationForm.vue‎

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -55,25 +55,32 @@
5555
<IconInformation :size="16" class="info-icon" />
5656
<span>{{ t('tables', 'Only text and number columns can be used as label') }}</span>
5757
</div>
58+
59+
<NcNoteCard v-if="targetInaccessible"
60+
type="warning"
61+
:text="t('tables', 'The linked table or view is no longer accessible to you, so this relation is disabled. Ask its owner to share it with you, or pick a different target.')" />
5862
</div>
5963
</template>
6064

6165
<script>
62-
import { NcSelect } from '@nextcloud/vue'
66+
import { NcSelect, NcNoteCard } from '@nextcloud/vue'
6367
import { translate as t } from '@nextcloud/l10n'
6468
import { mapState } from 'pinia'
6569
import { useTablesStore } from '../../../../../../store/store.js'
6670
import { useDataStore } from '../../../../../../store/data.js'
6771
import NumberColumn from '../../../mixins/columnsTypes/number.js'
6872
import TextLineColumn from '../../../mixins/columnsTypes/textLine.js'
73+
import permissionsMixin from '../../../mixins/permissionsMixin.js'
6974
import IconInformation from 'vue-material-design-icons/InformationOutline.vue'
7075
7176
export default {
7277
name: 'RelationForm',
7378
components: {
7479
IconInformation,
7580
NcSelect,
81+
NcNoteCard,
7682
},
83+
mixins: [permissionsMixin],
7784
props: {
7885
column: {
7986
type: Object,
@@ -88,6 +95,7 @@ export default {
8895
relationType: this.column.customSettings.relationType ?? 'table',
8996
},
9097
loadingColumns: false,
98+
targetInaccessible: false,
9199
relationTypeOptions: [
92100
{ id: 'table', label: t('tables', 'Table') },
93101
{ id: 'view', label: t('tables', 'View') },
@@ -96,20 +104,29 @@ export default {
96104
}
97105
},
98106
computed: {
99-
...mapState(useTablesStore, ['tables', 'views']),
107+
...mapState(useTablesStore, ['tables', 'views', 'activeTable', 'activeView']),
108+
hostTableId() {
109+
return this.activeView ? this.activeView.tableId : (this.activeTable?.id ?? null)
110+
},
100111
availableTargets() {
101112
if (this.customSettings.relationType === 'table') {
102-
return this.tables.map(table => ({
103-
id: table.id,
104-
label: `${table.emoji} ${table.title}`,
105-
}))
113+
return this.tables
114+
// exclude the host table (no self-reference) and anything the
115+
// user cannot read
116+
.filter(table => table.id !== this.hostTableId && this.canReadData(table))
117+
.map(table => ({
118+
id: table.id,
119+
label: `${table.emoji} ${table.title}`,
120+
}))
106121
}
107122
108123
if (this.customSettings.relationType === 'view') {
109-
return this.views.map(view => ({
110-
id: view.id,
111-
label: `${view.emoji} ${view.title}`,
112-
}))
124+
return this.views
125+
.filter(view => view.tableId !== this.hostTableId && this.canReadData(view))
126+
.map(view => ({
127+
id: view.id,
128+
label: `${view.emoji} ${view.title}`,
129+
}))
113130
}
114131
115132
return []
@@ -129,17 +146,23 @@ export default {
129146
}
130147
131148
this.loadingColumns = true
149+
this.targetInaccessible = false
132150
try {
133151
const dataStore = useDataStore()
134152
const columns = await dataStore.getColumnsFromBE({
135153
tableId: this.customSettings.relationType === 'table' ? this.customSettings.targetId : null,
136154
viewId: this.customSettings.relationType === 'view' ? this.customSettings.targetId : null,
155+
showError: false,
137156
})
138157
this.availableLabelColumns = columns
139158
.filter(column =>
140159
column instanceof NumberColumn || column instanceof TextLineColumn,
141160
)
142161
.map(column => ({ id: column.id, label: column.title }))
162+
} catch (e) {
163+
// The target is no longer readable (e.g. it was unshared)
164+
this.availableLabelColumns = []
165+
this.targetInaccessible = true
143166
} finally {
144167
this.loadingColumns = false
145168
}

0 commit comments

Comments
 (0)