Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ Every change that reaches `main` adds a line under **Unreleased**. When a versio

## Unreleased

### Breaking
- Writing CWA content (pages, layouts, routes, component groups and their order, component positions, every component including `/move`, and every page data class, project classes included) requires `publishable.permission` (or the class's `#[Publishable(isGranted:)]`), ANDed with any `security` the operation already has; opt an operation or resource out with `extraProperties: ['publishable_permission' => false]`. Form `/submit` stays public ([#579](https://github.com/components-web-app/api-components-bundle/pull/579))

### Added
- `emails.logo` (a Twig path, default the bundle's logo) and `emails.logo_width` (pixels, optional) set the logo at the top of every email the bundle sends, whose alt text is now the `website_name` instead of `Logo` ([#576](https://github.com/components-web-app/api-components-bundle/pull/576))

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,7 @@ Resources are fetched and cached **individually**. Never embed related data outs

### Security and users

- **Writing CWA content needs the publishable permission, whatever the application's access rules** (#569). `ContentWriteSecurityResourceMetadataCollectionFactory`, the outermost bundle metadata decorator (decoration priority -1, so it sees every operation the other bundle factories add, `/move` and uploads included), gives every `POST`/`PUT`/`PATCH`/`DELETE` operation of `Page`, `Layout`, `Route`, `ComponentGroup`, `ComponentGroupOrder`, `ComponentPosition` and every `AbstractComponent` and `AbstractPageData` subclass, project classes included, the `security` `publishable.permission`, or the class's own `#[Publishable(isGranted:)]` when it sets one (the permission `PublishableStatusChecker` resolves for the class). It goes in `security`, not `securityPostDenormalize`: the expression needs no object, and `security` runs before deserialization, so a refused write never reaches the processors. **An operation's own `security` is ANDed with it, never replaced and never an opt-out**: `Route`'s write operations carry `read_route` and `RoutableResourceMetadataCollectionFactory` gives page and page data writes `read_routable` (and `routable_security` on `POST`), which are read checks that granted any reader the write. **The opt-out is `extraProperties: [ContentWriteSecurityResourceMetadataCollectionFactory::OPT_OUT_EXTRA_PROPERTY => false]`** on an operation, or on `#[ApiResource]` for every operation of the resource; then the operation's own `security` (or none) is all that applies. Form `/submit` (`POST` and the validating `PATCH`) opts out, and the test bundle's `DummyPublishableComponent` does, so the publishable tests can still write as a user and anonymously. Reads are untouched. `ROLE_USER` never implies editing: a test that writes content logs in as an admin. `Security\ContentWriteSecurityTest` is the authority.
- **A `PUT` on a user merges, never replaces** (#426). API Platform's `standard_put` defaults to `true` on 4.x and 5.x: the body is denormalized into a new object and every property is copied onto the stored one, including those nobody can write (password hash, tokens), so they took the constructor's defaults. `UserResourceMetadataCollectionFactory` sets `standard_put: false` on every `Put` of an `AbstractUser`, over any application default. `User\UserPutTest` pins it.
- **`UserNormalizer` handles only a user payload (an array)** (#572). A user referenced by IRI from another resource's write reaches the serializer as a string; running `processChanges()` on it treated the stored user as a new registration and reset `emailAddressVerified`. `User\UserReferenceTest` pins it.
- **A plain password is hashed only after validation, and never verifies the email address** (#570). `UserDataProcessor::processChanges()` does not hash: the API hashes in `UserPasswordStateProcessor` (on `state_processor.locator`, before the persist) and the form listener after `processChanges()`, so the `plainPassword` `Length` (in `Default` as well as `User:password:create`) sees the value on API writes; hashed during denormalization, it was already erased. Only a password reset, which proves the inbox, sets `emailAddressVerified`. `User\UserPasswordTest` pins it.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php

/*
* This file is part of the Silverback API Components Bundle Project
*
* (c) Daniel West <daniel@silverback.is>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource;

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
use Silverback\ApiComponentsBundle\ApiResource\ComponentGroupOrder;
use Silverback\ApiComponentsBundle\AttributeReader\PublishableAttributeReader;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractPageData;
use Silverback\ApiComponentsBundle\Entity\Core\ComponentGroup;
use Silverback\ApiComponentsBundle\Entity\Core\ComponentPosition;
use Silverback\ApiComponentsBundle\Entity\Core\Layout;
use Silverback\ApiComponentsBundle\Entity\Core\Page;
use Silverback\ApiComponentsBundle\Entity\Core\Route;

/**
* @author Daniel West <daniel@silverback.is>
*/
final readonly class ContentWriteSecurityResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface
{
public const string OPT_OUT_EXTRA_PROPERTY = 'publishable_permission';

private const array CONTENT_CLASSES = [
Page::class,
Layout::class,
Route::class,
ComponentGroup::class,
ComponentGroupOrder::class,
ComponentPosition::class,
AbstractComponent::class,
AbstractPageData::class,
];

private const array WRITE_METHODS = [
HttpOperation::METHOD_POST,
HttpOperation::METHOD_PUT,
HttpOperation::METHOD_PATCH,
HttpOperation::METHOD_DELETE,
];

public function __construct(
private ResourceMetadataCollectionFactoryInterface $decorated,
private PublishableAttributeReader $publishableAttributeReader,
private string $permission,
) {
}

public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);

if (!$this->isContent($resourceClass)) {
return $resourceMetadataCollection;
}

$permission = $this->permission($resourceClass);
foreach ($resourceMetadataCollection as $i => $resource) {
$operations = $resource->getOperations();
if (null === $operations) {
continue;
}
foreach ($operations as $key => $operation) {
if (!\in_array($operation->getMethod(), self::WRITE_METHODS, true) || false === ($operation->getExtraProperties()[self::OPT_OUT_EXTRA_PROPERTY] ?? true)) {
continue;
}
$operations->add($key, $operation->withSecurity(self::security($permission, $operation->getSecurity())));
}
$resourceMetadataCollection[$i] = $resource->withOperations($operations);
}

return $resourceMetadataCollection;
}

private function isContent(string $resourceClass): bool
{
foreach (self::CONTENT_CLASSES as $contentClass) {
if (is_a($resourceClass, $contentClass, true)) {
return true;
}
}

return false;
}

private function permission(string $resourceClass): string
{
if (!$this->publishableAttributeReader->isConfigured($resourceClass)) {
return $this->permission;
}

return $this->publishableAttributeReader->getConfiguration($resourceClass)->isGranted ?? $this->permission;
}

private static function security(string $permission, ?string $security): string
{
if (null === $security || '' === $security) {
return $permission;
}

return \sprintf('(%s) and (%s)', $permission, $security);
}
}
4 changes: 4 additions & 0 deletions src/DependencyInjection/SilverbackApiComponentsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

use Doctrine\Persistence\ManagerRegistry;
use Ramsey\Uuid\Doctrine\UuidType;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\ContentWriteSecurityResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\RoutableResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\AttributeReader\UploadableAttributeReader;
use Silverback\ApiComponentsBundle\Command\ImagineGenerateCommand;
Expand Down Expand Up @@ -277,6 +278,9 @@
$definition = $container->findDefinition(RoutableResourceMetadataCollectionFactory::class);
$definition->setArgument('$securityStr', $config['routable_security']);

$definition = $container->findDefinition(ContentWriteSecurityResourceMetadataCollectionFactory::class);
$definition->setArgument('$permission', $config['publishable']['permission']);

Check warning on line 282 in src/DependencyInjection/SilverbackApiComponentsExtension.php

View workflow job for this annotation

GitHub Actions / PHPUnit (Symfony 7.4) (PHP 8.5)

Escaped Mutant for Mutator "MethodCallRemoval": @@ @@ $definition->setArgument('$securityStr', $config['routable_security']); $definition = $container->findDefinition(ContentWriteSecurityResourceMetadataCollectionFactory::class); - $definition->setArgument('$permission', $config['publishable']['permission']); + $definition = $container->findDefinition(MercureAuthorization::class); $definition->setArgument('$cookieSameSite', $config['mercure']['cookie']['samesite']);

$definition = $container->findDefinition(MercureAuthorization::class);
$definition->setArgument('$cookieSameSite', $config['mercure']['cookie']['samesite']);
$definition->setArgument('$hubName', $config['mercure']['hub_name']);
Expand Down
3 changes: 3 additions & 0 deletions src/Entity/Component/Form.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\Metadata\Put;
use Doctrine\ORM\Mapping as ORM;
use Silverback\ApiComponentsBundle\Annotation as Silverback;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\ContentWriteSecurityResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\DataProvider\StateProvider\FormStateProvider;
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
use Silverback\ApiComponentsBundle\Entity\Utility\TimestampedTrait;
Expand Down Expand Up @@ -52,6 +53,7 @@
write: false,
serialize: true,
requirements: ['id' => '[^/]+'],
extraProperties: [ContentWriteSecurityResourceMetadataCollectionFactory::OPT_OUT_EXTRA_PROPERTY => false],
),
new Post(
name: '_api_/forms/{id}/submit{._format}_post',
Expand All @@ -62,6 +64,7 @@
write: false,
serialize: true,
requirements: ['id' => '[^/]+'],
extraProperties: [ContentWriteSecurityResourceMetadataCollectionFactory::OPT_OUT_EXTRA_PROPERTY => false],
),
]
)]
Expand Down
13 changes: 13 additions & 0 deletions src/Resources/config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Property\ComponentPropertyMetadataFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Property\ImagineFiltersPropertyMetadataFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\ComponentResourceMetadataFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\ContentWriteSecurityResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\RoutableResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\RoutingPrefixResourceMetadataCollectionFactory;
use Silverback\ApiComponentsBundle\ApiPlatform\Metadata\Resource\UploadableResourceMetadataCollectionFactory;
Expand Down Expand Up @@ -2051,6 +2052,18 @@
);
$services->alias(UserResourceMetadataCollectionFactory::class, 'silverback.api_components.api_platform.metadata.resource.user_factory');

$services
->set('silverback.api_components.api_platform.metadata.resource.content_write_security_factory')
->class(ContentWriteSecurityResourceMetadataCollectionFactory::class)
->decorate('api_platform.metadata.resource.metadata_collection_factory', null, -1)
->args(
[
new Reference('silverback.api_components.api_platform.metadata.resource.content_write_security_factory.inner'),
new Reference(PublishableAttributeReader::class),
]
);
$services->alias(ContentWriteSecurityResourceMetadataCollectionFactory::class, 'silverback.api_components.api_platform.metadata.resource.content_write_security_factory');

$services
->set(VerifyEmailAddressAction::class)
->public()
Expand Down
Loading
Loading