Skip to content

Commit

Permalink
PS-711 databox - add security to workspace list (#482)
Browse files Browse the repository at this point in the history
* PS-711 databox - add security to workspace list

* PS-714 fix asset rendition query

* PS-713 fix collection inherited privacy

* nofollow for robots

* PS-713 databox privacy widget: fix collection inheritance constraint
  • Loading branch information
4rthem authored Nov 13, 2024
1 parent 5d090a6 commit 956e913
Show file tree
Hide file tree
Showing 15 changed files with 103 additions and 104 deletions.
58 changes: 58 additions & 0 deletions databox/api/src/Api/Extension/WorkspaceExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Api\Extension;

use Alchemy\AclBundle\Entity\AccessControlEntryRepository;
use Alchemy\AclBundle\Mapping\ObjectMapping;
use Alchemy\AclBundle\Security\PermissionInterface;
use Alchemy\AuthBundle\Security\JwtUser;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Entity\Core\Workspace;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;

final readonly class WorkspaceExtension implements QueryCollectionExtensionInterface
{
public function __construct(private Security $security, private ObjectMapping $objectMapping)
{
}

public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->addWhere($queryBuilder, $resourceClass, $context);
}

private function addWhere(QueryBuilder $queryBuilder, string $resourceClass, array $context): void
{
if (Workspace::class !== $resourceClass) {
return;
}

$rootAlias = $queryBuilder->getRootAliases()[0];

$user = $this->security->getUser();
if ($user instanceof JwtUser) {
AccessControlEntryRepository::joinAcl(
$queryBuilder,
$user->getId(),
$user->getGroups(),
$this->objectMapping->getObjectKey(Workspace::class),
$rootAlias,
PermissionInterface::VIEW,
false
);
$queryBuilder->andWhere(sprintf('ace.id IS NOT NULL OR %1$s.ownerId = :uid OR %1$s.public = true', $rootAlias));
} else {
$queryBuilder->andWhere(sprintf('%1$s.public = true', $rootAlias));
}
}
}
9 changes: 1 addition & 8 deletions databox/api/src/Api/Provider/CollectionProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class CollectionProvider extends AbstractCollectionProvider

public function __construct(
private readonly CollectionSearch $search,
private readonly CollectionSearch $collectionSearch,
) {
}

Expand All @@ -23,13 +22,7 @@ protected function provideCollection(
array $uriVariables = [],
array $context = [],
): array|object {
$filters = $context['filters'] ?? [];

if ($filters['groupByWorkspace'] ?? false) {
return $this->search->searchAggregationsByWorkspace($context['userId'], $context['groupIds'], $filters);
}

$result = $this->search->search($context['userId'], $context['groupIds'], $filters);
$result = $this->search->search($context['userId'], $context['groupIds'], $context['filters'] ?? []);

return new PagerFantaApiPlatformPaginator($result);
}
Expand Down
3 changes: 1 addition & 2 deletions databox/api/src/Controller/Admin/WorkspaceCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,7 @@ public function configureFields(string $pageName): iterable
yield TextField::new('slug');
yield TextField::new('ownerId')
->onlyOndetail();
yield $this->userChoiceField->create('ownerId', 'Owner')
->onlyOnForms();
yield $this->userChoiceField->create('ownerId', 'Owner');
yield ArrayField::new('enabledLocales');
yield ArrayField::new('localeFallbacks');
yield BooleanField::new('public')
Expand Down
52 changes: 0 additions & 52 deletions databox/api/src/Elasticsearch/CollectionSearch.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@

use App\Entity\Core\Collection;
use App\Entity\Core\WorkspaceItemPrivacyInterface;
use Elastica\Aggregation;
use Elastica\Query;
use FOS\ElasticaBundle\Finder\PaginatedFinderInterface;
use Pagerfanta\Pagerfanta;
Expand Down Expand Up @@ -48,57 +47,6 @@ public function search(
return $data;
}

public function searchAggregationsByWorkspace(
?string $userId,
array $groupIds,
array $options = [],
): array {
$query = new Query();
$query->setSize(0);

$boolQuery = new Query\BoolQuery();

$this->applyFilters($boolQuery, $userId, $groupIds, $options);

$aggregation = new Aggregation\Filter('ws');
$query->addAggregation($aggregation);
$aggregation->setFilter($boolQuery);

$termAgg = new Aggregation\Terms('workspaceId');
$termAgg->setField('workspaceId');
$termAgg->setSize(300);

$aggregation->addAggregation($termAgg);

$maxLimit = 50;
$limit = (int) ($options['limit'] ?? $maxLimit);
if ($limit > $maxLimit) {
$limit = $maxLimit;
}
$top = new Aggregation\TopHits('top');
$top->setSize($limit);
$top->setSort([
'sortName' => ['order' => 'asc'],
]);
$termAgg->addAggregation($top);

$result = $this->finder->findPaginated($query);
$aggregations = $result->getAdapter()->getAggregations();

$data = [];
foreach ($aggregations['ws']['workspaceId']['buckets'] as $bucket) {
foreach ($bucket['top']['hits']['hits'] as $hit) {
$object = $this->em->find(Collection::class, $hit['_id']);

if ($object instanceof Collection) {
$data[] = $object;
}
}
}

return $data;
}

