Feat #10: Token-Rechte vollständig dokumentiert #11

Merged
florian.wilde merged 2 commits from feat/10-token-rechte into main 2026-08-18 11:51:07 +00:00
4 changed files with 181 additions and 25 deletions
Showing only changes of commit e9f01ba8fa - Show all commits
+12 -2
View File
@@ -487,8 +487,9 @@ Firmen-Gitea nebeneinander.
#### Nötige Token-Rechte #### Nötige Token-Rechte
Nebula liest fast alles nur. Geschrieben wird ausschließlich beim Anlegen und Schließen Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: `POST …/issues`
von Issues (4.6) — wer darauf verzichtet, kommt mit einem rein lesenden Token aus. beim Anlegen und `PATCH`/`PUT …/issues/{nr}` beim Schließen oder Wiederöffnen (4.6). Wer
darauf verzichtet, kommt mit einem rein lesenden Token aus.
| System | Nur Abgleich | Zusätzlich für die Issue-Anbindung | | System | Nur Abgleich | Zusätzlich für die Issue-Anbindung |
| --- | --- | --- | | --- | --- | --- |
@@ -496,6 +497,10 @@ von Issues (4.6) — wer darauf verzichtet, kommt mit einem rein lesenden Token
| GitLab | `read_api` | `api` | | GitLab | `read_api` | `api` |
| Gitea / Forgejo | `read:repository`, `read:issue`, `read:user` | `write:issue` | | Gitea / Forgejo | `read:repository`, `read:issue`, `read:user` | `write:issue` |
Welcher Aufruf welches Recht verlangt, steht Zeile für Zeile in der
[README](README.md#welche-rechte-der-token-braucht) — dort ist jeder ausgehende Aufruf
der drei Clients aufgeführt.
**GitLab kennt keinen schmalen Schreib-Scope.** `api` erlaubt alles, was das Konto darf, **GitLab kennt keinen schmalen Schreib-Scope.** `api` erlaubt alles, was das Konto darf,
Code eingeschlossen. Wem das zu weit geht, bleibt bei `read_api`: der Abgleich läuft Code eingeschlossen. Wem das zu weit geht, bleibt bei `read_api`: der Abgleich läuft
vollständig, geschlossene Issues werden weiterhin erkannt — nur schreibt Nebula dann vollständig, geschlossene Issues werden weiterhin erkannt — nur schreibt Nebula dann
@@ -505,6 +510,11 @@ nicht zurück.
Webhooks. Der Haken wird von Hand im Git-System eingetragen (4.7); das erspart dem Token Webhooks. Der Haken wird von Hand im Git-System eingetragen (4.7); das erspart dem Token
genau die Administrationsrechte, die er sonst bräuchte. genau die Administrationsrechte, die er sonst bräuchte.
**Kommentare schreibt Nebula nie** — weder beim Anlegen noch beim Schließen. In den
Rumpf des angelegten Issues wandert die Aufgabenbeschreibung plus eine Zeile Rückverweis
auf das Projekt; danach bleibt der Text unangetastet. Was sonst an Kommentaren und
Labels an einem Issue hängt, kommt aus der Weboberfläche oder von einem anderen Werkzeug.
Ein fehlendes Recht endet in einem `403` mit „Token hat nicht die noetigen Scopes", Ein fehlendes Recht endet in einem `403` mit „Token hat nicht die noetigen Scopes",
sichtbar am Repository auf der Projektseite. Klein anfangen und gezielt nachlegen ist sichtbar am Repository auf der Projektseite. Klein anfangen und gezielt nachlegen ist
deshalb ohne Risiko. deshalb ohne Risiko.
+106 -21
View File
@@ -188,8 +188,10 @@ Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Welche Rechte der Token braucht ### Welche Rechte der Token braucht
Nebula liest fast alles nur — geschrieben wird ausschließlich, wenn du eine Aufgabe als Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: wenn du eine
Issue anlegst oder abhakst. Deshalb lohnt es, zwei Fälle zu unterscheiden: Aufgabe als Issue anlegst, und wenn eine Aufgabe auf `DONE` wandert und Nebula das
verknüpfte Issue schließt. Alles andere sind `GET`-Aufrufe. Deshalb lohnt es, zwei Fälle
zu unterscheiden:
**Fall A — nur Abgleich.** Branches, Tags, Commits, Releases, CI-Zustand, README, **Fall A — nur Abgleich.** Branches, Tags, Commits, Releases, CI-Zustand, README,
Heatmap, offene Issues und PRs anzeigen. Rein lesend. Heatmap, offene Issues und PRs anzeigen. Rein lesend.
@@ -203,29 +205,112 @@ Issue geschlossen, hakt Nebula die Aufgabe ab und schließt sie umgekehrt mit.
| **GitLab** | `read_api` | `api` — **es gibt nichts dazwischen** | | **GitLab** | `read_api` | `api` — **es gibt nichts dazwischen** |
| **Gitea / Forgejo** | `read:repository`, `read:issue`, `read:user` | `write:issue` | | **Gitea / Forgejo** | `read:repository`, `read:issue`, `read:user` | `write:issue` |
Wozu die einzelnen Rechte gut sind: #### Jeder Aufruf einzeln
| Recht | Wofür | Die folgenden Tabellen sind vollständig: mehr als das ruft Nebula nicht nach draußen.
| --- | --- | Sie sind aus `src/lib/git/github.ts`, `gitlab.ts` und `gitea.ts` erhoben — wer prüfen
| Repository lesen (`Contents`, `read:repository`) | Branches, Tags, Commits, Releases, Sprachen, README, Commit-Heatmap | will, ob hier etwas fehlt, greppt dort nach `getJson`, `getJsonSafe` und `sendJson`.
| Issues lesen | Offene Issues auf der Projektseite, „Issues als Aufgaben übernehmen" |
| Pull Requests lesen | Offene PRs auf der Projektseite |
| Actions lesen (nur GitHub) | CI-Zustand des Default-Branch. Bei GitLab kommt er aus den Pipelines (`read_api`), bei Gitea aus dem Commit-Status (`read:repository`) |
| Benutzer lesen | Nur für den Verbindungstest — Nebula meldet zurück, als wer der Token gilt |
| Issues schreiben | Aufgabe als Issue anlegen, schließen, wieder öffnen |
> **GitLab ist der unangenehme Fall.** Es kennt keinen schmalen Schreib-Scope für Issues: **GitHub** — `https://api.github.com`, Header `Authorization: Bearer …`
> `api` erlaubt alles, was dein Konto darf — auch Code ändern. Wenn dir das zu weit geht,
> nimm `read_api` und verzichte auf die Issue-Anbindung. Der Abgleich funktioniert
> vollständig, und geschlossene Issues werden trotzdem erkannt — nur eben nur gelesen.
**Was Nebula *nicht* braucht:** Schreibrechte auf Code, Adminrechte, Rechte zum Anlegen | Aufruf | Wofür | Benötigtes Recht |
von Webhooks. Den Haken für Webhooks trägst du selbst im Git-System ein — genau deshalb | --- | --- | --- |
verlangt Nebula dafür kein zusätzliches Recht. | `GET /rate_limit` | Verbindungstest | keins |
| `GET /user` | Kontoname beim Testen zurückmelden | keins |
| `GET /user/repos` | Auswahlliste hinter **Repos laden** | Metadata: Read |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | Metadata: Read |
| `GET …/languages` | Sprachverteilung | Metadata: Read |
| `GET …/tags` | Tags | Metadata: Read |
| `GET …/stats/commit_activity` | Commit-Heatmap über ein Jahr | Metadata: Read |
| `GET …/branches` | Branch-Liste | Contents: Read |
| `GET …/commits` | die letzten 100 Commits | Contents: Read |
| `GET …/releases` | Releases | Contents: Read |
| `GET …/readme` | README-Vorschau | Contents: Read |
| `GET …/pulls?state=open` | offene Pull Requests | Pull requests: Read |
| `GET …/issues?state=open` | offene Issues | Issues: Read |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | Issues: Read |
| `GET …/actions/runs` | CI-Zustand des Default-Branch | Actions: Read |
| `POST …/issues` | **Aufgabe als Issue anlegen** | Issues: Read and write |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | Issues: Read and write |
**Im Zweifel klein anfangen.** Ein fehlendes Recht führt zu einem `403` mit dem Hinweis Dass `/tags` und `/stats/commit_activity` unter *Metadata* fallen und nicht unter
„Token hat nicht die noetigen Scopes"; die Meldung steht am Repository auf der *Contents*, ist nicht zu erraten — es steht so in
Projektseite. Dann gezielt nachlegen, statt vorsorglich alles zu vergeben. [GitHubs Berechtigungsübersicht](https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens).
**GitLab** — `{Basis-URL}/api/v4`, Header `PRIVATE-TOKEN: …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /projects?per_page=1` | Verbindungstest | `read_api` |
| `GET /user` | Kontoname beim Testen zurückmelden | `read_user` (in `read_api` enthalten) |
| `GET /projects?membership=true` | Auswahlliste hinter **Repos laden** | `read_api` |
| `GET /projects/{id}?statistics=true` | Stammdaten, Default-Branch, Sichtbarkeit | `read_api` |
| `GET …/repository/branches` · `/tags` · `/commits` | Snapshot und Heatmap | `read_api` |
| `GET …/repository/tree` · `/repository/files/{datei}` | README (zwei Aufrufe, GitLab hat keinen README-Endpunkt) | `read_api` |
| `GET …/releases` · `/languages` | Releases, Sprachverteilung | `read_api` |
| `GET …/merge_requests?state=opened` | offene Merge Requests | `read_api` |
| `GET …/issues?state=opened` | offene Issues | `read_api` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read_api` |
| `GET …/pipelines` | CI-Zustand des Default-Branch | `read_api` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `api` |
| `PUT …/issues/{nr}` (`state_event`) | **Issue schließen oder wieder öffnen** | `api` |
**Gitea / Forgejo** — `{Basis-URL}/api/v1`, Header `Authorization: token …`
| Aufruf | Wofür | Benötigtes Recht |
| --- | --- | --- |
| `GET /version` | Verbindungstest | keins — der Aufruf darf fehlschlagen, Nebula wertet ihn nur ergänzend aus |
| `GET /user` | Kontoname beim Testen zurückmelden | `read:user` |
| `GET /user/repos` bzw. `GET /repos/search` | Auswahlliste hinter **Repos laden** | `read:repository` |
| `GET /repos/{o}/{r}` | Stammdaten, Default-Branch, Sichtbarkeit | `read:repository` |
| `GET …/branches` · `/tags` · `/commits` · `/releases` · `/languages` | Snapshot und Heatmap | `read:repository` |
| `GET …/contents` · `/contents/{datei}` | README (zwei Aufrufe, wie bei GitLab) | `read:repository` |
| `GET …/commits/{branch}/status` | CI-Zustand des Default-Branch | `read:repository` |
| `GET …/pulls?state=open` | offene Pull Requests | `read:repository` — Gitea zählt PRs zum Repository-Scope, nicht zu den Issues |
| `GET …/issues?state=open&type=issues` | offene Issues | `read:issue` |
| `GET …/issues/{nr}` | Abgleich: ist das verknüpfte Issue noch offen? | `read:issue` |
| `POST …/issues` | **Aufgabe als Issue anlegen** | `write:issue` |
| `PATCH …/issues/{nr}` | **Issue schließen oder wieder öffnen** | `write:issue` |
`write:issue` schließt `read:issue` mit ein — Gitea versteht `write:` als „lesen und
schreiben". Beide anzuhaken schadet nicht, nötig ist es nicht.
#### Was Nebula nicht tut
- **Keine Kommentare.** Nebula schreibt nie einen Issue-Kommentar, weder beim Anlegen
noch beim Schließen. Was im Rumpf des angelegten Issues steht, ist die
Aufgabenbeschreibung plus eine Zeile Rückverweis — danach fasst Nebula den Text
nicht mehr an.
- **Kein Code.** Nichts wird committet, gepusht, gemergt oder getaggt. Nebula ist eine
Übersicht, keine Entwicklungsumgebung.
- **Keine Webhooks anlegen.** Den Haken trägst du selbst im Git-System ein — genau
deshalb verlangt Nebula dafür keine Administrationsrechte.
- **Keine Labels, keine Meilensteine, keine Zuweisungen.**
- **Keine Adminrechte**, keine Organisations- oder Benutzerverwaltung.
Kommentare und Labels, die an einem Issue hängen, stammen also entweder aus der
Weboberfläche deines Git-Systems oder von einem anderen Werkzeug mit eigenem Token —
nicht von Nebula.
#### Der GitLab-Sonderfall
GitLab kennt keinen schmalen Schreib-Scope für Issues. Laut
[Access token scopes](https://docs.gitlab.com/security/tokens/access_token_scopes/)
gibt es nur `api` — „complete read and write access to the API" — und darunter direkt
`read_api`. Wer bei GitLab Aufgaben als Issue anlegen will, vergibt damit ein Token, das
auch Code ändern darf.
Wenn dir das zu weit geht: bleib bei `read_api` und verzichte auf die Issue-Anbindung.
Der Abgleich läuft vollständig, und geschlossene Issues werden weiterhin erkannt und
haken ihre Aufgabe ab — nur der Rückweg von Nebula ins Issue fällt weg. Ein eigenes
Dienstkonto, das nur Zugriff auf die betroffenen Projekte hat, ist der andere Ausweg.
#### Im Zweifel klein anfangen
Ein fehlendes Recht führt zu einem `403` mit dem Hinweis „Token hat nicht die noetigen
Scopes"; die Meldung steht am Repository auf der Projektseite. Fang mit Fall A an und
leg gezielt nach, wenn du die Issue-Anbindung wirklich benutzt — statt vorsorglich
alles zu vergeben.
### Automatisch statt auf Knopfdruck ### Automatisch statt auf Knopfdruck
+3 -2
View File
@@ -12,8 +12,9 @@ export const PROVIDER_META: Record<
label: "GitHub", label: "GitHub",
defaultBaseUrl: "https://github.com", defaultBaseUrl: "https://github.com",
tokenHelp: tokenHelp:
"Fine-grained Token: Metadata, Contents und Pull requests lesen, Issues lesen. " + "Fine-grained Token, alle nur lesend: Metadata, Contents, Issues, Pull requests " +
"Nur wenn Aufgaben als Issue angelegt werden sollen, Issues zusaetzlich schreiben.", "und Actions. Nur wenn Aufgaben als Issue angelegt werden sollen, Issues " +
"zusaetzlich schreiben.",
docsUrl: "https://github.com/settings/tokens", docsUrl: "https://github.com/settings/tokens",
}, },
GITLAB: { GITLAB: {
+60
View File
@@ -0,0 +1,60 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Waechter ueber eine Zusage in der Dokumentation.
*
* README und FEATURES sagen zu, dass Nebula an genau zwei Stellen nach
* draussen schreibt: ein Issue anlegen und seinen Zustand umsetzen. Auf
* dieser Zusage steht die Empfehlung, ein rein lesendes Token zu vergeben,
* solange die Issue-Anbindung nicht gebraucht wird.
*
* Kaeme ein dritter Schreibaufruf dazu, wuerde die Empfehlung still falsch:
* niemand bekaeme einen Fehler, nur ein 403 an einer Stelle, die laut
* Dokumentation gar nicht schreibt. Deshalb wird hier der Quelltext selbst
* abgeklopft – sendJson ist der einzige Weg aus lib/git heraus, der eine
* andere Methode als GET benutzt (siehe http.ts).
*/
const CLIENTS = ["gitea.ts", "github.ts", "gitlab.ts"] as const;
/** Die Zeile ueber dem Methoden-Argument nennt den Endpunkt. */
function schreibaufrufe(quelltext: string): string[] {
const zeilen = quelltext.split("\n");
const gefunden: string[] = [];
zeilen.forEach((zeile, i) => {
if (!/\bsendJson[<(]/.test(zeile)) return;
// sendJson(url, METHODE, …) – Endpunkt und Methode stehen darunter.
const block = zeilen.slice(i, i + 4).join(" ");
const methode = block.match(/"(POST|PATCH|PUT)"/)?.[1];
const pfad = block.match(/\/issues[^`"']*/)?.[0] ?? "?";
gefunden.push(`${methode ?? "?"} ${pfad}`);
});
return gefunden;
}
describe("Schreibzugriffe nach draussen", () => {
it("beschraenken sich auf Issues anlegen und Zustand setzen", () => {
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
const aufrufe = schreibaufrufe(quelltext);
expect(aufrufe.length, `${datei}: Anzahl Schreibaufrufe`).toBe(2);
for (const aufruf of aufrufe) {
expect(aufruf, `${datei}: ${aufruf}`).toMatch(/^(POST|PATCH|PUT) \/issues/);
}
// Genau einer legt an, genau einer setzt um.
expect(aufrufe.filter((a) => a.startsWith("POST")).length, datei).toBe(1);
expect(aufrufe.filter((a) => /^(PATCH|PUT)/.test(a)).length, datei).toBe(1);
}
});
it("laufen ueber sendJson, nicht an http.ts vorbei", () => {
// Ein direkter fetch in einem Client haette weder SSRF-Schutz noch
// Zeitbegrenzung – und wuerde am Waechter oben vorbeilaufen.
for (const datei of CLIENTS) {
const quelltext = readFileSync(join(__dirname, datei), "utf8");
expect(quelltext, datei).not.toMatch(/\bfetch\s*\(/);
}
});
});