refactor: integration labels, AdminPostAction, ExposurePolicy key parsing (M9)

Integrations name their own admin group (Integration::label, registry
labels()); ToolsTab no longer hard-codes ids and explains why it re-applies
withGroups. AccessTab and LogTab share AdminPostAction (guard, redirect,
terminate seam). ExposurePolicy parses option keys in one place, has one
isRequired() check and a clear $groups docblock (null vs []).

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:
2026-09-26 18:31:15 +02:00
co-authored by Claude Opus 5.5
parent 4cb18d17c3
commit dca1bd67f8
13 changed files with 170 additions and 54 deletions
+44 -16
View File
@@ -47,7 +47,12 @@ final class ExposurePolicy
/** @var array<string, true> Group ids switched to "nur lesen". */
private array $readonlyGroups = [];
/** @var array<string, string>|null Ability name => group id; null until resolved. */
/**
* Ability name => group id. [] = no groups known (withGroups() not called: group entries have
* no effect); null = a lazy $groupSource is set and not resolved yet (groupOf() resolves it).
*
* @var array<string, string>|null
*/
private ?array $groups = [];
/** @var (callable(): array<string, string>)|null Lazy source of $groups. */
@@ -60,17 +65,17 @@ final class ExposurePolicy
public function __construct(array $overrides = [])
{
foreach ($overrides as $key => $value) {
$key = (string) $key;
if (str_starts_with($key, self::GROUP_PREFIX)) {
['kind' => $kind, 'id' => $id] = self::parseKey((string) $key);
if ($kind === 'group') {
if (is_string($value) && in_array($value, self::MODES, true)) {
$this->groupModes[substr($key, strlen(self::GROUP_PREFIX))] = $value;
$this->groupModes[$id] = $value;
}
} elseif (str_starts_with($key, self::READONLY_PREFIX)) {
} elseif ($kind === 'readonly') {
if ($value === true) {
$this->readonlyGroups[substr($key, strlen(self::READONLY_PREFIX))] = true;
$this->readonlyGroups[$id] = true;
}
} elseif (is_string($value)) {
$this->tools[$key] = $value;
$this->tools[$id] = $value;
}
}
}
@@ -103,7 +108,7 @@ final class ExposurePolicy
public function mode(Ability $ability): string
{
if (in_array($ability->name(), self::REQUIRED, true)) {
if (self::isRequired($ability->name())) {
return $this->defaultMode($ability);
}
@@ -119,7 +124,7 @@ final class ExposurePolicy
{
$name = $ability->name();
$groupMode = $this->groupModeOf($name);
if ($groupMode === null || in_array($name, self::REQUIRED, true)) {
if ($groupMode === null || self::isRequired($name)) {
return $this->defaultMode($ability);
}
@@ -133,7 +138,7 @@ final class ExposurePolicy
$groupMode = $this->groupModeOf($name);
return $groupMode !== null
&& ! in_array($name, self::REQUIRED, true)
&& ! self::isRequired($name)
&& ! self::isAllowed($name, $groupMode);
}
@@ -145,7 +150,7 @@ final class ExposurePolicy
return false;
}
if (in_array($ability->name(), self::REQUIRED, true)) {
if (self::isRequired($ability->name())) {
return false;
}
@@ -213,7 +218,7 @@ final class ExposurePolicy
return false;
}
if (in_array($name, self::REQUIRED, true)) {
if (self::isRequired($name)) {
return $mode === self::DIRECT;
}
@@ -244,17 +249,18 @@ final class ExposurePolicy
continue;
}
if (str_starts_with($key, self::GROUP_PREFIX)) {
['kind' => $kind, 'id' => $id] = self::parseKey($key);
if ($kind === 'group') {
if (
self::isGroupId(substr($key, strlen(self::GROUP_PREFIX)), $groupIds)
self::isGroupId($id, $groupIds)
&& is_string($value)
&& in_array($value, self::MODES, true)
) {
$clean[$key] = $value;
}
} elseif (str_starts_with($key, self::READONLY_PREFIX)) {
} elseif ($kind === 'readonly') {
if (
self::isGroupId(substr($key, strlen(self::READONLY_PREFIX)), $groupIds)
self::isGroupId($id, $groupIds)
&& ($value === true || $value === '1' || $value === 1)
) {
$clean[$key] = true;
@@ -272,6 +278,28 @@ final class ExposurePolicy
return $clean;
}
/**
* What an option key names: "@group:<id>" a group's mode, "@readonly:<id>" a group's
* read-only switch, anything else an ability (id = the whole key).
*
* @return array{kind: 'group'|'readonly'|'tool', id: string}
*/
private static function parseKey(string $key): array
{
foreach (['group' => self::GROUP_PREFIX, 'readonly' => self::READONLY_PREFIX] as $kind => $prefix) {
if (str_starts_with($key, $prefix)) {
return ['kind' => $kind, 'id' => substr($key, strlen($prefix))];
}
}
return ['kind' => 'tool', 'id' => $key];
}
private static function isRequired(string $name): bool
{
return in_array($name, self::REQUIRED, true);
}
/** @param list<string>|null $groupIds */
private static function isGroupId(string $id, ?array $groupIds): bool
{