fix: check WordPress meta capabilities per field key
Fields from the meta/sample sources need edit_post_meta (update, checked in PostWriter::prepare) or add_post_meta (create, checked right after the insert; a refusal deletes the new draft again) for their key; a refusal is a fields.<key> validation error. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -118,6 +118,11 @@ final class ContentCreateAbility implements Ability
|
||||
}
|
||||
|
||||
$postId = (int) $postId;
|
||||
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
|
||||
if ($denied !== []) {
|
||||
return $this->refuse($postId, $postType, $denied);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->writer->writeExtras($postId, $postType, $prepared, $input);
|
||||
} catch (Throwable $e) {
|
||||
@@ -187,6 +192,37 @@ final class ContentCreateAbility implements Ability
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta rights can only be checked once the draft exists (add_post_meta needs its ID): a
|
||||
* refused field removes the brand-new draft again and is reported like any invalid input.
|
||||
*
|
||||
* @param array<string, string> $denied fields.<key> => error
|
||||
* @throws WriteFailed When the draft cannot be deleted.
|
||||
*/
|
||||
private function refuse(int $postId, string $postType, array $denied): WP_Error
|
||||
{
|
||||
try {
|
||||
$deleted = (bool) wp_delete_post($postId, true);
|
||||
} catch (Throwable $e) {
|
||||
$deleted = false;
|
||||
}
|
||||
|
||||
if (! $deleted) {
|
||||
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
|
||||
$this->logPartial($postType, (string) $postId, $summary);
|
||||
throw new WriteFailed(
|
||||
'Keine Berechtigung für ' . implode(', ', array_keys($denied)) . '.',
|
||||
false,
|
||||
$postId,
|
||||
null,
|
||||
null,
|
||||
sprintf('Meta rights denied for new post %d; draft could not be deleted', $postId)
|
||||
);
|
||||
}
|
||||
|
||||
return Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
|
||||
}
|
||||
|
||||
private function logPartial(string $postType, string $postId, string $summary): void
|
||||
{
|
||||
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
|
||||
|
||||
@@ -33,6 +33,11 @@ class PostWriter
|
||||
'template' => 'page_template',
|
||||
];
|
||||
|
||||
/** Field sources that store plain post meta, guarded per key by WordPress' meta capabilities. */
|
||||
private const META_SOURCES = ['meta', 'sample'];
|
||||
|
||||
private const META_DENIED = 'Keine Berechtigung, dieses Feld zu ändern.';
|
||||
|
||||
/** wp_insert_post() ignores an empty page_template; this value selects the default template. */
|
||||
public const DEFAULT_TEMPLATE = 'default';
|
||||
|
||||
@@ -91,6 +96,12 @@ class PostWriter
|
||||
$errors['fields.' . $key] = $message;
|
||||
}
|
||||
|
||||
// On create the entry does not exist yet; lit/content-create checks add_post_meta after the insert.
|
||||
$postId = (int) ($input['post_id'] ?? 0);
|
||||
if (! $isCreate && $postId > 0) {
|
||||
$errors = array_merge($errors, $this->deniedMeta($postId, $postType, $fields['values'], 'edit_post_meta'));
|
||||
}
|
||||
|
||||
$terms = $this->terms->resolve($postType, is_array($input['terms'] ?? null) ? $input['terms'] : []);
|
||||
foreach ($terms['errors'] as $taxonomy => $message) {
|
||||
$errors['terms.' . $taxonomy] = $message;
|
||||
@@ -110,6 +121,33 @@ class PostWriter
|
||||
return ['fields' => $fields['values'], 'terms' => $terms['terms'], 'warnings' => $fields['warnings']];
|
||||
}
|
||||
|
||||
/**
|
||||
* Meta fields (sources meta/sample) the current user may not write: WordPress decides per
|
||||
* key (protected keys, register_meta auth callbacks) via edit_post_meta / add_post_meta.
|
||||
*
|
||||
* @param array<string, mixed> $values Validated field values.
|
||||
* @param string $cap edit_post_meta (update) or add_post_meta (create).
|
||||
* @return array<string, string> fields.<key> => error
|
||||
*/
|
||||
public function deniedMeta(int $postId, string $postType, array $values, string $cap): array
|
||||
{
|
||||
$definitions = $this->schema->fields($postType);
|
||||
$denied = [];
|
||||
foreach (array_keys($values) as $key) {
|
||||
$key = (string) $key;
|
||||
$definition = $definitions[$key] ?? null;
|
||||
if (
|
||||
$definition !== null
|
||||
&& in_array($definition->source, self::META_SOURCES, true)
|
||||
&& ! current_user_can($cap, $postId, $key)
|
||||
) {
|
||||
$denied['fields.' . $key] = self::META_DENIED;
|
||||
}
|
||||
}
|
||||
|
||||
return $denied;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
|
||||
@@ -39,6 +39,7 @@ final class ContentCreateAbilityTest extends TestCase
|
||||
$writer = Mockery::mock(PostWriter::class);
|
||||
$writer->allows('prepare')->andReturn(['fields' => ['role' => 'Leitung'], 'terms' => [], 'warnings' => $warnings]);
|
||||
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
|
||||
$writer->allows('deniedMeta')->andReturn([]);
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
|
||||
return $writer;
|
||||
@@ -85,6 +86,7 @@ final class ContentCreateAbilityTest extends TestCase
|
||||
$writer = Mockery::mock(PostWriter::class);
|
||||
$writer->allows('prepare')->with('lit_person', $input, true)->andReturn($prepared);
|
||||
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
|
||||
$writer->expects('deniedMeta')->with(42, 'lit_person', ['role' => 'Leitung'], 'add_post_meta')->andReturn([]);
|
||||
$writer->expects('writeExtras')->with(42, 'lit_person', $prepared, $input);
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
Functions\expect('wp_insert_post')->once()->with([
|
||||
@@ -126,6 +128,47 @@ final class ContentCreateAbilityTest extends TestCase
|
||||
self::assertSame('lit_write_failed', $failure->toError()->get_error_code());
|
||||
}
|
||||
|
||||
public function testMetaFieldsTheUserMayNotAddAreRefusedAndTheDraftIsRemoved(): void
|
||||
{
|
||||
$writer = Mockery::mock(PostWriter::class);
|
||||
$writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]);
|
||||
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
|
||||
$writer->expects('deniedMeta')->with(42, 'lit_person', ['city' => 'Trier'], 'add_post_meta')
|
||||
->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']);
|
||||
$writer->expects('writeExtras')->never();
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
Functions\when('wp_insert_post')->justReturn(42);
|
||||
Functions\expect('wp_delete_post')->once()->with(42, true)->andReturn(new WP_Post(['ID' => 42]));
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('record')->never();
|
||||
|
||||
$result = (new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT);
|
||||
|
||||
self::assertSame('lit_validation_failed', $result->get_error_code());
|
||||
self::assertArrayHasKey('fields.city', $result->get_error_data()['fields']);
|
||||
}
|
||||
|
||||
public function testARefusedDraftThatCannotBeDeletedIsAPartialWrite(): void
|
||||
{
|
||||
$writer = Mockery::mock(PostWriter::class);
|
||||
$writer->allows('prepare')->andReturn(['fields' => ['city' => 'Trier'], 'terms' => [], 'warnings' => []]);
|
||||
$writer->allows('postData')->andReturn(['post_title' => 'Anna']);
|
||||
$writer->allows('deniedMeta')->andReturn(['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.']);
|
||||
Functions\when('get_current_user_id')->justReturn(3);
|
||||
Functions\when('wp_insert_post')->justReturn(42);
|
||||
Functions\when('wp_delete_post')->justReturn(false);
|
||||
$log = Mockery::mock(ChangeLog::class);
|
||||
$log->expects('recordPartial');
|
||||
|
||||
try {
|
||||
(new ContentCreateAbility($this->schema(), $writer, $log))->execute(self::INPUT);
|
||||
self::fail('WriteFailed expected');
|
||||
} catch (WriteFailed $e) {
|
||||
self::assertFalse($e->rolledBack);
|
||||
self::assertSame(42, $e->postId);
|
||||
}
|
||||
}
|
||||
|
||||
public function testADraftThatCannotBeDeletedIsAPartialWriteAndLogged(): void
|
||||
{
|
||||
$writer = $this->writer();
|
||||
|
||||
@@ -131,6 +131,49 @@ final class PostWriterTest extends TestCase
|
||||
self::assertCount(1, $result['warnings']);
|
||||
}
|
||||
|
||||
public function testMetaFieldsNeedTheRightToEditThatMetaKeyOnUpdate(): void
|
||||
{
|
||||
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('fields')->andReturn([
|
||||
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||||
'secret' => new FieldDefinition('secret', 'secret', 'string', 'meta', 'writable'),
|
||||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
||||
]);
|
||||
$checked = [];
|
||||
Functions\when('current_user_can')->alias(static function (string $cap, ...$args) use (&$checked): bool {
|
||||
$checked[] = array_merge([$cap], $args);
|
||||
return $args !== [5, 'secret'];
|
||||
});
|
||||
|
||||
$result = (new PostWriter($schema, new FieldValidator(), new TermAssigner()))->prepare(
|
||||
'page',
|
||||
['post_id' => 5, 'fields' => ['city' => 'Trier', 'secret' => 'x', 'role' => 'Leitung']],
|
||||
false
|
||||
);
|
||||
|
||||
self::assertSame(['fields.secret' => 'Keine Berechtigung, dieses Feld zu ändern.'], $result->get_error_data()['fields']);
|
||||
self::assertSame([['edit_post_meta', 5, 'city'], ['edit_post_meta', 5, 'secret']], $checked);
|
||||
}
|
||||
|
||||
public function testCreateChecksMetaRightsOnlyOnceTheDraftExists(): void
|
||||
{
|
||||
Functions\when('get_object_taxonomies')->justReturn([]);
|
||||
$schema = Mockery::mock(ContentSchema::class);
|
||||
$schema->allows('fields')->andReturn([
|
||||
'city' => new FieldDefinition('city', 'city', 'unknown', 'sample', 'unknown'),
|
||||
'role' => new FieldDefinition('role', 'Funktion', 'string', 'pods', 'writable'),
|
||||
]);
|
||||
Functions\expect('current_user_can')->once()->with('add_post_meta', 42, 'city')->andReturn(false);
|
||||
$writer = new PostWriter($schema, new FieldValidator(), new TermAssigner());
|
||||
|
||||
self::assertIsArray($writer->prepare('page', ['fields' => ['city' => 'Trier', 'role' => 'x']], true));
|
||||
self::assertSame(
|
||||
['fields.city' => 'Keine Berechtigung, dieses Feld zu ändern.'],
|
||||
$writer->deniedMeta(42, 'page', ['city' => 'Trier', 'role' => 'x'], 'add_post_meta')
|
||||
);
|
||||
}
|
||||
|
||||
public function testPostDataMapsOnlyGivenKeys(): void
|
||||
{
|
||||
$writer = new PostWriter(Mockery::mock(ContentSchema::class), new FieldValidator(), new TermAssigner());
|
||||
|
||||
Reference in New Issue
Block a user