private function applyFilters(
Query\BoolQuery $boolQuery,
?string $userId,
Expand Down
4 changes: 4 additions & 0 deletions databox/api/src/Storage/RenditionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -173,12 +173,16 @@ public function getAssetRenditionUsedAs(string $as, string $assetId): ?AssetRend
->select('r')
->from(AssetRendition::class, 'r')
->innerJoin('r.definition', 'd')
->innerJoin('d.class', 'c')
->andWhere('r.asset = :asset')
->andWhere('c.public = true')
->andWhere(sprintf('d.useAs%s = :as', ucfirst($as)))
->setParameters([
'asset' => $assetId,
'as' => true,
])
->addOrderBy('d.priority', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
}
Expand Down
2 changes: 1 addition & 1 deletion databox/client/docker/nginx/conf.d/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ server {
server_name _;
server_tokens off;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "deny";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Expand Down
1 change: 0 additions & 1 deletion databox/client/src/api/collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ export type CollectionOptions = {
query?: string;
parent?: string;
workspaces?: string[];
groupByWorkspace?: boolean;
};

export async function getCollections(
Expand Down
64 changes: 31 additions & 33 deletions databox/client/src/components/Ui/PrivacyField.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, {useState} from 'react';
import MenuItem from '@mui/material/MenuItem';
import {
Alert,
Checkbox,
FormControl,
FormControlLabel,
Expand Down Expand Up @@ -33,6 +34,10 @@ function getValue(value: string, workspace: boolean, auth: boolean): number {
}
}

function getAllowedValue(value: number, inheritedPrivacy?: number): number {
return Math.max(inheritedPrivacy ?? 0, value);
}

function getKeyValue(value: string): number {
switch (value) {
default:
Expand Down Expand Up @@ -83,67 +88,58 @@ export default function PrivacyField<TFieldValues extends FieldValues>({
defaultValue: 0 as any,
});

const firstValue = React.useMemo(() => value, []);
const [p, w, a] = getFields(value);
const [privacy, setPrivacy] = useState<string>(p);
const [workspaceOnly, setWorkspaceOnly] = useState(w);
const [auth, setAuth] = useState(a);

const ip = inheritedPrivacy ?? 0;
const inheritedKeyPrivacy = getKeyValue(getFields(ip)[0]);
const resolvedPrivacy =
inheritedKeyPrivacy > 0 && getKeyValue(privacy) <= inheritedKeyPrivacy
? getFields(inheritedKeyPrivacy)[0]
: privacy;
const workspaceOnlyLocked = getValue(resolvedPrivacy, false, true) === ip;
const resolvedWorkspaceOnly = workspaceOnlyLocked ? false : workspaceOnly;
const authLocked =
getValue(resolvedPrivacy, resolvedWorkspaceOnly, false) === ip;
const resolveAuth = authLocked ? false : auth;

React.useEffect(() => {
const [p, w, a] = getFields(value);
const [p, w, a] = getFields(getAllowedValue(value, inheritedPrivacy));
setPrivacy(p);
setWorkspaceOnly(
w ||
(firstValue === value &&
getKeyValue(privacy) < inheritedKeyPrivacy &&
getValue(resolvedPrivacy, true, resolveAuth) === ip)
);
setAuth(
a ||
(firstValue === value &&
getValue(resolvedPrivacy, resolvedWorkspaceOnly, true) ===
ip)
);
setWorkspaceOnly(w);
setAuth(a);
}, [value]);

const handlePChange = (e: SelectChangeEvent): void => {
const v = e.target.value;
setPrivacy(v);
onChange(getValue(v, resolvedWorkspaceOnly, resolveAuth));
onChange(getAllowedValue(getValue(v, workspaceOnly, auth), inheritedPrivacy));
};
const handleWSOnlyChange = (
e: React.ChangeEvent<HTMLInputElement>
): void => {
setWorkspaceOnly(e.target.checked);
onChange(getValue(resolvedPrivacy, e.target.checked, resolveAuth));
onChange(getAllowedValue(getValue(privacy, e.target.checked, auth), inheritedPrivacy));
};
const handleAuthChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
setAuth(e.target.checked);
onChange(
getValue(resolvedPrivacy, resolvedWorkspaceOnly, e.target.checked)
getAllowedValue(getValue(privacy, workspaceOnly, e.target.checked), inheritedPrivacy)
);
};

const workspaceOnlyLocked = !!inheritedPrivacy && getValue(privacy, true, auth) < inheritedPrivacy;
const authLocked = !!inheritedPrivacy && getValue(privacy, workspaceOnly, false) < inheritedPrivacy;

const label = t('form.privacy.label', 'Privacy');

return (
<>
{!!inheritedPrivacy ? <>
<Alert severity={'warning'}>
{t('form.privacy.inherited', 'This collection cannot be more restricted than its parent collection.')}
</Alert>
</> : null}
<FormControl>
<InputLabel>{label}</InputLabel>
<InputLabel>
{label}
</InputLabel>
<Select<string>
label={label}
value={resolvedPrivacy}
value={privacy}
onChange={handlePChange}
>
{Object.keys(choices).map(k => {
Expand All @@ -164,12 +160,13 @@ export default function PrivacyField<TFieldValues extends FieldValues>({
);
})}
</Select>
{['private', 'public'].includes(resolvedPrivacy) && (

{['private', 'public'].includes(privacy) && (
<FormControlLabel
disabled={workspaceOnlyLocked}
control={
<Checkbox
checked={resolvedWorkspaceOnly}
checked={workspaceOnly}
onChange={handleWSOnlyChange}
/>
}
Expand All @@ -180,12 +177,12 @@ export default function PrivacyField<TFieldValues extends FieldValues>({
labelPlacement="end"
/>
)}
{resolvedPrivacy === 'public' && (
{privacy === 'public' && (
<FormControlLabel
disabled={authLocked || resolvedWorkspaceOnly}
disabled={authLocked || workspaceOnly}
control={
<Checkbox
checked={resolveAuth || resolvedWorkspaceOnly}
checked={auth || workspaceOnly}
onChange={handleAuthChange}
/>
}
Expand All @@ -197,5 +194,6 @@ export default function PrivacyField<TFieldValues extends FieldValues>({
/>
)}
</FormControl>
</>
);
}
2 changes: 1 addition & 1 deletion databox/client/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export interface Collection extends IPermissions {
public: boolean;
shared: boolean;
privacy: number;
inheritedPrivacy: number;
inheritedPrivacy?: number;
createdAt: string;
updatedAt: string;
owner?: User;
Expand Down
2 changes: 1 addition & 1 deletion expose/api/docker/nginx/tpl/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ server {

server_tokens off;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "deny";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Expand Down
2 changes: 1 addition & 1 deletion expose/client/docker/nginx/conf.d/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ server {
server_name _;
server_tokens off;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header X-Content-Type-Options "nosniff";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";

Expand Down
2 changes: 1 addition & 1 deletion infra/docker/matomo-nginx/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ upstream php-handler {
server {
listen 80;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header Referrer-Policy origin; # make sure outgoing links don't show the URL to the Matomo instance
root /var/www/html; # replace with path to your matomo instance
index index.php;
Expand Down
2 changes: 1 addition & 1 deletion infra/docker/nginx-fpm-base/tpl/default.conf
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ server {

client_max_body_size $UPLOAD_MAX_FILE_SIZE;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "deny";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Expand Down
2 changes: 1 addition & 1 deletion uploader/api/src/Controller/Admin/TargetCrudController.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ public function configureFields(string $pageName): iterable
yield CodeField::new('pullModeUrl', 'Pull mode URL')
->onlyOnIndex();
yield TextField::new('targetUrl')
->setHelp('Leave empty for pull mode. i.e: "https://phraseanet.phrasea.local/api/v1/upload/enqueue/" for Phraseanet, "http://databox-api/incoming-uploads" for Databox upload');
->setHelp('Leave empty for pull mode. i.e: "https://phraseanet.phrasea.local/api/v1/upload/enqueue/" for Phraseanet, "http://api-databox.phrasea.local/incoming-uploads" for Databox upload');
yield TextField::new('targetTokenType')
->setHelp('Use "OAuth" for Phraseanet')
->setFormTypeOptions(['attr' => ['placeholder' => 'Defaults to "Bearer"']])
Expand Down
2 changes: 1 addition & 1 deletion uploader/client/docker/nginx/conf.d/app.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ server {
server_name _;
server_tokens off;

add_header X-Robots-Tag "noindex";
add_header X-Robots-Tag "noindex, nofollow";
add_header X-Content-Type-Options "nosniff";
add_header X-Frame-Options "deny";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains";
Expand Down

0 comments on commit 956e913

Please sign in to comment.