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:
co-authored by
Claude Opus 5
parent
eeda6c911e
commit
57aa0bc5be
+1
-1
@@ -15,7 +15,7 @@ Monitoring und einen Bugtracker, den Coding-Agenten selbständig bedienen.
|
|||||||
| **[Änderungen](../public/docs/changelog.json)** | Was sich je Fassung geändert hat und was zu tun ist. Öffentlich unter `/docs/changelog.php`, maschinenlesbar über `GET /api/updateservice/v1/changelog?since=X` |
|
| **[Änderungen](../public/docs/changelog.json)** | Was sich je Fassung geändert hat und was zu tun ist. Öffentlich unter `/docs/changelog.php`, maschinenlesbar über `GET /api/updateservice/v1/changelog?since=X` |
|
||||||
| **[Release-Anleitung für Agenten](../public/docs/release.md)** | Ein Projekt veröffentlichungsfähig machen: Vorlage kopieren, konfigurieren, ausliefern |
|
| **[Release-Anleitung für Agenten](../public/docs/release.md)** | Ein Projekt veröffentlichungsfähig machen: Vorlage kopieren, konfigurieren, ausliefern |
|
||||||
| **[Linux-Testumgebung](./TESTUMGEBUNG.md)** | **Die geteilte Test-VM.** Wie ein Agent dort selbständig testet: DEV-Lizenz holen, Auslieferung prüfen, Befunde melden — mit `dc-testkit` |
|
| **[Linux-Testumgebung](./TESTUMGEBUNG.md)** | **Die geteilte Test-VM.** Wie ein Agent dort selbständig testet: DEV-Lizenz holen, Auslieferung prüfen, Befunde melden — mit `dc-testkit` |
|
||||||
| **[Testinstanz & automatisierter Testlauf](./TESTINSTANZ.md)** | **`dc-dev.mhdf.de` — die zweite Installation zum Kaputtmachen.** 129 Fälle gegen API, Rechte, Migrationen und Zugangssperre (`tests/e2e/run.py`). **`scripts/deploy.py` führt sie vor jedem Produktiv-Upload aus** und bricht bei Fehlschlägen ab (`--ohne-test` umgeht das) |
|
| **[Testinstanz & automatisierter Testlauf](./TESTINSTANZ.md)** | **`dc-dev.mhdf.de` — die zweite Installation zum Kaputtmachen.** 136 Fälle gegen API, Rechte, Migrationen und Zugangssperre (`tests/e2e/run.py`). **`scripts/deploy.py` führt sie vor jedem Produktiv-Upload aus** und bricht bei Fehlschlägen ab (`--ohne-test` umgeht das) |
|
||||||
| **[Agenten anbinden](./AGENTEN_ANBINDEN.md)** | **Was einmalig je Projekt zu tun ist**, damit ein Agent Bugtracker, Roadmap und MCP sinnvoll benutzt: Token, Adapter, Bausteine, Roadmap-Übernahme — mit Prüffragen am Ende |
|
| **[Agenten anbinden](./AGENTEN_ANBINDEN.md)** | **Was einmalig je Projekt zu tun ist**, damit ein Agent Bugtracker, Roadmap und MCP sinnvoll benutzt: Token, Adapter, Bausteine, Roadmap-Übernahme — mit Prüffragen am Ende |
|
||||||
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Die Textbausteine selbst. **A** Grundhaltung (immer), **B** mit MCP, **C** ohne MCP, **D** Test-VM — nicht alles einkopieren |
|
| [Agent-Prompt-Vorlage](./AGENT_PROMPT_TEMPLATE.md) | Die Textbausteine selbst. **A** Grundhaltung (immer), **B** mit MCP, **C** ohne MCP, **D** Test-VM — nicht alles einkopieren |
|
||||||
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
|
| [Agenten-Handbuch](../public/docs/bugtracker.md) | Vollständige Beschreibung des Bugtracker-Workflows, öffentlich unter `/docs/` |
|
||||||
|
|||||||
+226
-20
@@ -959,6 +959,39 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|||||||
dc_redirect('#tab-tokens');
|
dc_redirect('#tab-tokens');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'edit_master_token': {
|
||||||
|
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||||||
|
$scopes = $_POST['scopes'] ?? [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ok = (new CoreTokenManager($pdo))->updateMasterToken($tokenId, [
|
||||||
|
'name' => $_POST['name'] ?? '',
|
||||||
|
'project_slug' => $_POST['project_slug'] ?? '',
|
||||||
|
'license_key' => $_POST['license_key'] ?? '',
|
||||||
|
'owner_type' => $_POST['owner_type'] ?? '',
|
||||||
|
'owner_identity' => $_POST['owner_identity'] ?? '',
|
||||||
|
'environment' => $_POST['environment'] ?? '',
|
||||||
|
// Das Datumsfeld liefert nur den Tag; das Token soll
|
||||||
|
// an diesem Tag noch gelten und erst danach ablaufen.
|
||||||
|
'expires_at' => !empty($_POST['expires_at'])
|
||||||
|
? $_POST['expires_at'] . ' 23:59:59' : '',
|
||||||
|
'scopes' => is_array($scopes) ? $scopes : [],
|
||||||
|
]);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
dc_flash($e->getMessage(), 'danger');
|
||||||
|
dc_redirect('#tab-tokens');
|
||||||
|
}
|
||||||
|
|
||||||
|
dc_flash(
|
||||||
|
$ok
|
||||||
|
? 'Token-Angaben gespeichert. Der Tokenwert selbst ist unveraendert — '
|
||||||
|
. 'auf den Maschinen muss nichts ausgetauscht werden.'
|
||||||
|
: 'Master-Token nicht gefunden.',
|
||||||
|
$ok ? 'success' : 'danger'
|
||||||
|
);
|
||||||
|
dc_redirect('#tab-tokens');
|
||||||
|
}
|
||||||
|
|
||||||
case 'revoke_core_token': {
|
case 'revoke_core_token': {
|
||||||
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
$tokenId = trim((string)($_POST['token_id'] ?? ''));
|
||||||
if ($tokenId !== '') {
|
if ($tokenId !== '') {
|
||||||
@@ -2085,6 +2118,33 @@ function rmBadge(string $status): string
|
|||||||
return '<span class="badge ' . $class . '">' . $marker . ' ' . e($label) . '</span>';
|
return '<span class="badge ' . $class . '">' . $marker . ' ' . e($label) . '</span>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Die auswaehlbaren Rechte - EINE Liste fuer Anlegen und Bearbeiten.
|
||||||
|
//
|
||||||
|
// Vorher stand sie im Anlegeformular. Genau deshalb fehlten die
|
||||||
|
// Supervisor-Rechte monatelang: Wer eine Liste an zwei Stellen fuehrt,
|
||||||
|
// pflegt eine davon nicht. Ein zweites Formular waere die zweite Stelle
|
||||||
|
// gewesen.
|
||||||
|
$scopeChoices = [
|
||||||
|
'*' => ['⚠️ Alle Rechte', 'Uneingeschränkt — auch Releases veröffentlichen und Tokens ausstellen. Nicht an einen Agenten geben.'],
|
||||||
|
'bugtracker:manage' => ['⚙️ Bugtracker verwalten', 'Übernehmen, kommentieren, schließen — schließt Lesen und Melden ein'],
|
||||||
|
'bugtracker:report' => ['🐛 Bugtracker melden', 'Nur Einträge anlegen — schließt Lesen ein'],
|
||||||
|
'bugtracker:read' => ['👁️ Bugtracker lesen', 'Nur Abfragen'],
|
||||||
|
'watchdog:ping' => ['🛡️ Watchdog Heartbeat', 'Statusmeldungen senden'],
|
||||||
|
'watchdog:read' => ['📊 Watchdog lesen', 'Monitore und Ereignisse abfragen'],
|
||||||
|
// Die Supervisor-Rechte fehlten hier, obwohl TokenManager
|
||||||
|
// sie seit 2.6.0 kennt. Folge: Ein Werkbank-Token liess
|
||||||
|
// sich ueber die Oberflaeche gar nicht vollstaendig
|
||||||
|
// ausstellen - dc_findings und dc_metrics im MCP-Adapter
|
||||||
|
// liefen ins unauthorized, und der Grund war nirgends zu
|
||||||
|
// sehen.
|
||||||
|
'supervisor:read' => ['🔎 Befunde lesen', 'Supervisor-Befunde abfragen — braucht ein Werkbank-Token für dc_findings'],
|
||||||
|
'supervisor:manage' => ['🔬 Befunde einstufen', 'Befunde abhaken und bewerten — schließt Lesen ein'],
|
||||||
|
'supervisor:report' => ['📡 Befunde melden', 'Nur einliefern, etwa aus einem Prüfwerkzeug — schließt Lesen ein'],
|
||||||
|
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
|
||||||
|
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
|
||||||
|
'license:dev' => ['🧪 DEV-Lizenz abholen', 'Befristeten Testschlüssel selbst ausstellen — nur für Werkbank-Token auf Testmaschinen'],
|
||||||
|
];
|
||||||
|
|
||||||
// Agenten-Uebersicht: wer bearbeitet gerade was
|
// Agenten-Uebersicht: wer bearbeitet gerade was
|
||||||
$agentWorkload = $pdo->query('
|
$agentWorkload = $pdo->query('
|
||||||
SELECT claimed_by,
|
SELECT claimed_by,
|
||||||
@@ -5303,6 +5363,19 @@ Antwort:
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div style="display:flex; gap:0.3rem;">
|
<div style="display:flex; gap:0.3rem;">
|
||||||
|
<button type="button" class="btn btn-sm btn-secondary" title="Angaben ändern"
|
||||||
|
onclick='openTokenEdit(<?= htmlspecialchars(json_encode([
|
||||||
|
"token_id" => (string)$mTok["token_id"],
|
||||||
|
"name" => (string)$mTok["name"],
|
||||||
|
"owner_type" => (string)$mTok["owner_type"],
|
||||||
|
"owner_identity" => (string)($mTok["owner_identity"] ?? ""),
|
||||||
|
"project_slug" => (string)($mTok["project_slug"] ?? ""),
|
||||||
|
"license_key" => (string)($mTok["license_key"] ?? ""),
|
||||||
|
"environment" => (string)$mTok["environment"],
|
||||||
|
"expires_at" => substr((string)($mTok["expires_at"] ?? ""), 0, 10),
|
||||||
|
"scopes" => $scopesArr,
|
||||||
|
"subs" => (int)($mTok["sub_token_count"] ?? 0),
|
||||||
|
], JSON_UNESCAPED_UNICODE), ENT_QUOTES) ?>)'>✏️</button>
|
||||||
<?php if (!$mTok['revoked']): ?>
|
<?php if (!$mTok['revoked']): ?>
|
||||||
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
|
<form method="POST" action="index.php#tab-tokens" style="display:inline" onsubmit="return confirm('Master-Token widerrufen? Alle zugehörigen Sub-Tokens werden sofort kaskadierend mit gesperrt!');">
|
||||||
<?= $csrfField ?>
|
<?= $csrfField ?>
|
||||||
@@ -5399,6 +5472,113 @@ Antwort:
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- =============== Dialog: Master-Token bearbeiten =============== -->
|
||||||
|
<!--
|
||||||
|
Alles ausser dem Tokenwert selbst und der Nummer. Zuvor
|
||||||
|
erzwang ein Schreibfehler im Namen oder ein nachtraeglich
|
||||||
|
gebrauchtes Recht ein komplett neues Token - mit der Folge,
|
||||||
|
dass der neue Wert auf alle Maschinen verteilt werden muss
|
||||||
|
und am Ende das alte mitlaeuft, weil eine vergessen wurde.
|
||||||
|
-->
|
||||||
|
<div id="tokenEditModal" style="display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.75); backdrop-filter:blur(8px); z-index:9999; align-items:center; justify-content:center; padding:1rem;">
|
||||||
|
<div class="card" style="max-width:820px; width:100%; max-height:90vh; overflow-y:auto; margin:0;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2 class="card-title">✏️ Token bearbeiten — <span id="tokEditName"></span></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="font-size:0.82rem; color:var(--text-muted); margin-bottom:1rem;">
|
||||||
|
Der <strong>Tokenwert</strong> und die <strong>Nummer</strong>
|
||||||
|
(<code id="tokEditIdText"></code>) bleiben unverändert — auf den
|
||||||
|
Maschinen muss nichts ausgetauscht werden.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tokEditSubHinweis" style="display:none; background:rgba(245,166,35,0.10); border-left:3px solid var(--warning); border-radius:8px; padding:0.75rem 1rem; margin-bottom:1rem; font-size:0.83rem;">
|
||||||
|
An diesem Token hängen <strong><span id="tokEditSubCount"></span> Sub-Tokens</strong>.
|
||||||
|
Sie haben ihre Rechte beim Ausstellen bekommen und behalten sie —
|
||||||
|
eine Einschränkung hier wirkt <em>nicht</em> rückwirkend auf sie.
|
||||||
|
Wer auch die Kinder beschneiden will, widerruft sie und stellt sie neu aus.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="index.php#tab-tokens">
|
||||||
|
<?= $csrfField ?>
|
||||||
|
<input type="hidden" name="action" value="edit_master_token">
|
||||||
|
<input type="hidden" name="token_id" id="tokEditId">
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Bezeichnung / Name</label>
|
||||||
|
<input type="text" name="name" id="tokEditNameInput" class="form-input" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Identitätstyp (Owner Type)</label>
|
||||||
|
<select name="owner_type" id="tokEditOwnerType" class="form-input" required>
|
||||||
|
<option value="custom">Custom / Allgemein</option>
|
||||||
|
<option value="project">Projekt-Gebunden</option>
|
||||||
|
<option value="license">Lizenz-Gebunden</option>
|
||||||
|
<option value="host">Host / Infrastruktur Server</option>
|
||||||
|
<option value="dev_agent">Entwickler KI-Agent</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Freie Identität / Host / HWID</label>
|
||||||
|
<input type="text" name="owner_identity" id="tokEditIdentity" class="form-input">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Projekt</label>
|
||||||
|
<select name="project_slug" id="tokEditProject" class="form-input">
|
||||||
|
<option value="">-- Keins (Universal) --</option>
|
||||||
|
<?php foreach ($projects as $p): ?>
|
||||||
|
<option value="<?= e($p['slug']) ?>"><?= e($p['name']) ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Umgebung</label>
|
||||||
|
<select name="environment" id="tokEditEnv" class="form-input" required>
|
||||||
|
<option value="all">🌐 Alle Umgebungen</option>
|
||||||
|
<option value="production">🔴 Nur Produktiv</option>
|
||||||
|
<option value="development">🔵 Nur Entwicklung</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Gültig bis (leer = unbefristet)</label>
|
||||||
|
<input type="date" name="expires_at" id="tokEditExpires" class="form-input">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top:0.5rem;">
|
||||||
|
<label class="form-label">Lizenzschlüssel (nur bei Lizenz-Bindung)</label>
|
||||||
|
<input type="text" name="license_key" id="tokEditLicense" class="form-input"
|
||||||
|
placeholder="XXXXX-XXXXX-XXXXX-XXXXX-XXXXX">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" style="margin-top:1rem;">
|
||||||
|
<label class="form-label">Erlaubte Rechte (Scopes)</label>
|
||||||
|
<p style="font-size:0.75rem; color:var(--text-muted); margin:0.25rem 0 0.6rem 0;">
|
||||||
|
Mindestens eines. Stärkere Rechte schließen die schwächeren ein.
|
||||||
|
</p>
|
||||||
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
||||||
|
<?php foreach ($scopeChoices as $value => $labels): ?>
|
||||||
|
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
||||||
|
<input type="checkbox" name="scopes[]" value="<?= e($value) ?>"
|
||||||
|
class="tok-edit-scope" style="margin-top:3px;">
|
||||||
|
<span>
|
||||||
|
<strong><?= e($labels[0]) ?></strong>
|
||||||
|
<div style="font-size:0.75rem; color:var(--text-muted);"><?= e($labels[1]) ?></div>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex; justify-content:flex-end; gap:0.75rem; margin-top:1.25rem;">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="closeTokenEdit()">Abbrechen</button>
|
||||||
|
<button type="submit" class="btn">Speichern</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="sub-tokens-create" class="subtab-content">
|
<div id="sub-tokens-create" class="subtab-content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><h2 class="card-title">➕ Neuen Token erstellen</h2></div>
|
<div class="card-header"><h2 class="card-title">➕ Neuen Token erstellen</h2></div>
|
||||||
@@ -5470,26 +5650,6 @@ Antwort:
|
|||||||
</p>
|
</p>
|
||||||
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap:0.5rem;">
|
||||||
<?php
|
<?php
|
||||||
$scopeChoices = [
|
|
||||||
'*' => ['⚠️ Alle Rechte', 'Uneingeschränkt — auch Releases veröffentlichen und Tokens ausstellen. Nicht an einen Agenten geben.'],
|
|
||||||
'bugtracker:manage' => ['⚙️ Bugtracker verwalten', 'Übernehmen, kommentieren, schließen — schließt Lesen und Melden ein'],
|
|
||||||
'bugtracker:report' => ['🐛 Bugtracker melden', 'Nur Einträge anlegen — schließt Lesen ein'],
|
|
||||||
'bugtracker:read' => ['👁️ Bugtracker lesen', 'Nur Abfragen'],
|
|
||||||
'watchdog:ping' => ['🛡️ Watchdog Heartbeat', 'Statusmeldungen senden'],
|
|
||||||
'watchdog:read' => ['📊 Watchdog lesen', 'Monitore und Ereignisse abfragen'],
|
|
||||||
// Die Supervisor-Rechte fehlten hier, obwohl TokenManager
|
|
||||||
// sie seit 2.6.0 kennt. Folge: Ein Werkbank-Token liess
|
|
||||||
// sich ueber die Oberflaeche gar nicht vollstaendig
|
|
||||||
// ausstellen - dc_findings und dc_metrics im MCP-Adapter
|
|
||||||
// liefen ins unauthorized, und der Grund war nirgends zu
|
|
||||||
// sehen.
|
|
||||||
'supervisor:read' => ['🔎 Befunde lesen', 'Supervisor-Befunde abfragen — braucht ein Werkbank-Token für dc_findings'],
|
|
||||||
'supervisor:manage' => ['🔬 Befunde einstufen', 'Befunde abhaken und bewerten — schließt Lesen ein'],
|
|
||||||
'supervisor:report' => ['📡 Befunde melden', 'Nur einliefern, etwa aus einem Prüfwerkzeug — schließt Lesen ein'],
|
|
||||||
'updateservice:read' => ['📦 Updates prüfen', 'Auf neue Releases prüfen'],
|
|
||||||
'updateservice:publish' => ['🚀 Releases veröffentlichen', 'Neue Releases einstellen — schließt Prüfen ein'],
|
|
||||||
'license:dev' => ['🧪 DEV-Lizenz abholen', 'Befristeten Testschlüssel selbst ausstellen — nur für Werkbank-Token auf Testmaschinen'],
|
|
||||||
];
|
|
||||||
foreach ($scopeChoices as $value => $labels): ?>
|
foreach ($scopeChoices as $value => $labels): ?>
|
||||||
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
<label style="display:flex; align-items:flex-start; gap:0.5rem; font-size:0.85rem; cursor:pointer; background:rgba(255,255,255,0.03); border:1px solid var(--border-glass); border-radius:10px; padding:0.6rem 0.75rem;">
|
||||||
<?php
|
<?php
|
||||||
@@ -6109,6 +6269,52 @@ SYSTEM
|
|||||||
// GET-Parameter - das skaliert, ueberlebt den Auto-Refresh und laesst
|
// GET-Parameter - das skaliert, ueberlebt den Auto-Refresh und laesst
|
||||||
// sich als Link teilen.
|
// sich als Link teilen.
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
// Token bearbeiten
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fuellt den Dialog aus der Zeile, die geklickt wurde.
|
||||||
|
*
|
||||||
|
* Die Angaben kommen als JSON aus dem PHP-Teil mit, statt sie
|
||||||
|
* nachzuladen: Sie stehen ohnehin schon in der Tabelle, und ein
|
||||||
|
* zusaetzlicher Endpunkt waere eine weitere Stelle, an der die
|
||||||
|
* Rechtepruefung sitzen muesste.
|
||||||
|
*/
|
||||||
|
function openTokenEdit(tok) {
|
||||||
|
document.getElementById('tokEditId').value = tok.token_id;
|
||||||
|
document.getElementById('tokEditIdText').innerText = tok.token_id;
|
||||||
|
document.getElementById('tokEditName').innerText = tok.name;
|
||||||
|
document.getElementById('tokEditNameInput').value = tok.name;
|
||||||
|
document.getElementById('tokEditOwnerType').value = tok.owner_type;
|
||||||
|
document.getElementById('tokEditIdentity').value = tok.owner_identity || '';
|
||||||
|
document.getElementById('tokEditProject').value = tok.project_slug || '';
|
||||||
|
document.getElementById('tokEditLicense').value = tok.license_key || '';
|
||||||
|
document.getElementById('tokEditEnv').value = tok.environment;
|
||||||
|
document.getElementById('tokEditExpires').value = tok.expires_at || '';
|
||||||
|
|
||||||
|
const gesetzt = new Set(tok.scopes || []);
|
||||||
|
document.querySelectorAll('.tok-edit-scope').forEach(feld => {
|
||||||
|
feld.checked = gesetzt.has(feld.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Der Hinweis zu den Sub-Tokens nur dort, wo es welche gibt -
|
||||||
|
// sonst stumpft er ab und wird ueberlesen.
|
||||||
|
const hinweis = document.getElementById('tokEditSubHinweis');
|
||||||
|
if (tok.subs > 0) {
|
||||||
|
document.getElementById('tokEditSubCount').innerText = tok.subs;
|
||||||
|
hinweis.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
hinweis.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('tokenEditModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTokenEdit() {
|
||||||
|
document.getElementById('tokenEditModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Roadmap
|
// Roadmap
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -378,6 +378,105 @@ final class TokenManager
|
|||||||
* Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
|
* Widerruft ein Token und alle davon abgeleiteten Sub-Tokens.
|
||||||
* Getrennte Platzhalter, da derselbe Parametername sonst HY093 ausloest.
|
* 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
|
public function revokeToken(string $tokenId): int
|
||||||
{
|
{
|
||||||
$stmt = $this->db->prepare('
|
$stmt = $this->db->prepare('
|
||||||
@@ -544,6 +643,12 @@ final class TokenManager
|
|||||||
return $ts !== false && $ts < time();
|
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
|
private static function nullIfEmpty(?string $value): ?string
|
||||||
{
|
{
|
||||||
if ($value === null) {
|
if ($value === null) {
|
||||||
|
|||||||
@@ -246,6 +246,90 @@ def _(ctx):
|
|||||||
erwarte_fehler(r, http=(401, 403))
|
erwarte_fehler(r, http=(401, 403))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Token nachtraeglich aendern
|
||||||
|
#
|
||||||
|
# Bis 2.7.0 ging das nicht: Ein Schreibfehler im Namen oder ein nachtraeglich
|
||||||
|
# gebrauchtes Recht erzwang ein komplett neues Token - mit der Folge, dass der
|
||||||
|
# neue Wert auf alle Maschinen verteilt werden muss und am Ende das alte
|
||||||
|
# mitlaeuft, weil eine vergessen wurde.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@test('token', 'Aenderbares Token laesst sich anlegen')
|
||||||
|
def _(ctx):
|
||||||
|
r = ctx.testctl('workbench_token', project_slug='myapp', name='vorher')
|
||||||
|
erwarte_erfolg(r)
|
||||||
|
ctx['edit_token_id'] = r.feld('token_id')
|
||||||
|
ctx['edit_token_raw'] = r.feld('raw_token')
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Name und Rechte lassen sich nachtraeglich aendern')
|
||||||
|
def _(ctx):
|
||||||
|
r = ctx.testctl('update_master_token',
|
||||||
|
token_id=ctx['edit_token_id'],
|
||||||
|
name='nachher',
|
||||||
|
owner_type='dev_agent',
|
||||||
|
project_slug='myapp',
|
||||||
|
environment='development',
|
||||||
|
scopes=['bugtracker:read', 'supervisor:read'])
|
||||||
|
erwarte_erfolg(r)
|
||||||
|
erwarte(r.feld('token', 'name') == 'nachher',
|
||||||
|
'Name nicht uebernommen: %r' % r.feld('token', 'name'))
|
||||||
|
erwarte(r.feld('token', 'environment') == 'development',
|
||||||
|
'Umgebung nicht uebernommen: %r' % r.feld('token', 'environment'))
|
||||||
|
erwarte('supervisor:read' in (r.feld('token', 'scopes') or ''),
|
||||||
|
'Rechte nicht uebernommen: %r' % r.feld('token', 'scopes'))
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Der Tokenwert bleibt bei einer Aenderung derselbe')
|
||||||
|
def _(ctx):
|
||||||
|
"""
|
||||||
|
Die eigentliche Zusage der Funktion. Waere der Wert danach ein anderer,
|
||||||
|
muessten alle Maschinen neu bestueckt werden - dann koennte man auch
|
||||||
|
gleich ein neues Token ausstellen, und die Bearbeitung waere sinnlos.
|
||||||
|
"""
|
||||||
|
r = ctx.client.get('/api/bugtracker/v1/projects', token=ctx['edit_token_raw'])
|
||||||
|
erwarte_erfolg(r)
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Entzogene Rechte greifen sofort')
|
||||||
|
def _(ctx):
|
||||||
|
"""
|
||||||
|
Die Gegenprobe: Der Wert bleibt, die Rechte nicht. Das Token hatte
|
||||||
|
bugtracker:manage und hat es nach der Aenderung nicht mehr.
|
||||||
|
"""
|
||||||
|
r = ctx.client.post('/api/bugtracker/v1/manage',
|
||||||
|
query={'action': 'next'},
|
||||||
|
token=ctx['edit_token_raw'],
|
||||||
|
body={'limit': 1})
|
||||||
|
erwarte_fehler(r, http=(401, 403))
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Aenderung ohne Rechte wird abgewiesen')
|
||||||
|
def _(ctx):
|
||||||
|
"""
|
||||||
|
Ein Token ohne jedes Recht sieht gueltig aus und kann nichts - der
|
||||||
|
Aufrufer bekaeme "unauthorized" ohne erkennbaren Grund.
|
||||||
|
"""
|
||||||
|
r = ctx.testctl('update_master_token', token_id=ctx['edit_token_id'],
|
||||||
|
name='ohne rechte', scopes=[])
|
||||||
|
erwarte_fehler(r, http=400, code='invalid_update')
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Aenderung ohne Namen wird abgewiesen')
|
||||||
|
def _(ctx):
|
||||||
|
r = ctx.testctl('update_master_token', token_id=ctx['edit_token_id'],
|
||||||
|
name=' ', scopes=['bugtracker:read'])
|
||||||
|
erwarte_fehler(r, http=400, code='invalid_update')
|
||||||
|
|
||||||
|
|
||||||
|
@test('token', 'Aenderung an einem fehlenden Token antwortet 404')
|
||||||
|
def _(ctx):
|
||||||
|
r = ctx.testctl('update_master_token', token_id='tok_m_gibtsnicht',
|
||||||
|
name='egal', scopes=['bugtracker:read'])
|
||||||
|
erwarte_fehler(r, http=404, code='not_found')
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Allgemeines Verhalten der Schnittstelle
|
# Allgemeines Verhalten der Schnittstelle
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -235,6 +235,44 @@ switch ($action) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
// Master-Token nachtraeglich aendern.
|
||||||
|
//
|
||||||
|
// Der Weg dorthin ist die Oberflaeche - eine API gibt es dafuer bewusst
|
||||||
|
// nicht, denn ein Token soll seine eigenen Rechte nicht erweitern koennen.
|
||||||
|
// Damit waere die Aenderung aber ungeprueft geblieben, und gerade hier
|
||||||
|
// zaehlt eine Zusage: dass der Tokenwert unveraendert bleibt. Sonst
|
||||||
|
// muessten alle Maschinen neu bestueckt werden, und das Versprechen der
|
||||||
|
// Funktion waere gebrochen.
|
||||||
|
// ------------------------------------------------------------------
|
||||||
|
case 'update_master_token': {
|
||||||
|
$tokenId = (string)(Http::str('token_id') ?? '');
|
||||||
|
if ($tokenId === '') {
|
||||||
|
Http::fail(400, 'missing_token_id', 'token_id wird gebraucht.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$felder = Http::body();
|
||||||
|
unset($felder['action'], $felder['token_id']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$ok = (new TokenManager($db))->updateMasterToken($tokenId, $felder);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
Http::fail(400, 'invalid_update', $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$ok) {
|
||||||
|
Http::fail(404, 'not_found', sprintf('Master-Token "%s" existiert nicht.', $tokenId));
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $db->prepare('SELECT token_id, name, project_slug, owner_type, owner_identity,
|
||||||
|
scopes, environment, expires_at, type
|
||||||
|
FROM dc_tokens WHERE token_id = :id');
|
||||||
|
$stmt->execute([':id' => $tokenId]);
|
||||||
|
|
||||||
|
Http::ok(['token' => $stmt->fetch() ?: null]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
// Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz
|
// Signierschluessel fuer den Updateservice. Er wird auf der Testinstanz
|
||||||
// erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem
|
// erzeugt und nie von der Produktion kopiert - ein Testlauf darf mit dem
|
||||||
@@ -302,6 +340,6 @@ switch ($action) {
|
|||||||
default:
|
default:
|
||||||
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
Http::fail(400, 'unknown_action', sprintf('Unbekannte Aktion "%s".', $action), null, [
|
||||||
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
'actions' => ['status', 'wipe', 'migrate', 'reset', 'workbench_token',
|
||||||
'genkey', 'log', 'clearlog'],
|
'update_master_token', 'genkey', 'log', 'clearlog'],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user