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
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ jobs:
strategy:
fail-fast: false
matrix:
php: ["8.4"]
php: ["8.5"]
name: PHPUnit (PHP ${{ matrix.php }})
steps:
- uses: actions/checkout@v4
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,22 @@

All notable changes to this bundle. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follow [SemVer](https://semver.org/) and come from the Git tags.

## [0.6.0] - Unreleased

Access to `ApiConfiguration` is decided by permission attributes instead of a fixed role, so applications choose who may read, write and authorize configurations. The changes break consumers in the ways listed under "Breaking changes"; the migration is described in the README under [Migrating from 0.5](README.md#migrating-from-05).

### Breaking changes

- **Permission attributes replace the role check.** `Get`, `GetCollection` and `health` check `apiconfig:read`; `Post`, `Put`, `Patch` and `Delete` check `apiconfig:write`; `authorize` checks `apiconfig:authorize` (before: `ROLE_ADMIN` for every operation). The constants are in `Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions` and are tagged `za7_core.permission` for applications that collect them.
- **Without a permission voter every operation answers `403`.** The bundle grants nothing itself; the application needs a voter that grants the three attributes. Denied callers get `403` before any `404`.
- **The browser path of `authorize` is hardened.** `GET /api/admin/api_configurations/{id}/authorize` with `Accept: text/html` checks `apiconfig:authorize` before looking up the configuration and answers `403` when denied, `401` for anonymous callers; before, any authenticated caller got the `303` redirect.
- **`symfony/security-bundle` and `symfony/expression-language` are required** (`require` instead of `require-dev`); the application needs a firewall in front of `/api/admin`.
- **PHP >= 8.5** is required.

### Added

- Tests: an HTTP test kernel under `tests/` (header authentication via `X-Test-Roles`, `X-Test-User` and `X-Test-Auth: anonymous`, a test permission voter, SQLite in memory) that runs the operations through the full request pipeline, including a kernel without a voter.

## [0.5.0] - 2026-10-10

Secure storage and transport of the credentials in `configJson` ([Issue #8](https://github.com/dmstr/api-configuration-bundle/issues/8)) and a slimmer client contract ([Issue #9](https://github.com/dmstr/api-configuration-bundle/issues/9)). The changes break consumers in the ways listed under "Breaking changes". 0.5.0-beta1 contained everything below except the removal of `order[id]` ("Changed").
Expand Down Expand Up @@ -45,4 +61,5 @@ Secure storage and transport of the credentials in `configJson` ([Issue #8](http

See the git history.

[0.6.0]: https://github.com/dmstr/api-configuration-bundle/compare/0.5.0...HEAD
[0.5.0]: https://github.com/dmstr/api-configuration-bundle/compare/0.4.0...0.5.0
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,4 @@ via `.github/workflows/tests.yml`.

## Requirements

PHP >= 8.4.
PHP >= 8.5.
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ Manage external API connections as Doctrine entities.

Changes per version are listed in [CHANGELOG.md](CHANGELOG.md).

## Requirements

PHP >= 8.5 and `symfony/security-bundle` with a firewall in front of `/api/admin`, see [Access](#access).

## API clients

An extension implements `ApiExtensionInterface` (tagged automatically) and builds a client implementing `ApiClientInterface`, which holds only what every source supports. Optional capabilities are separate interfaces; implement those the source really supports, callers check `instanceof`:
Expand All @@ -37,7 +41,43 @@ An extension implements `ApiExtensionInterface` (tagged automatically) and build

### Access

Every operation of the `ApiConfiguration` resource under `/api/admin/api_configurations` — reads and the `health` and `authorize` sub-resources included — requires `ROLE_ADMIN`, because `configJson` holds the credentials for the upstream systems.
Every operation of the `ApiConfiguration` resource under `/api/admin/api_configurations` checks a permission attribute with `is_granted()`, because `configJson` holds the credentials for the upstream systems:

| Permission | Operations |
|---|---|
| `apiconfig:read` | `GET` collection and item, `GET /api/admin/api_configurations/{id}/health` |
| `apiconfig:write` | `POST`, `PUT`, `PATCH`, `DELETE` |
| `apiconfig:authorize` | `GET /api/admin/api_configurations/{id}/authorize`, also the browser path (`Accept: text/html`, answered with a `303` redirect) |

The constants are in `Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions` (`READ`, `WRITE`, `AUTHORIZE`, `all()`). The bundle also registers the three attributes as `za7_core.permission` service tags (attribute `permission`), so applications that collect such tags can offer them for assignment; the bundle itself depends on nothing for this.

The bundle grants none of the permissions itself. A permission voter in the application maps its roles or users to the permissions; without one, every operation answers `403`. Denied callers get `403` before any `404`, anonymous callers `401` from the firewall. Credentials stay masked for every caller, and the `hydra:operation` list of an item only shows the operations the caller is allowed.

#### Migrating from 0.5

0.5 checked a fixed role; 0.6 checks the permissions above, so an application without a voter for them loses all access after the upgrade. A minimal voter granting all three permissions to an administrator role, with `<your-admin-role>` replaced by the role your application uses:

```php
use Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class ApiConfigurationPermissionVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return in_array($attribute, ApiConfigurationPermissions::all(), true);
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
return in_array('<your-admin-role>', $token->getRoleNames(), true);
}
}
```

With autoconfiguration the voter is registered by itself. If the application already has a permission voter driven by a role-to-permission map, add the three attributes to the entry of `<your-admin-role>` instead. Clients that open the authorize URL in a browser need `apiconfig:authorize` as well; before 0.6 any authenticated caller got the redirect.

### Secrets in `configJson`

Expand Down
10 changes: 8 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,27 @@
}
],
"require": {
"php": ">=8.4",
"php": ">=8.5",
"symfony/framework-bundle": "^7.0",
"api-platform/symfony": "^4.0",
"doctrine/orm": "^3.0",
"doctrine/doctrine-bundle": "^2.8",
"dmstr/api-platform-utils-bundle": "*",
"dmstr/openapi-json-schema-bundle": "*",
"opis/json-schema": "^2.3",
"symfony/expression-language": "^7.0|^8.0",
"symfony/security-bundle": "^7.0|^8.0",
"symfony/validator": "^7.0|^8.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
"doctrine/migrations": "^3.9",
"doctrine/dbal": "^3.7",
"ext-pdo_sqlite": "*"
"ext-pdo_sqlite": "*",
"doctrine/doctrine-migrations-bundle": "^3.3",
"symfony/browser-kit": "^7.0|^8.0",
"symfony/http-client": "^7.0|^8.0",
"symfony/yaml": "^7.0|^8.0"
},
"autoload": {
"psr-4": {
Expand Down
10 changes: 10 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ services:
- '../src/Entity/'
- '../src/Extension/'

# Permission attributes of the resource's operations. An application
# (e.g. za7-core) collects the `za7_core.permission` tags to offer them
# for assignment; the bundle itself depends on nothing for it. Replaces
# the definition discovered above (a constants class, never instantiated).
Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions:
tags:
- { name: 'za7_core.permission', permission: 'apiconfig:read' }
- { name: 'za7_core.permission', permission: 'apiconfig:write' }
- { name: 'za7_core.permission', permission: 'apiconfig:authorize' }

# Explicit health-check state provider tag for API Platform
Dmstr\ApiConfiguration\State\ApiConfigurationHealthProvider:
tags:
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
php:
build:
dockerfile_inline: |
FROM php:8.4-cli-alpine
FROM php:8.5-cli-alpine
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_sqlite
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
Expand Down
31 changes: 22 additions & 9 deletions src/Entity/ApiConfiguration.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Dmstr\OpenApiJsonSchema\Attribute\JsonSchema;
use Dmstr\OpenApiJsonSchema\Interface\JsonSchemaProviderInterface;
use Dmstr\OpenApiJsonSchema\OpenApi\JsonFieldSchemaDecorator;
use Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions;
use Dmstr\ApiConfiguration\State\ApiConfigurationAuthorizeProvider;
use Dmstr\ApiConfiguration\State\ApiConfigurationHealthProvider;
use Dmstr\ApiConfiguration\Validator\ApiConfigurationConstraint;
Expand All @@ -37,15 +38,16 @@
#[ORM\HasLifecycleCallbacks]
#[ApiResource(
operations: [
new Get(),
new GetCollection(),
new Post(security: "is_granted('ROLE_ADMIN')"),
new Put(security: "is_granted('ROLE_ADMIN')"),
new Patch(security: "is_granted('ROLE_ADMIN')"),
new Delete(security: "is_granted('ROLE_ADMIN')"),
new Get(security: self::SECURITY_READ),
new GetCollection(security: self::SECURITY_READ),
new Post(security: self::SECURITY_WRITE),
new Put(security: self::SECURITY_WRITE),
new Patch(security: self::SECURITY_WRITE),
new Delete(security: self::SECURITY_WRITE),
new Get(
uriTemplate: '/api_configurations/{id}/health',
name: 'api_configuration_health',
security: self::SECURITY_READ,
provider: ApiConfigurationHealthProvider::class,
// Nested arrays (metadata, error) stay plain objects in JSON-LD
// instead of becoming hydra:Collection (Issue #9)
Expand All @@ -54,13 +56,17 @@
new Get(
uriTemplate: '/api_configurations/{id}/authorize',
name: 'api_configuration_authorize',
security: "is_granted('ROLE_ADMIN')",
security: self::SECURITY_AUTHORIZE,
provider: ApiConfigurationAuthorizeProvider::class,
),
],
routePrefix: '/admin',
// Reads included: configJson holds upstream credentials (Issue #8)
security: "is_granted('ROLE_ADMIN')",
// Every operation names its permission explicitly; this is only the
// fallback for an operation added without one. Reads are included:
// configJson holds upstream credentials (Issue #8). The attributes are
// decided by the application's permission voter, without one every
// operation is denied (see ApiConfigurationPermissions).
security: self::SECURITY_READ,
paginationEnabled: true,
paginationItemsPerPage: 30,
openapi: new Operation(tags: ['System'])
Expand All @@ -75,6 +81,13 @@
#[ApiFilter(OrderFilter::class, properties: ['name', 'type', 'endpointType', 'active', 'createdAt', 'updatedAt'])]
class ApiConfiguration implements JsonSchemaProviderInterface
{
// Security expressions of the operations above. They only check the
// permission attribute, without `object`, so API Platform evaluates them
// before reading the item: a denied caller gets 403, never 404.
private const string SECURITY_READ = "is_granted('" . ApiConfigurationPermissions::READ . "')";
private const string SECURITY_WRITE = "is_granted('" . ApiConfigurationPermissions::WRITE . "')";
private const string SECURITY_AUTHORIZE = "is_granted('" . ApiConfigurationPermissions::AUTHORIZE . "')";

#[ORM\Id]
#[ORM\Column(type: 'uuid', unique: true)]
#[ApiProperty(identifier: true, readable: true, writable: false, description: 'Unique identifier')]
Expand Down
17 changes: 15 additions & 2 deletions src/EventListener/AuthorizeRedirectListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use Dmstr\ApiConfiguration\Entity\ApiConfiguration;
use Dmstr\ApiConfiguration\Extension\AuthorizableExtensionInterface;
use Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions;
use Dmstr\ApiConfiguration\Service\ApiExtensionRegistry;
use Dmstr\ApiPlatformUtils\Service\UuidResolver;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
Expand All @@ -15,6 +16,8 @@
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

/**
* Short-circuits the normal API-Platform pipeline for
Expand All @@ -25,8 +28,14 @@
* JSON-LD (Hydra) and XHR callers keep the regular pipeline → JSON body
* with `authorizeUrl`/`state` from {@see ApiConfigurationAuthorizeProvider}.
*
* Runs after Symfony Security (priority < 8) so the bearer-token check has
* already happened; the listener trusts that the request is authorized.
* Runs after the Symfony firewall (priority < 8), so the token is
* available, but before API Platform's own security check: the listener
* therefore checks the operation's permission
* ({@see ApiConfigurationPermissions::AUTHORIZE}) itself. It does so before
* looking up the configuration, so a denied caller always gets the same
* answer and cannot probe ids by a 404 or 409. A denial throws Symfony's
* {@see AccessDeniedException}, which the firewall turns into 403 for an
* authenticated caller and into its entry point (401) for an anonymous one.
*/
#[AsEventListener(event: KernelEvents::REQUEST, priority: 4)]
final class AuthorizeRedirectListener
Expand All @@ -36,6 +45,7 @@ final class AuthorizeRedirectListener
public function __construct(
private readonly UuidResolver $uuidResolver,
private readonly ApiExtensionRegistry $extensionRegistry,
private readonly AuthorizationCheckerInterface $authorizationChecker,
#[Autowire(param: 'app.oauth.redirect_uri')]
private readonly string $defaultRedirectUri,
) {
Expand All @@ -56,6 +66,9 @@ public function __invoke(RequestEvent $event): void
if (!$this->wantsHtml($request)) {
return;
}
if (!$this->authorizationChecker->isGranted(ApiConfigurationPermissions::AUTHORIZE)) {
throw new AccessDeniedException();
}

$config = $this->uuidResolver->findByPartialUuid(ApiConfiguration::class, $matches[1]);
if (!$config instanceof ApiConfiguration) {
Expand Down
37 changes: 37 additions & 0 deletions src/Security/ApiConfigurationPermissions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php
// file generated with AI assistance: Claude Code - 2026-10-28 07:52:48 UTC

declare(strict_types=1);

namespace Dmstr\ApiConfiguration\Security;

/**
* Permission attributes of the ApiConfiguration resource, checked with
* `is_granted()` on every operation.
*
* The bundle grants none of them itself: they are decided by a permission
* voter of the consuming application. Without such a voter every
* operation is denied.
*
* Registered as a service tagged `za7_core.permission` (see
* `config/services.yaml`), so an application can collect the attributes.
*/
final class ApiConfigurationPermissions
{
/** Read configurations (credentials masked) and their health */
public const string READ = 'apiconfig:read';

/** Create, replace, update and delete configurations */
public const string WRITE = 'apiconfig:write';

/** Start the interactive authorization flow of a configuration */
public const string AUTHORIZE = 'apiconfig:authorize';

/**
* @return list<string>
*/
public static function all(): array
{
return [self::READ, self::WRITE, self::AUTHORIZE];
}
}
65 changes: 49 additions & 16 deletions tests/Entity/ApiConfigurationSecurityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,66 @@
namespace Dmstr\ApiConfiguration\Tests\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use Dmstr\ApiConfiguration\Entity\ApiConfiguration;
use Dmstr\ApiConfiguration\Security\ApiConfigurationPermissions;
use PHPUnit\Framework\TestCase;

/**
* Issue #8: configJson holds upstream credentials, so every operation of the
* resource — reads included — is restricted to ROLE_ADMIN.
* Every operation of the resource names its permission attribute
* explicitly; reads are included because configJson holds upstream
* credentials (Issue #8). The resource-level expression is only a fallback.
*/
final class ApiConfigurationSecurityTest extends TestCase
{
private const string ADMIN = "is_granted('ROLE_ADMIN')";
public function testResourceFallbackRequiresReadPermission(): void
{
self::assertSame(self::expression(ApiConfigurationPermissions::READ), self::resource()->getSecurity());
}

public function testEveryOperationHasExactlyTheExpectedExplicitExpression(): void
{
$expected = [
Get::class . ' ' => ApiConfigurationPermissions::READ,
GetCollection::class . ' ' => ApiConfigurationPermissions::READ,
Post::class . ' ' => ApiConfigurationPermissions::WRITE,
Put::class . ' ' => ApiConfigurationPermissions::WRITE,
Patch::class . ' ' => ApiConfigurationPermissions::WRITE,
Delete::class . ' ' => ApiConfigurationPermissions::WRITE,
Get::class . ' /api_configurations/{id}/health' => ApiConfigurationPermissions::READ,
Get::class . ' /api_configurations/{id}/authorize' => ApiConfigurationPermissions::AUTHORIZE,
];

$actual = [];
foreach (self::resource()->getOperations() ?? [] as $operation) {
self::assertNotNull($operation->getSecurity(), self::label($operation) . ' has no own security');
$actual[self::label($operation)] = $operation->getSecurity();
}

self::assertSame(array_map(self::expression(...), $expected), $actual);
}

public function testEveryOperationRequiresAdmin(): void
public static function resource(): ApiResource
{
$attributes = (new \ReflectionClass(ApiConfiguration::class))->getAttributes(ApiResource::class);
self::assertCount(1, $attributes);

/** @var ApiResource $resource */
$resource = $attributes[0]->newInstance();
self::assertSame(self::ADMIN, $resource->getSecurity());

foreach ($resource->getOperations() ?? [] as $operation) {
// An operation without its own expression inherits the resource's
self::assertContains(
$operation->getSecurity(),
[null, self::ADMIN],
sprintf('%s %s', $operation->getMethod(), $operation->getUriTemplate() ?? $operation::class),
);
}
return $attributes[0]->newInstance();
}

private static function label(Operation $operation): string
{
return $operation::class . ' ' . ($operation->getUriTemplate() ?? '');
}

private static function expression(string $permission): string
{
return sprintf("is_granted('%s')", $permission);
}
}
Loading
Loading