feat(tokens): Master-Tokens nachtraeglich bearbeiten

Bisher erzwang jede Korrektur ein neues Token: ein Schreibfehler im Namen, ein
falsch gewaehlter Identitaetstyp, ein nachtraeglich gebrauchtes Recht. Das ist
nicht nur laestig, sondern gefaehrlich - der neue Wert muss auf alle Maschinen
verteilt werden, und am Ende laeuft das alte weiter, weil eine vergessen wurde.

AENDERBAR ist alles ausser dem Tokenwert selbst, der Nummer, dem Typ und dem
Anlagedatum: Name, Identitaetstyp, freie Identitaet, Projekt, Lizenzschluessel,
Umgebung, Ablaufdatum und die Rechte.

Der Wert bleibt unberuehrt, weil er es muss - er liegt nur als Hash vor und
steht auf fremden Maschinen. Ein "geaenderter" Wert waere ein neues Token mit
alter Nummer. Genau diese Zusage haelt ein eigener Testfall fest: derselbe rohe
Token muss nach der Aenderung weiter funktionieren.

  - TokenManager::updateMasterToken() mit zwei Schranken: leerer Name und
    leere Rechteliste werden abgewiesen. Ein Token ohne Rechte sieht gueltig
    aus und laesst jeden Aufruf mit "unauthorized" scheitern, ohne dass der
    Grund sichtbar waere.
  - Was sich geaendert hat, geht ins Log. Eine stille Rechteaenderung an einem
    Token ist genau das, was man spaeter rekonstruieren will.
  - Dialog in der Token-Verwaltung, erreichbar ueber den Stift in der Zeile.
    Zeigt Nummer und Hinweis, dass der Wert unveraendert bleibt.
  - Haengen Sub-Tokens daran, sagt der Dialog, dass eine Einschraenkung NICHT
    rueckwirkend auf sie durchschlaegt - sie haben ihre Rechte beim Ausstellen
    bekommen. Wer die Kinder beschneiden will, widerruft und stellt neu aus.

$scopeChoices ist aus dem Anlegeformular in den Datenteil gewandert. Beide
Formulare lesen jetzt dieselbe Liste - eine an zwei Stellen gefuehrte Liste
haette genau den Fehler wiederholt, der eben erst behoben wurde: Die
Supervisor-Rechte fehlten monatelang im Formular, obwohl TokenManager sie kennt.

