Merge #10: Token-Rechte vollstaendig dokumentiert
CI / pruefen (push) Canceled after 0s

This commit was merged in pull request #11.
This commit is contained in:
2026-08-18 11:51:06 +00:00
5 changed files with 291 additions and 11 deletions
+34
View File
@@ -485,6 +485,40 @@ Firmen-Gitea nebeneinander.
Kurzbeschreibung. Kurzbeschreibung.
- **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten. - **Löschen** trennt nur die Verknüpfung; die Projekte selbst bleiben erhalten.
#### Nötige Token-Rechte
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: `POST …/issues`
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 |
| --- | --- | --- |
| GitHub | *Metadata*, *Contents*, *Issues*, *Pull requests*, *Actions* — je lesend | *Issues: Read and write* |
| GitLab | `read_api` | `api` |
| 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,
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
nicht zurück.
**Nicht verlangt** werden Schreibrechte auf Code, Adminrechte und Rechte zum Anlegen von
Webhooks. Der Haken wird von Hand im Git-System eingetragen (4.7); das erspart dem Token
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",
sichtbar am Repository auf der Projektseite. Klein anfangen und gezielt nachlegen ist
deshalb ohne Risiko.
### 4.3 Was beim Sync geholt wird ### 4.3 Was beim Sync geholt wird
| Bereich | Details | | Bereich | Details |
+128 -8
View File
@@ -173,14 +173,8 @@ Nebula auffindbare Credentials nutzt.
## Git-Verbindung einrichten ## Git-Verbindung einrichten
1. Im Git-System einen **Personal Access Token** mit Leserechten erzeugen: 1. Im Git-System einen **Personal Access Token** erzeugen — mit den Rechten aus dem
Abschnitt [Welche Rechte der Token braucht](#welche-rechte-der-token-braucht).
| System | Scopes |
| --- | --- |
| GitHub | Fine-grained Token: *Contents: Read*, *Metadata: Read*, *Issues: Read*, *Pull requests: Read* |
| GitLab | `read_api` (alternativ `read_repository`) |
| Gitea / Forgejo | `repository:read`, `issue:read`, `user:read` |
2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen: 2. In Nebula unter **Git-Verbindungen → Verbindung hinzufügen** eintragen:
System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token. System wählen, Basis-URL (z. B. `https://git.meine-firma.de`, ohne `/api`) und Token.
3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück. 3. Mit **Testen** prüfen — Nebula meldet den erkannten Benutzer zurück.
@@ -192,6 +186,132 @@ Nebula auffindbare Credentials nutzt.
Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline. Der Snapshot bleibt in der Datenbank, das Dashboard funktioniert also auch offline.
Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit. Ohne Token gehen nur öffentliche Repositories, mit strengem Rate-Limit.
### Welche Rechte der Token braucht
Nebula liest fast alles nur. Geschrieben wird an **genau zwei Stellen**: wenn du eine
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,
Heatmap, offene Issues und PRs anzeigen. Rein lesend.
**Fall B — zusätzlich die Issue-Anbindung.** Eine Aufgabe als Issue anlegen; wird das
Issue geschlossen, hakt Nebula die Aufgabe ab und schließt sie umgekehrt mit.
| System | Fall A (nur lesen) | Fall B zusätzlich |
| --- | --- | --- |
| **GitHub** | Fine-grained Token: *Metadata: Read*, *Contents: Read*, *Issues: Read*, *Pull requests: Read*, *Actions: Read* | *Issues: **Read and write*** |
| **GitLab** | `read_api` | `api` — **es gibt nichts dazwischen** |
| **Gitea / Forgejo** | `read:repository`, `read:issue`, `read:user` | `write:issue` |
#### Jeder Aufruf einzeln
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
will, ob hier etwas fehlt, greppt dort nach `getJson`, `getJsonSafe` und `sendJson`.
**GitHub** — `https://api.github.com`, Header `Authorization: Bearer …`
| Aufruf | Wofür | Benötigtes 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 |
Dass `/tags` und `/stats/commit_activity` unter *Metadata* fallen und nicht unter
*Contents*, ist nicht zu erraten — es steht so in
[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
Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den Unter **Admin-Einstellungen → Automatischer Abgleich** lässt sich der Sync in den
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import { PROVIDER_META, guessProvider } from "./meta";
const PROVIDER = ["GITHUB", "GITLAB", "GITEA"] as const;
describe("PROVIDER_META", () => {
it("kennt jedes unterstuetzte System", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider], provider).toBeDefined();
}
// Und nichts darueber hinaus – ein Eintrag ohne Gegenstueck im Schema
// waere ein Ueberbleibsel.
expect(Object.keys(PROVIDER_META).sort()).toEqual([...PROVIDER].sort());
});
it("hat zu jedem System Beschriftung, Basis-URL und Dokuverweis", () => {
for (const provider of PROVIDER) {
const meta = PROVIDER_META[provider];
expect(meta.label.length, provider).toBeGreaterThan(0);
expect(meta.defaultBaseUrl, provider).toMatch(/^https:\/\//);
expect(meta.docsUrl, provider).toMatch(/^https:\/\//);
}
});
/**
* Der eigentliche Waechter.
*
* Der Hinweis am Eingabefeld war eine Zeit lang rein lesend formuliert,
* obwohl Nebula seit v1.7.0 Issues anlegt und schliesst – wer sich daran
* hielt, bekam einen 403. Jeder Provider in GitClient muss createIssue und
* setIssueState koennen; folglich muss auch jeder Hinweis sagen, was das
* an Rechten kostet.
*/
it("sagt bei jedem System etwas ueber die Rechte fuer Issues", () => {
for (const provider of PROVIDER) {
expect(PROVIDER_META[provider].tokenHelp, provider).toMatch(/issue/i);
}
});
it("nennt einen konkreten Scope statt nur 'Leserechte'", () => {
// GitLab nennt Scopes in Anfuehrungszeichen, GitHub und Gitea
// Berechtigungsnamen – gemeinsam ist, dass etwas Nachschlagbares dasteht.
expect(PROVIDER_META.GITLAB.tokenHelp).toMatch(/read_api/);
expect(PROVIDER_META.GITEA.tokenHelp).toMatch(/read:repository/);
expect(PROVIDER_META.GITHUB.tokenHelp).toMatch(/Metadata/);
});
});
describe("guessProvider", () => {
it("erkennt die oeffentlichen Instanzen am Hostnamen", () => {
expect(guessProvider("github.com")).toBe("GITHUB");
expect(guessProvider("gitlab.com")).toBe("GITLAB");
});
it("faellt bei allem anderen auf Gitea zurueck", () => {
// Selbst gehostet ist hier der haeufigste Fall, und Gitea die
// wahrscheinlichste Wahl dafuer.
expect(guessProvider("git.wilde-server.de")).toBe("GITEA");
});
});
+9 -3
View File
@@ -12,19 +12,25 @@ export const PROVIDER_META: Record<
label: "GitHub", label: "GitHub",
defaultBaseUrl: "https://github.com", defaultBaseUrl: "https://github.com",
tokenHelp: tokenHelp:
"Fine-grained Token mit Lesezugriff auf Contents, Metadata, Issues und Pull Requests.", "Fine-grained Token, alle nur lesend: Metadata, Contents, Issues, Pull requests " +
"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: {
label: "GitLab", label: "GitLab",
defaultBaseUrl: "https://gitlab.com", defaultBaseUrl: "https://gitlab.com",
tokenHelp: "Personal Access Token mit Scope 'read_api' (bzw. 'read_repository').", tokenHelp:
"Scope 'read_api' genuegt fuer den Abgleich. Aufgaben als Issue anzulegen verlangt " +
"'api' – GitLab kennt keinen schmaleren Schreibzugriff.",
docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens", docsUrl: "https://gitlab.com/-/user_settings/personal_access_tokens",
}, },
GITEA: { GITEA: {
label: "Gitea / Forgejo", label: "Gitea / Forgejo",
defaultBaseUrl: "https://git.example.com", defaultBaseUrl: "https://git.example.com",
tokenHelp: "Access Token mit Leserechten auf repository, issue und user.", tokenHelp:
"read:repository, read:issue und read:user. Nur wenn Aufgaben als Issue angelegt " +
"werden sollen, zusaetzlich write:issue.",
docsUrl: "https://docs.gitea.com/development/api-usage", docsUrl: "https://docs.gitea.com/development/api-usage",
}, },
}; };
+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*\(/);
}
});
});