From 2885d3a4fa921e302c9747acaf33d92fe69e28ef Mon Sep 17 00:00:00 2001 From: mpabi Date: Sun, 26 Apr 2026 20:49:35 +0200 Subject: [PATCH] Render token scan as compact token table --- README.md | 12 +- doc/rvctl.md | 47 +++-- doc/tokens.md | 55 +++--- rvctl.py | 511 ++++++++++++++++++++++++++++++++++---------------- 4 files changed, 399 insertions(+), 226 deletions(-) diff --git a/README.md b/README.md index 83f7738..ab42b92 100644 --- a/README.md +++ b/README.md @@ -156,13 +156,11 @@ Podstawowe komendy tokenow: ./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ``` -`tokens scan` wypisuje waska tabele. Dla jednego endpointu zobaczysz -osobny wiersz `source=repo` oraz osobny wiersz `source=tokens.json`. -Ten sam numer `item` laczy oba wiersze w pare dla jednego endpointu. -Kolumna `token` pokazuje, gdzie jest token, a `sync` stawia `*` przy zrodle -prawdy. Kolumna `id` pokazuje `r1` dla tokena w repo albo `t1` dla tokena w -`tokens.json`, bez pokazywania sekretu. Endpoint jest rozbity na `server`, -`scheme`, `host` i `port`. +`tokens scan` wypisuje tabele `tokens`: jeden wiersz na logiczny token. +`token_ref` laczy nazwe tokena z markerem po prawej stronie: `*` oznacza, ze +remote i `tokens.json` sa zgodne oraz uprawnienia zostaly wczytane, `R` oznacza +token tylko w remote, a `S` token tylko w `tokens.json`. Kolumny `scope`, `org` +i `repo` sa maskami uprawnien; bez `*` maja wartosc `?????` albo `????`. ## Fetch i switch diff --git a/doc/rvctl.md b/doc/rvctl.md index e83aab7..7023313 100644 --- a/doc/rvctl.md +++ b/doc/rvctl.md @@ -292,44 +292,40 @@ Przyklady: ## `tokens scan` Skanuje remote URL-e w repo oraz lokalny `tokens.json`, laczy wpisy w pary po -endpoincie i pokazuje, gdzie przechowywany jest token. +endpoincie, userze i wartosci tokena, a potem pokazuje jeden logiczny wiersz +na token. Przelaczniki: - `--repo PATH` Sciezka wewnatrz docelowego repo. Domyslnie biezacy katalog. -- `--verbose` - Pokazuje dodatkowo sekcje `context` i `scan`. Typowy wynik: ```text -items -item source id server scheme host port credential repo_user store_user token sync ----- ----------- ---------- ------ ------ --------------- ----- ---------- --------- ---------- ----- ---- -1 repo r1 gitea http 77.90.8.171 3001 auth u1 u1 yes -1 tokens.json t1 gitea http 77.90.8.171 3001 store u1 u1 yes * +tokens +item server host org repo user remote token_ref token scope org repo +---- ------ ------------- --------- ----------- ---- ------ --------- ------------ ------ ------ ------ +1 gitea 77.90.8.171 edu-tools rv-launcher u1 r1 t1 * e59cc...13be +---+ +++++ ++++ ``` -Jesli remote istnieje, ale ma URL bez `LOGIN:TOKEN@`, wynik bedzie mial -w wierszu `repo` wartosci `credential=plain` i `token=no`. Jezeli ten sam -endpoint istnieje w `tokens.json`, drugi wiersz tego samego `item` pokaze -`source=tokens.json`, `id=t1`, `store_user=u1` i `token=yes`. +`token_ref` jest komorka stalej szerokosci: nazwa tokena jest po lewej, a marker +po prawej. Marker `*` oznacza, ze token w remote i `tokens.json` jest zgodny +oraz uprawnienia zostaly wczytane. Marker `R` oznacza token tylko w remote, a +`S` token tylko w `tokens.json`. -Ten sam numer `item` oznacza jedna pare logiczna dla jednego endpointu. Wiersz -`source=repo` pokazuje stan remote, a wiersz `source=tokens.json` pokazuje stan -lokalnego store. Kolumna `sync` pokazuje `*` przy aktualnym zrodle prawdy dla -pary repo/store. Przy konflikcie pokazuje `!`. -Kolumna `id` pokazuje identyfikator miejsca przechowywania tokena: dla repo jest -to nazwa remota, na przyklad `r1`, a dla store nazwa tokenu, na przyklad `t1`. -Endpoint jest rozbity na `server`, `scheme`, `host` i `port`. +Maski uprawnien: + +- `scope` ma pozycje `awrop`: `all`, `write:repository`, `read:repository`, organization scope, `public-only` +- `org` ma pozycje `oawrc`: owner, admin, write, read, create repo +- `repo` ma pozycje `oawr`: owner, admin, write, read +- `+` oznacza wlaczone, `-` wylaczone, `?` nie wczytano, `!` blad wczytania Przyklad: ```bash ./rvctl tokens scan ./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 -./rvctl tokens scan --verbose ``` ## `tokens read` @@ -385,15 +381,14 @@ itemvalue repo_root... token_path... -items -item source id server scheme host port credential repo_user store_user token sync ----- ----------- ---------- ------ ------ --------------- ----- ---------- --------- ---------- ----- ---- -1 repo r1 gitea http 77.90.8.171 3001 plain u1 no -1 tokens.json t1 gitea http 77.90.8.171 3001 store u1 u1 yes * +tokens +item server host org repo user remote token_ref token scope org repo +---- ------ ------------- --------- ----------- ---- ------ --------- ------------ ------ ------ ------ +1 gitea 77.90.8.171 edu-tools rv-launcher u1 r1 t1 * e59cc...13be +---+ +++++ ++++ status itemvalue -store_ahead1 +in_sync1 ``` Przyklad: diff --git a/doc/tokens.md b/doc/tokens.md index f9d7390..803b254 100644 --- a/doc/tokens.md +++ b/doc/tokens.md @@ -88,49 +88,48 @@ Dzialanie: - laczy oba zrodla po endpoincie serwera - zapisuje znalezione tokeny do `tokens.json` - zapisuje je per endpoint serwera -- wypisuje domyslnie tylko waska tabele `items` -- z `--verbose` dopisuje diagnostyczne sekcje `context` oraz `scan` +- wypisuje tabele `tokens` -Tabela `items` porownuje dwa zrodla dla tego samego endpointu. Ten sam `item` -moze miec dwa wiersze: +Tabela `tokens` pokazuje jeden logiczny wiersz na token. Remote i `tokens.json` +sa laczone po endpoincie, userze i wartosci tokena. -- `source=repo` - stan remote URL-i w repo -- `source=tokens.json` - stan lokalnego store tokenow +Znacznik w `token_ref`: -Dzieki temu widac, dla ktorego endpointu token jest w remote, a dla ktorego -jest tylko w `tokens.json`. +- `*` - token jest w remote i `tokens.json`, uprawnienia zostaly wczytane +- `R` - token jest tylko w remote +- `S` - token jest tylko w `tokens.json` +- `!` - blad wczytania uprawnien dla sparowanego tokena -Kolumny w tabeli `items`: +Kolumny w tabeli `tokens`: -- `item` - numer porownywanego endpointu -- `source` - `repo` albo `tokens.json` -- `id` - identyfikator miejsca tokena: remote `r1` dla repo albo token `t1` dla `tokens.json` +- `item` - numer wiersza tokena - `server` - typ serwera, na przyklad `gitea` -- `scheme` - schemat URL, na przyklad `http` - `host` - host endpointu, na przyklad `77.90.8.171` -- `port` - port endpointu, na przyklad `3001` -- `credential` - typ wpisu: `auth`, `plain`, `store` albo `missing` -- `repo_user` - login odczytany z remote URL-i repo -- `store_user` - login odczytany z `tokens.json` -- `token` - `yes` albo `no`, czyli czy dane zrodlo ma token dla endpointu -- `sync` - `*` przy zrodle prawdy albo `!` przy konflikcie +- `org` - organizacja z remote URL +- `repo` - repo z remote URL +- `user` - login wlasciciela tokena +- `remote` - nazwa remota, na przyklad `r1` +- `token_ref` - nazwa tokena z markerem po prawej stronie +- `token` - zamaskowana wartosc tokena +- `scope` - maska scope tokena `awrop` +- `org` - maska praw w organizacji `oawrc` +- `repo` - maska praw w repo `oawr` -Tabela nie wypisuje sekretu tokena. Jezeli token jest w remote, `id` pokazuje -nazwe remota, na przyklad `r1`. Jezeli token jest w `tokens.json`, `id` -pokazuje nazwe tokenu, na przyklad `t1`. +Maski uprawnien: -Zasada znacznika `sync`: +- `+` - flaga wlaczona +- `-` - flaga wylaczona +- `?` - nie wczytano, na przyklad dla `R` albo `S` +- `!` - blad wczytania -- `in_sync`, `store_only`, `store_ahead` - `*` przy `tokens.json` -- `repo_only`, `repo_ahead` - `*` przy `repo` -- `diverged` - `!`, bo nie ma jednoznacznego zrodla prawdy +Tabela nie wypisuje sekretu tokena wprost. Kolumna `token` pokazuje skrot, na +przyklad `e59cc...13be`. Przyklad: ```bash ./rvctl tokens scan ./rvctl tokens scan --repo ~/dev/workspace/rv/series/inf/03 -./rvctl tokens scan --verbose ``` ### `tokens read` @@ -170,7 +169,7 @@ Wynik ma ten sam model porownania co `tokens scan`, ale nie zapisuje zmian: - liczbe endpointow w store - laczna unie endpointow - statusy zgodnosci, na przyklad `in_sync`, `store_ahead`, `repo_ahead` -- tabele `items`, w ktorej repo i `tokens.json` sa osobnymi wierszami tego samego itemu +- tabele `tokens` Przyklad: diff --git a/rvctl.py b/rvctl.py index 0d89e2d..50604d7 100644 --- a/rvctl.py +++ b/rvctl.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import base64 import datetime as dt import json import os @@ -13,6 +14,8 @@ import subprocess import sys from dataclasses import dataclass from pathlib import Path +from urllib import error as urlerror +from urllib import request as urlrequest from urllib.parse import quote, unquote, urlsplit, urlunsplit @@ -244,6 +247,17 @@ def server_type_from_split(config: WorkspaceConfig, split_url) -> str: return "unknown" +def repo_parts_from_url(remote_url: str) -> tuple[str, str]: + split_url = urlsplit(remote_url) + repo_path = unquote(split_url.path).strip("/") + if repo_path.endswith(".git"): + repo_path = repo_path[:-4] + path_parts = [part for part in repo_path.split("/") if part] + if len(path_parts) < 2: + return "", "" + return path_parts[0], path_parts[1] + + def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | None: split_url = urlsplit(remote_url) if split_url.scheme not in {"http", "https"}: @@ -252,12 +266,15 @@ def server_info_from_url(config: WorkspaceConfig, remote_url: str) -> dict | Non return None endpoint, port_value = endpoint_from_split(split_url) + org_name, repo_name = repo_parts_from_url(remote_url) return { "endpoint": endpoint, "type": server_type_from_split(config, split_url), "scheme": split_url.scheme, "host": split_url.hostname, "port": port_value, + "org": org_name, + "repo": repo_name, } @@ -455,8 +472,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "remote": remote_name, "endpoint": "", "type": "unsupported", + "org": "", + "repo": "", "url_kind": "unsupported", "user": "", + "token_value": "", "result": "ignored", } ) @@ -471,8 +491,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "remote": remote_name, "endpoint": server_info["endpoint"], "type": server_info["type"], + "org": server_info["org"], + "repo": server_info["repo"], "url_kind": "plain", "user": "", + "token_value": "", "result": "no_credentials", } ) @@ -486,8 +509,11 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "remote": remote_name, "endpoint": server_info["endpoint"], "type": server_info["type"], + "org": server_info["org"], + "repo": server_info["repo"], "url_kind": "auth", "user": user_name, + "token_value": token_value, "result": "found", } ) @@ -575,6 +601,8 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup "auth_urls": 0, "plain_urls": 0, "users": {}, + "tokens": [], + "plain_remotes": [], }, ) entry["remotes"].add(remote_name) @@ -586,10 +614,26 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup credentials = remote_credentials(remote_url) if credentials is None: entry["plain_urls"] += 1 + entry["plain_remotes"].append( + { + "remote": remote_name, + "org": server_info["org"], + "repo": server_info["repo"], + } + ) continue entry["auth_urls"] += 1 user_name, token_value = credentials + entry["tokens"].append( + { + "remote": remote_name, + "org": server_info["org"], + "repo": server_info["repo"], + "user": user_name, + "token_value": token_value, + } + ) user_entry = entry["users"].setdefault(user_name, {"tokens": {}}) tokens = user_entry.setdefault("tokens", {}) if token_value not in tokens.values(): @@ -657,7 +701,7 @@ def print_token_context( print(f"token_path\t{config.token_path}") print(f"repo_endpoints\t{len(repo_servers)}") print(f"tokens_endpoints\t{len(store_servers)}") - print(f"items\t{endpoint_count}") + print(f"tokens\t{endpoint_count}") def print_status_counts(status_counts: dict[str, int]) -> None: @@ -667,19 +711,21 @@ def print_status_counts(status_counts: dict[str, int]) -> None: print(f"{status_name}\t{status_counts[status_name]}") -TOKEN_ITEM_COLUMNS = [ - ("item", 4), - ("source", 11), - ("id", 10), - ("server", 6), - ("scheme", 6), - ("host", 15), - ("port", 5), - ("credential", 10), - ("repo_user", 9), - ("store_user", 10), - ("token", 5), - ("sync", 4), +TOKEN_REF_WIDTH = 9 + +TOKEN_COLUMNS = [ + ("item", "item", 4), + ("server", "server", 6), + ("host", "host", 13), + ("owner", "org", 9), + ("repo_name", "repo", 11), + ("user", "user", 4), + ("remote", "remote", 6), + ("token_ref", "token_ref", TOKEN_REF_WIDTH), + ("token", "token", 12), + ("scope_mask", "scope", 6), + ("org_mask", "org", 6), + ("repo_mask", "repo", 6), ] @@ -691,24 +737,23 @@ def fit_cell(value: str, width: int) -> str: return f"{value[:width - 3]}..." -def print_token_item_table(rows: list[dict[str, str]]) -> None: - print("items") - print(" ".join(fit_cell(name, width) for name, width in TOKEN_ITEM_COLUMNS).rstrip()) - print(" ".join("-" * width for _, width in TOKEN_ITEM_COLUMNS).rstrip()) +def print_fixed_table(title: str, columns: list[tuple[str, str, int]], rows: list[dict[str, str]]) -> None: + print(title) + print(" ".join(fit_cell(label, width) for _, label, width in columns).rstrip()) + print(" ".join("-" * width for _, _, width in columns).rstrip()) for row in rows: - print(" ".join(fit_cell(row.get(name, ""), width) for name, width in TOKEN_ITEM_COLUMNS).rstrip()) + print(" ".join(fit_cell(row.get(name, ""), width) for name, _, width in columns).rstrip()) -def sync_mark(status_name: str, source: str) -> str: - if status_name == "diverged": - return "!" - if status_name in {"in_sync", "store_only", "store_ahead"}: - truth_source = "tokens.json" - elif status_name in {"repo_only", "repo_ahead"}: - truth_source = "repo" - else: - truth_source = "" - return "*" if source == truth_source else "" +def short_secret(secret_value: str) -> str: + if len(secret_value) <= 12: + return mask_secret(secret_value) + return f"{secret_value[:5]}...{secret_value[-4:]}" + + +def format_token_ref(token_name: str, marker: str) -> str: + name_width = TOKEN_REF_WIDTH - 2 + return f"{fit_cell(token_name, name_width)}{marker:>2}" def users_label(server_entry: dict | None) -> str: @@ -741,6 +786,191 @@ def endpoint_column_values(endpoint: str, repo_entry: dict | None, store_entry: return values +def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]: + rows: list[dict[str, str]] = [] + for endpoint, server_entry in sorted(store_servers.items()): + users = server_entry.get("users", {}) + for user_name in sorted(users): + user_entry = users[user_name] + if not isinstance(user_entry, dict): + continue + for token_name, token_value in sorted(user_entry.get("tokens", {}).items()): + if isinstance(token_value, str): + rows.append( + { + "endpoint": endpoint, + "user": str(user_name), + "token_name": str(token_name), + "token_value": token_value, + } + ) + return rows + + +def repo_token_rows( + endpoint_names: list[str], + repo_servers: dict[str, dict], + scan_rows: list[dict[str, str]] | None, +) -> list[dict[str, str]]: + if scan_rows is not None: + return [ + { + "endpoint": row["endpoint"], + "remote": row["remote"], + "owner": row.get("org", ""), + "repo_name": row.get("repo", ""), + "user": row.get("user", ""), + "token_value": row.get("token_value", ""), + } + for row in scan_rows + if row.get("endpoint") and row.get("url_kind") == "auth" and row.get("token_value") + ] + + rows: list[dict[str, str]] = [] + for endpoint in endpoint_names: + repo_entry = repo_servers.get(endpoint) + if repo_entry is None: + continue + for row in repo_entry.get("tokens", []): + rows.append( + { + "endpoint": endpoint, + "remote": row.get("remote", ""), + "owner": row.get("org", ""), + "repo_name": row.get("repo", ""), + "user": row.get("user", ""), + "token_value": row.get("token_value", ""), + } + ) + return rows + + +def fallback_project_by_endpoint( + endpoint_names: list[str], + repo_servers: dict[str, dict], + scan_rows: list[dict[str, str]] | None, +) -> dict[str, dict[str, str]]: + fallback: dict[str, dict[str, str]] = {} + for row in scan_rows or []: + endpoint = row.get("endpoint", "") + if endpoint and endpoint not in fallback: + fallback[endpoint] = { + "remote": row.get("remote", ""), + "owner": row.get("org", ""), + "repo_name": row.get("repo", ""), + } + + for endpoint in endpoint_names: + if endpoint in fallback: + continue + repo_entry = repo_servers.get(endpoint) + if repo_entry is None: + continue + for row in repo_entry.get("tokens", []) + repo_entry.get("plain_remotes", []): + fallback[endpoint] = { + "remote": row.get("remote", ""), + "owner": row.get("org", ""), + "repo_name": row.get("repo", ""), + } + break + return fallback + + +def api_get_json(api_url: str, token_value: str, user_name: str = "", basic: bool = False) -> object: + headers = {"Accept": "application/json"} + if basic: + raw_credentials = f"{user_name}:{token_value}".encode("utf-8") + headers["Authorization"] = "Basic " + base64.b64encode(raw_credentials).decode("ascii") + else: + headers["Authorization"] = f"token {token_value}" + request = urlrequest.Request(api_url, headers=headers) + with urlrequest.urlopen(request, timeout=5) as response: + return json.loads(response.read().decode("utf-8")) + + +def scope_mask(scopes: list[str]) -> str: + scope_set = {scope.lower() for scope in scopes} + return "".join( + [ + "+" if "all" in scope_set else "-", + "+" if "write:repository" in scope_set else "-", + "+" if "read:repository" in scope_set else "-", + "+" if {"read:organization", "write:organization"} & scope_set else "-", + "+" if "public-only" in scope_set else "-", + ] + ) + + +def org_permission_mask(org_permissions: dict) -> str: + return "".join( + [ + "+" if org_permissions.get("is_owner") else "-", + "+" if org_permissions.get("is_admin") else "-", + "+" if org_permissions.get("can_write") else "-", + "+" if org_permissions.get("can_read") else "-", + "+" if org_permissions.get("can_create_repository") else "-", + ] + ) + + +def repo_permission_mask(repo_permissions: dict) -> str: + permission = str(repo_permissions.get("permission", "none")).lower() + levels = { + "owner": "++++", + "admin": "-+++", + "write": "--++", + "read": "---+", + } + return levels.get(permission, "----") + + +def load_gitea_authz(endpoint: str, user_name: str, token_name: str, token_value: str, owner: str, repo_name: str) -> dict: + api_base = endpoint.rstrip("/") + "/api/v1" + result = { + "ok": False, + "scope_mask": "!!!!!", + "org_mask": "!!!!!", + "repo_mask": "!!!!", + } + + try: + tokens_url = f"{api_base}/users/{quote(user_name, safe='')}/tokens" + token_list = api_get_json(tokens_url, token_value, user_name=user_name, basic=True) + token_last8 = token_value[-8:] + token_entry = next( + ( + entry + for entry in token_list + if isinstance(entry, dict) and entry.get("token_last_eight") == token_last8 + ), + None, + ) + scopes = token_entry.get("scopes", []) if isinstance(token_entry, dict) else [] + if not isinstance(scopes, list): + scopes = [] + result["scope_mask"] = scope_mask([str(scope) for scope in scopes]) + + if owner: + org_url = f"{api_base}/users/{quote(user_name, safe='')}/orgs/{quote(owner, safe='')}/permissions" + org_permissions = api_get_json(org_url, token_value) + if isinstance(org_permissions, dict): + result["org_mask"] = org_permission_mask(org_permissions) + + if owner and repo_name: + repo_url = ( + f"{api_base}/repos/{quote(owner, safe='')}/{quote(repo_name, safe='')}" + f"/collaborators/{quote(user_name, safe='')}/permission" + ) + repo_permissions = api_get_json(repo_url, token_value) + if isinstance(repo_permissions, dict): + result["repo_mask"] = repo_permission_mask(repo_permissions) + + result["ok"] = True + return result + except (OSError, ValueError, urlerror.URLError, urlerror.HTTPError): + return result + + def print_token_item_rows( endpoint_names: list[str], repo_servers: dict[str, dict], @@ -749,132 +979,100 @@ def print_token_item_rows( ) -> dict[str, int]: status_names = ["in_sync", "repo_only", "store_only", "repo_ahead", "store_ahead", "diverged"] status_counts = {status_name: 0 for status_name in status_names} - scan_rows_by_endpoint: dict[str, list[dict[str, str]]] = {} - for row in scan_rows or []: - endpoint = row.get("endpoint", "") - if endpoint: - scan_rows_by_endpoint.setdefault(endpoint, []).append(row) - - item_rows: list[dict[str, str]] = [] - for index, endpoint in enumerate(endpoint_names, start=1): - item_id = str(index) + for endpoint in endpoint_names: repo_entry = repo_servers.get(endpoint) store_entry = store_servers.get(endpoint) status_name, _, _ = compare_server_entries(repo_entry, store_entry) if status_name in status_counts: status_counts[status_name] += 1 - endpoint_values = endpoint_column_values(endpoint, repo_entry, store_entry) - repo_users_label = users_label(repo_entry) - store_users_label = users_label(store_entry) - repo_rows = scan_rows_by_endpoint.get(endpoint, []) - if repo_rows: - for row in repo_rows: - token_label = "remote" if row["url_kind"] == "auth" else "" - item_rows.append( - { - "item": item_id, - "source": "repo", - "id": row["remote"], - **endpoint_values, - "credential": row["url_kind"], - "repo_user": row["user"], - "store_user": store_users_label, - "token": "yes" if token_label else "no", - "sync": sync_mark(status_name, "repo"), - } - ) - elif repo_entry is None: - item_rows.append( - { - "item": item_id, - "source": "repo", - **endpoint_values, - "credential": "missing", - "store_user": store_users_label, - "token": "no", - "sync": sync_mark(status_name, "repo"), - } - ) - else: - repo_kind, repo_result = repo_result_for_entry(repo_entry) - remote_names = ",".join(sorted(repo_entry.get("remotes", set()))) - repo_users = token_name_rows(repo_entry.get("users", {})) - if repo_users: - for user_name, _ in repo_users: - token_label = "remote" if repo_kind == "auth" else "" - item_rows.append( - { - "item": item_id, - "source": "repo", - "id": remote_names, - **endpoint_values, - "credential": repo_kind, - "repo_user": user_name, - "store_user": store_users_label, - "token": "yes" if token_label else "no", - "sync": sync_mark(status_name, "repo"), - } - ) - else: - item_rows.append( - { - "item": item_id, - "source": "repo", - "id": remote_names, - **endpoint_values, - "credential": repo_kind, - "store_user": store_users_label, - "token": "yes" if repo_result == "present" else "no", - "sync": sync_mark(status_name, "repo"), - } - ) + store_rows = token_store_rows(store_servers) + store_by_value: dict[tuple[str, str, str], dict[str, str]] = {} + for row in store_rows: + store_by_value.setdefault((row["endpoint"], row["user"], row["token_value"]), row) - if store_entry is None: - item_rows.append( - { - "item": item_id, - "source": "tokens.json", - **endpoint_values, - "credential": "missing", - "repo_user": repo_users_label, - "token": "no", - "sync": sync_mark(status_name, "tokens.json"), - } - ) + endpoint_values_by_name = { + endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint)) + for endpoint in endpoint_names + } + project_fallback = fallback_project_by_endpoint(endpoint_names, repo_servers, scan_rows) + authz_cache: dict[tuple[str, str, str, str, str], dict] = {} + used_store_keys: set[tuple[str, str, str, str]] = set() + token_rows: list[dict[str, str]] = [] + + def append_token_row( + endpoint: str, + user_name: str, + remote_name: str, + token_name: str, + token_value: str, + marker: str, + owner: str, + repo_name: str, + authz: dict | None = None, + ) -> None: + endpoint_values = endpoint_values_by_name.get(endpoint, endpoint_column_values(endpoint, None, None)) + token_rows.append( + { + "item": str(len(token_rows) + 1), + "server": endpoint_values["server"], + "host": endpoint_values["host"], + "owner": owner, + "repo_name": repo_name, + "user": user_name, + "remote": remote_name, + "token_ref": format_token_ref(token_name, marker), + "token": short_secret(token_value), + "scope_mask": authz.get("scope_mask", "?????") if authz else "?????", + "org_mask": authz.get("org_mask", "?????") if authz else "?????", + "repo_mask": authz.get("repo_mask", "????") if authz else "????", + } + ) + + for row in repo_token_rows(endpoint_names, repo_servers, scan_rows): + endpoint = row["endpoint"] + token_value = row["token_value"] + store_row = store_by_value.get((endpoint, row["user"], token_value)) + token_name = store_row["token_name"] if store_row else "" + marker = "R" + authz = None + if store_row: + used_store_keys.add((endpoint, store_row["user"], store_row["token_name"], token_value)) + authz_key = (endpoint, row["user"], token_value, row["owner"], row["repo_name"]) + authz = authz_cache.get(authz_key) + if authz is None: + authz = load_gitea_authz(endpoint, row["user"], token_name, token_value, row["owner"], row["repo_name"]) + authz_cache[authz_key] = authz + marker = "*" if authz.get("ok") else "!" + append_token_row( + endpoint, + row["user"], + row["remote"], + token_name, + token_value, + marker, + row["owner"], + row["repo_name"], + authz if marker == "*" else None, + ) + + for row in store_rows: + store_key = (row["endpoint"], row["user"], row["token_name"], row["token_value"]) + if store_key in used_store_keys: continue + fallback = project_fallback.get(row["endpoint"], {}) + append_token_row( + row["endpoint"], + row["user"], + "", + row["token_name"], + row["token_value"], + "S", + fallback.get("owner", ""), + fallback.get("repo_name", ""), + ) - store_users = token_name_rows(store_entry.get("users", {})) - if not store_users: - item_rows.append( - { - "item": item_id, - "source": "tokens.json", - **endpoint_values, - "credential": "store", - "repo_user": repo_users_label, - "token": "no", - "sync": sync_mark(status_name, "tokens.json"), - } - ) - continue - - for user_name, token_name in store_users: - item_rows.append( - { - "item": item_id, - "source": "tokens.json", - "credential": "store", - "id": token_name, - **endpoint_values, - "repo_user": repo_users_label, - "store_user": user_name, - "token": "yes" if token_name else "no", - "sync": sync_mark(status_name, "tokens.json"), - } - ) - - print_token_item_table(item_rows) + print_fixed_table("tokens", TOKEN_COLUMNS, token_rows) return status_counts @@ -972,22 +1170,6 @@ def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None: token_data = load_token_store(config) store_servers = token_data.get("servers", {}) endpoint_names = sorted(set(repo_servers) | set(store_servers)) - - if args.verbose: - print_token_context(repo_root, config, repo_servers, store_servers, len(endpoint_names)) - print() - print("scan") - print("item\tvalue") - print(f"remotes\t{report['remotes']}") - print(f"urls\t{report['urls']}") - print(f"scanned\t{report['scanned']}") - print(f"auth_urls\t{report['auth_urls']}") - print(f"plain_urls\t{report['plain_urls']}") - print(f"unsupported_urls\t{report['unsupported_urls']}") - print(f"found\t{report['found']}") - print(f"added\t{report['added']}") - print(f"servers\t{report['servers']}") - print() print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) @@ -1386,7 +1568,7 @@ def print_main_overview(config_path: Path) -> None: print_table( ["step", "command", "result"], [ - ["1", "./rvctl tokens scan", "pair repo remotes with tokens.json entries"], + ["1", "./rvctl tokens scan", "print token table from repo remotes and tokens.json"], ["2", "./rvctl list-series", "choose a series"], ["3", "./rvctl list-cards inf", "choose a card"], ["4", "./rvctl tmux-container inf ", "start the working container"], @@ -1413,7 +1595,7 @@ def print_tokens_overview() -> None: print_table( ["command", "common options", "direction", "purpose"], [ - ["scan", "[--repo PATH]", "repo + tokens.json", "pair endpoints and show where token refs are stored"], + ["scan", "[--repo PATH]", "repo + tokens.json", "print token table with remote/store marker and auth masks"], ["read", "[--server ENDPOINT]", "tokens.json", "show servers, users and token names"], ["stats", "[--repo PATH]", "repo + tokens.json", "compare remote URLs with local token store"], ["write", "--remote R [--replace]", "tokens.json -> repo", "write selected token into a remote URL"], @@ -1524,7 +1706,6 @@ def build_parser() -> argparse.ArgumentParser: help="Scan repo remotes and tokens.json, then print paired endpoint rows.", ) add_repo_option(tokens_scan_parser) - tokens_scan_parser.add_argument("--verbose", action="store_true", help="Print context and scan counters before item rows.") tokens_read_parser = tokens_subparsers.add_parser( "read",