Testlauf 136 Faelle. Da die Bearbeitung bewusst keine API hat - ein Token soll
seine eigenen Rechte nicht erweitern koennen - fuehrt der Weg dorthin ueber
testctl.php?action=update_master_token, wie schon beim Werkbank-Token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Deploymentcenter Bot
2026-08-30 14:04:02 +02:00
co-authored by Claude Opus 5
parent eeda6c911e
commit 57aa0bc5be
5 changed files with 455 additions and 22 deletions
+105
View File
@@ -378,6 +378,105 @@ final class TokenManager
* Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
* Getrennte Platzhalter, da derselbe Parametername sonst HY093 ausloest.
*/
/**
* Aendert die Angaben eines Master-Tokens.
*
* NICHT aenderbar: der Tokenwert selbst, token_id, der Typ und das
* Anlagedatum. Alles Uebrige schon - ein Schreibfehler im Namen oder ein
* nachtraeglich gebrauchtes Recht sollte kein neues Token erzwingen. Wer
* dafuer jedes Mal neu ausstellen muss, verteilt den neuen Wert auf alle
* Maschinen und laesst am Ende das alte Token mitlaufen, weil eine
* vergessen wurde.
*
* Der Wert bleibt unberuehrt, weil er es muss: Er ist nur als Hash
* gespeichert (bzw. daneben im Klartext, wenn security.store_raw_tokens
* gesetzt ist) und auf fremden Maschinen hinterlegt. Ein "geaenderter"
* Tokenwert waere ein neues Token mit alter Nummer.
*
* ZUR TRAGWEITE einer Rechteaenderung: Sub-Tokens erben beim Ausstellen
* und behalten, was sie bekommen haben - eine Einschraenkung hier wirkt
* also nicht rueckwirkend auf sie. Wer die Kinder mit einschraenken will,
* widerruft sie und stellt sie neu aus. Das steht auch so an der
* Oberflaeche.
*
* @param array<string,mixed> $felder
* @return bool false, wenn es das Token nicht gibt oder es kein Master ist
*/
public function updateMasterToken(string $tokenId, array $felder): bool
{
$stmt = $this->db->prepare('SELECT * FROM dc_tokens WHERE token_id = :id AND type = "master"');
$stmt->execute([':id' => $tokenId]);
$vorher = $stmt->fetch();
if (!is_array($vorher)) {
return false;
}
$name = trim((string)($felder['name'] ?? ''));
if ($name === '') {
throw new InvalidArgumentException('Die Token-Bezeichnung darf nicht leer sein.');
}
// Ein Token ohne jedes Recht kann nichts und sieht trotzdem gueltig
// aus - der Aufrufer bekaeme "unauthorized" ohne erkennbaren Grund.
$scopes = self::normalizeScopes($felder['scopes'] ?? null, []);
if ($scopes === []) {
throw new InvalidArgumentException(
'Mindestens ein Recht auswaehlen. Ein Token ohne Rechte laesst jeden '
. 'Aufruf mit "unauthorized" scheitern, ohne dass der Grund sichtbar waere.'
);
}
$update = $this->db->prepare('
UPDATE dc_tokens SET
name = :name,
project_slug = :proj,
license_key = :lic,
owner_type = :owner_type,
owner_identity = :identity,
scopes = :scopes,
environment = :env,
expires_at = :expires
WHERE token_id = :id AND type = "master"
');
$update->execute([
':name' => mb_substr($name, 0, 100),
':proj' => self::nullIfEmpty(self::alsText($felder['project_slug'] ?? null)),
':lic' => self::nullIfEmpty(self::alsText($felder['license_key'] ?? null)),
':owner_type' => in_array($felder['owner_type'] ?? null, self::OWNER_TYPES, true)
? $felder['owner_type'] : (string)$vorher['owner_type'],
':identity' => self::nullIfEmpty(self::alsText($felder['owner_identity'] ?? null)),
':scopes' => json_encode($scopes),
':env' => in_array($felder['environment'] ?? null, self::ENVIRONMENTS, true)
? $felder['environment'] : (string)$vorher['environment'],
':expires' => self::nullIfEmpty(self::alsText($felder['expires_at'] ?? null)),
':id' => $tokenId,
]);
// Was sich geaendert hat, gehoert ins Log - eine stille Rechteaenderung
// an einem Token ist genau das, was man spaeter rekonstruieren will.
$geaendert = [];
foreach (['name', 'project_slug', 'owner_type', 'owner_identity', 'environment', 'expires_at'] as $feld) {
$alt = (string)($vorher[$feld] ?? '');
$neu = (string)($felder[$feld] ?? '');
if ($alt !== $neu) {
$geaendert[$feld] = ['von' => $alt, 'auf' => $neu];
}
}
$alteScopes = self::decodeScopes($vorher['scopes'] ?? null);
if ($alteScopes !== $scopes) {
$geaendert['scopes'] = ['von' => $alteScopes, 'auf' => $scopes];
}
Logger::info('Master-Token geaendert', [
'token_id' => $tokenId,
'geaendert' => $geaendert,
]);
return true;
}
public function revokeToken(string $tokenId): int
{
$stmt = $this->db->prepare('
@@ -544,6 +643,12 @@ final class TokenManager
return $ts !== false && $ts < time();
}
/** Skalar zu String, alles Uebrige zu null - $_POST kann auch Arrays liefern. */
private static function alsText($value): ?string
{
return (is_string($value) || is_numeric($value)) ? (string)$value : null;
}
private static function nullIfEmpty(?string $value): ?string
{
if ($value === null) {