From 1a66e161aa788998b7601347ebceb91eabfa13f8 Mon Sep 17 00:00:00 2001 From: mpabi Date: Sun, 26 Apr 2026 21:12:16 +0200 Subject: [PATCH] Make token scan read-only --- README.md | 4 +- doc/tokens.md | 36 ++++++--- rvctl.py | 202 +++++++++++++++++++++++++++++++++++--------------- 3 files changed, 171 insertions(+), 71 deletions(-) diff --git a/README.md b/README.md index f3f8a44..e1f561f 100644 --- a/README.md +++ b/README.md @@ -156,11 +156,13 @@ Podstawowe komendy tokenow: ./rvctl tokens stats --repo ~/dev/workspace/rv/series/inf/03 ``` -`tokens scan` wypisuje tabele `tokens`: jeden wiersz na logiczny token. +`tokens scan` wypisuje tabele `tokens` i niczego nie zapisuje: jeden wiersz na +logiczny remote tokena. `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 `????`. +Zapis z remote do `tokens.json` robi dopiero `tokens update --from remotes`. Kolumna `valid` oznacza, czy token zostal zaakceptowany przez API teraz. Jesli przy tokenie w `tokens.json` zapiszesz `expires_at`, `valid` pokaze date wygasniecia; bez daty poprawny token pokazuje `forever`. diff --git a/doc/tokens.md b/doc/tokens.md index b1503e7..fc56413 100644 --- a/doc/tokens.md +++ b/doc/tokens.md @@ -10,7 +10,8 @@ Sa dwa miejsca, w ktorych moga byc zapisane tokeny: - lokalny plik `~/dev/workspace/rv/tokens/tokens.json` Jesli remote URL zawiera `LOGIN:TOKEN@...`, to remote jest zrodlem prawdy. -Launcher moze wtedy zeskanowac repo i zapisac ten token do `tokens.json`. +Launcher moze wtedy pokazac stan przez `tokens scan` albo jawnie zapisac token +do `tokens.json` przez `tokens update --from remotes`. Jesli remote nie ma tokena, `tokens.json` moze byc uzyty jako lokalny store przy operacjach `tokens write` i `tokens update --from store`. @@ -34,6 +35,9 @@ Tokeny sa trzymane per endpoint serwera, a dopiero pod nim per user i token: "t1": "SECRET", "t2": { "value": "SECRET", + "remote": "r1", + "org": "edu-tools", + "repo": "rv-launcher", "expires_at": "2026-05-01T12:00:00" } } @@ -50,6 +54,8 @@ To pozwala odroznic: - endpoint, czyli `scheme + host + port` - uzytkownikow na danym serwerze - wiele tokenow dla jednego usera +- nazwe remota, ktora jest identyfikatorem parowania z repo +- opcjonalne `org` i `repo` zapamietane z remote URL-a - opcjonalna date wygasniecia `expires_at` dla tokena ## Skanowanie remota @@ -58,10 +64,10 @@ Przy `tokens scan` launcher: - czyta wszystkie remote URL-e w repo - czyta `tokens.json` -- laczy remote i store w pary po endpoincie serwera +- laczy remote i store w pary po endpoincie serwera i nazwie remota - wybiera tylko `http` i `https` - jesli URL ma `LOGIN:TOKEN@...`, wyciaga login i token -- zapisuje je pod odpowiednim endpointem w `tokens.json` +- niczego nie zapisuje do `tokens.json` Endpoint jest liczony z: @@ -69,7 +75,7 @@ Endpoint jest liczony z: - host - port -Przy skanowaniu launcher zapisuje tez metadane serwera: +Przy `tokens update --from remotes` launcher zapisuje tez metadane serwera: - `type` - `scheme` @@ -90,17 +96,15 @@ Dzialanie: - skanuje remote URL-e w repo - czyta wpisy z `tokens.json` -- laczy oba zrodla po endpoincie serwera -- zapisuje znalezione tokeny do `tokens.json` -- zapisuje je per endpoint serwera +- laczy oba zrodla po endpoincie serwera i nazwie remota - wypisuje tabele `tokens` -Tabela `tokens` pokazuje jeden logiczny wiersz na token. Remote i `tokens.json` -sa laczone po endpoincie, userze i wartosci tokena. +Tabela `tokens` pokazuje jeden logiczny wiersz na remote tokena. Remote i +`tokens.json` sa laczone po endpoincie serwera oraz kolumnie `remote`. Znacznik w `token_ref`: -- `*` - token jest w remote i `tokens.json`, uprawnienia zostaly wczytane +- `*` - token jest w remote i `tokens.json`, remote/user/token sa zgodne, uprawnienia zostaly wczytane - `R` - token jest tylko w remote - `S` - token jest tylko w `tokens.json` - `!` - blad wczytania uprawnien dla sparowanego tokena @@ -140,6 +144,10 @@ przyklad `e59cc...13be`. - `?` - token nie jest sparowany jako `*`, wiec nie sprawdzamy uprawnien - `!` - blad sprawdzania API +`tokens scan` jest read-only. Jezeli token jest tylko w remote, tabela pokaze +`R` i maski `?????`/`????`. Dopiero jawne `tokens update --from remotes` +zapisuje token oraz metadane `remote`, `org` i `repo` do `tokens.json`. + Przyklad: ```bash @@ -160,6 +168,7 @@ Wynik zawiera: - port - users - tokens +- remote/org/repo przy tokenie, jezeli sa zapisane Oraz liste userow i nazw tokenow dla kazdego endpointu. @@ -226,6 +235,10 @@ Dozwolone kierunki: - `tokens update --from remotes` - `tokens update --from store` +`tokens update --from remotes` kopiuje tokeny z remote URL-i repo do +`tokens.json`. `tokens update --from store` kopiuje wybrany token z +`tokens.json` do remote URL-a repo. + Przyklad: ```bash @@ -240,7 +253,7 @@ Domyslnie launcher nie zgaduje przy konflikcie. Jesli: - token jest w repo, ale nie ma go w `tokens.json` - uzyj `tokens scan` + uzyj `tokens update --from remotes` - token jest w `tokens.json`, ale nie ma go w repo uzyj `tokens write` - token jest i tu, i tu, ale wartosci sa rozne @@ -256,6 +269,7 @@ Przy `tokens write` i `tokens update --from store` mozna uzyc: Najbezpieczniejszy model pracy: - `tokens scan` do zczytywania danych z remote'ow +- `tokens update --from remotes` do jawnego zapisania tokenow z remote'ow w `tokens.json` - `tokens read` do podgladu store - `tokens stats` do zbiorczego przegladu per endpoint - `tokens write` do jawnego wpisania auth do remota diff --git a/rvctl.py b/rvctl.py index dca98a4..7ffc369 100644 --- a/rvctl.py +++ b/rvctl.py @@ -303,6 +303,10 @@ def normalize_token_entry(raw_token_entry) -> str | dict | None: expires_at = raw_token_entry.get("expires_at") if isinstance(expires_at, str) and expires_at: token_entry["expires_at"] = expires_at + for field_name in ["remote", "org", "repo"]: + field_value = raw_token_entry.get(field_name) + if isinstance(field_value, str) and field_value: + token_entry[field_name] = field_value return token_entry @@ -320,6 +324,12 @@ def token_entry_expires_at(token_entry) -> str: return "" +def token_entry_field(token_entry, field_name: str) -> str: + if isinstance(token_entry, dict) and isinstance(token_entry.get(field_name), str): + return token_entry[field_name] + return "" + + def token_value_exists(tokens: dict, token_value: str) -> bool: return any(token_entry_value(existing_token) == token_value for existing_token in tokens.values()) @@ -431,7 +441,7 @@ def normalize_token_store(config: WorkspaceConfig, raw_data: dict) -> dict: return token_data -def load_token_store(config: WorkspaceConfig) -> dict: +def load_token_store(config: WorkspaceConfig, write_normalized: bool = True) -> dict: if not config.token_path.exists(): return empty_token_store() @@ -439,7 +449,7 @@ def load_token_store(config: WorkspaceConfig) -> dict: if not isinstance(raw_data, dict): raise SystemExit(f"Invalid token store format: {config.token_path}") normalized_data = normalize_token_store(config, raw_data) - if raw_data != normalized_data: + if write_normalized and raw_data != normalized_data: write_token_store(config, normalized_data) return normalized_data @@ -455,17 +465,58 @@ def write_token_store(config: WorkspaceConfig, token_data: dict) -> None: os.chmod(config.token_path, 0o600) -def register_token(token_data: dict, server_info: dict, user_name: str, token_value: str) -> bool: +def token_entry_with_metadata(token_value: str, server_info: dict, remote_name: str) -> dict[str, str]: + token_entry = {"value": token_value} + for source_name, target_name in [("org", "org"), ("repo", "repo")]: + source_value = server_info.get(source_name) + if isinstance(source_value, str) and source_value: + token_entry[target_name] = source_value + if remote_name: + token_entry["remote"] = remote_name + return token_entry + + +def update_token_entry_metadata(token_entry, token_value: str, server_info: dict, remote_name: str) -> tuple[dict, bool]: + if isinstance(token_entry, dict): + updated_entry = dict(token_entry) + else: + updated_entry = {"value": token_value} + + changed = updated_entry.get("value") != token_value + updated_entry["value"] = token_value + for source_name, target_name in [("org", "org"), ("repo", "repo")]: + source_value = server_info.get(source_name) + if isinstance(source_value, str) and source_value and updated_entry.get(target_name) != source_value: + updated_entry[target_name] = source_value + changed = True + if remote_name and updated_entry.get("remote") != remote_name: + updated_entry["remote"] = remote_name + changed = True + return updated_entry, changed + + +def register_token(token_data: dict, server_info: dict, remote_name: str, user_name: str, token_value: str) -> str: server_entry = ensure_server_entry(token_data, server_info) users = server_entry.setdefault("users", {}) user_entry = users.setdefault(user_name, {"tokens": {}}) tokens = user_entry.setdefault("tokens", {}) - if token_value_exists(tokens, token_value): - return False + for token_name, token_entry in list(tokens.items()): + if token_entry_field(token_entry, "remote") != remote_name: + continue + updated_entry, changed = update_token_entry_metadata(token_entry, token_value, server_info, remote_name) + tokens[token_name] = updated_entry + return "updated" if changed else "existing" - tokens[next_token_name(tokens)] = token_value - return True + for token_name, token_entry in list(tokens.items()): + if token_entry_value(token_entry) != token_value: + continue + updated_entry, changed = update_token_entry_metadata(token_entry, token_value, server_info, remote_name) + tokens[token_name] = updated_entry + return "updated" if changed else "existing" + + tokens[next_token_name(tokens)] = token_entry_with_metadata(token_value, server_info, remote_name) + return "added" def remote_urls(repo_path: Path) -> list[str]: @@ -502,7 +553,7 @@ def remote_credentials(remote_url: str) -> tuple[str, str] | None: return unquote(split_url.username), unquote(split_url.password) -def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: +def scan_repo_remotes(config: WorkspaceConfig, repo_path: Path) -> dict: repo_root = git_repo_root(repo_path) if repo_root is None: return { @@ -515,12 +566,14 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "unsupported_urls": 0, "found": 0, "added": 0, + "updated": 0, "servers": 0, "remote_rows": [], + "found_credentials": [], "changed": False, } - found_credentials: list[tuple[int, dict, str, str]] = [] + found_credentials: list[tuple[int, dict, str, str, str]] = [] remote_entries = remote_name_urls(repo_root) remote_names = {remote_name for remote_name, _ in remote_entries} touched_servers: set[str] = set() @@ -582,19 +635,7 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "result": "found", } ) - found_credentials.append((row_index, server_info, user_name, token_value)) - - token_data = load_token_store(config) - added = 0 - for row_index, server_info, user_name, token_value in found_credentials: - if register_token(token_data, server_info, user_name, token_value): - added += 1 - remote_rows[row_index]["result"] = "added" - else: - remote_rows[row_index]["result"] = "existing" - - if found_credentials and (added or not config.token_path.exists()): - write_token_store(config, token_data) + found_credentials.append((row_index, server_info, remote_name, user_name, token_value)) return { "repo_root": repo_root, @@ -605,13 +646,42 @@ def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: "plain_urls": plain_urls, "unsupported_urls": unsupported_urls, "found": len(found_credentials), - "added": added, + "added": 0, + "updated": 0, "servers": len(touched_servers), "remote_rows": remote_rows, - "changed": added > 0, + "found_credentials": found_credentials, + "changed": False, } +def sync_tokens_from_repo(config: WorkspaceConfig, repo_path: Path) -> dict: + report = scan_repo_remotes(config, repo_path) + found_credentials = report.get("found_credentials", []) + if not found_credentials: + return report + + token_data = load_token_store(config) + added = 0 + updated = 0 + for row_index, server_info, remote_name, user_name, token_value in found_credentials: + result = register_token(token_data, server_info, remote_name, user_name, token_value) + if result == "added": + added += 1 + elif result == "updated": + updated += 1 + report["remote_rows"][row_index]["result"] = result + + changed = added > 0 or updated > 0 + if changed or not config.token_path.exists(): + write_token_store(config, token_data) + + report["added"] = added + report["updated"] = updated + report["changed"] = changed + return report + + def count_server_tokens(server_entry: dict) -> int: return sum(len(user_entry.get("tokens", {})) for user_entry in server_entry.get("users", {}).values()) @@ -627,15 +697,15 @@ def token_store_servers(token_data: dict) -> list[tuple[str, dict]]: return sorted(servers.items()) -def token_pairs_from_users(users_data: dict) -> set[tuple[str, str]]: - token_pairs: set[tuple[str, str]] = set() +def token_pairs_from_users(users_data: dict) -> set[tuple[str, str, str]]: + token_pairs: set[tuple[str, str, str]] = set() for user_name, user_entry in users_data.items(): if not isinstance(user_entry, dict): continue for token_entry in user_entry.get("tokens", {}).values(): token_value = token_entry_value(token_entry) if token_value: - token_pairs.add((str(user_name), token_value)) + token_pairs.add((token_entry_field(token_entry, "remote"), str(user_name), token_value)) return token_pairs @@ -703,7 +773,7 @@ def collect_repo_server_entries(config: WorkspaceConfig, repo_path: Path) -> tup user_entry = entry["users"].setdefault(user_name, {"tokens": {}}) tokens = user_entry.setdefault("tokens", {}) if not token_value_exists(tokens, token_value): - tokens[next_token_name(tokens)] = token_value + tokens[next_token_name(tokens)] = token_entry_with_metadata(token_value, server_info, remote_name) return repo_root, repo_servers @@ -878,6 +948,9 @@ def token_store_rows(store_servers: dict[str, dict]) -> list[dict[str, str]]: "endpoint": endpoint, "user": str(user_name), "token_name": str(token_name), + "remote": token_entry_field(token_entry, "remote"), + "owner": token_entry_field(token_entry, "org"), + "repo_name": token_entry_field(token_entry, "repo"), "token_value": token_value, "expires_at": token_entry_expires_at(token_entry), } @@ -1078,9 +1151,10 @@ def print_token_item_rows( status_counts[status_name] += 1 store_rows = token_store_rows(store_servers) - store_by_value: dict[tuple[str, str, str], dict[str, str]] = {} + store_by_remote: dict[tuple[str, str], dict[str, str]] = {} for row in store_rows: - store_by_value.setdefault((row["endpoint"], row["user"], row["token_value"]), row) + if row["remote"]: + store_by_remote.setdefault((row["endpoint"], row["remote"]), row) endpoint_values_by_name = { endpoint: endpoint_column_values(endpoint, repo_servers.get(endpoint), store_servers.get(endpoint)) @@ -1125,27 +1199,28 @@ def print_token_item_rows( 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)) + store_row = store_by_remote.get((endpoint, row["remote"])) token_name = store_row["token_name"] if store_row else "" expires_at = store_row.get("expires_at", "") 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, expires_at, 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, - expires_at, - row["owner"], - row["repo_name"], - ) - authz_cache[authz_key] = authz - marker = "*" if authz.get("ok") else "!" + used_store_keys.add((endpoint, store_row["user"], store_row["token_name"], store_row["token_value"])) + if store_row["user"] == row["user"] and store_row["token_value"] == token_value: + authz_key = (endpoint, row["user"], token_value, expires_at, 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, + expires_at, + row["owner"], + row["repo_name"], + ) + authz_cache[authz_key] = authz + marker = "*" if authz.get("ok") else "!" append_token_row( endpoint, row["user"], @@ -1166,12 +1241,12 @@ def print_token_item_rows( append_token_row( row["endpoint"], row["user"], - "", + row["remote"], row["token_name"], row["token_value"], "S", - fallback.get("owner", ""), - fallback.get("repo_name", ""), + row["owner"] or fallback.get("owner", ""), + row["repo_name"] or fallback.get("repo_name", ""), ) print_fixed_table("tokens", TOKEN_COLUMNS, token_rows) @@ -1268,16 +1343,16 @@ def resolve_repo_argument(repo_arg: str | None) -> Path: def run_tokens_scan(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_path = resolve_repo_argument(args.repo) - report = sync_tokens_from_repo(config, repo_path) - repo_root, repo_servers = collect_repo_server_entries(config, repo_path) - token_data = load_token_store(config) + token_data = load_token_store(config, write_normalized=False) store_servers = token_data.get("servers", {}) + report = scan_repo_remotes(config, repo_path) + repo_root, repo_servers = collect_repo_server_entries(config, repo_path) endpoint_names = sorted(set(repo_servers) | set(store_servers)) print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: - token_data = load_token_store(config) + token_data = load_token_store(config, write_normalized=False) servers = token_store_servers(token_data) if args.server: server_entry = token_data.get("servers", {}).get(args.server) @@ -1309,6 +1384,10 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: token_value = token_entry_value(token_entry) secret_value = token_value if args.show_secrets else mask_secret(token_value) print(f"token\t{token_name}\t{secret_value}") + for field_name in ["remote", "org", "repo"]: + field_value = token_entry_field(token_entry, field_name) + if field_value: + print(f"{field_name}\t{token_name}\t{field_value}") expires_at = token_entry_expires_at(token_entry) if expires_at: print(f"expires_at\t{token_name}\t{expires_at}") @@ -1316,7 +1395,7 @@ def run_tokens_read(config: WorkspaceConfig, args: argparse.Namespace) -> None: def run_tokens_stats(config: WorkspaceConfig, args: argparse.Namespace) -> None: repo_root, repo_servers = collect_repo_server_entries(config, resolve_repo_argument(args.repo)) - token_data = load_token_store(config) + token_data = load_token_store(config, write_normalized=False) store_servers = token_data.get("servers", {}) endpoint_names = sorted(set(repo_servers) | set(store_servers)) if args.server: @@ -1381,7 +1460,13 @@ def run_tokens_write(config: WorkspaceConfig, args: argparse.Namespace) -> None: def run_tokens_update(config: WorkspaceConfig, args: argparse.Namespace) -> None: if args.from_source == "remotes": - run_tokens_scan(config, args) + repo_path = resolve_repo_argument(args.repo) + report = sync_tokens_from_repo(config, repo_path) + repo_root, repo_servers = collect_repo_server_entries(config, repo_path) + token_data = load_token_store(config, write_normalized=False) + store_servers = token_data.get("servers", {}) + endpoint_names = sorted(set(repo_servers) | set(store_servers)) + print_token_item_rows(endpoint_names, repo_servers, store_servers, report["remote_rows"]) return if args.from_source == "store": run_tokens_write(config, args) @@ -1702,11 +1787,11 @@ def print_tokens_overview() -> None: print_table( ["command", "common options", "direction", "purpose"], [ - ["scan", "[--repo PATH]", "repo + tokens.json", "print token table with remote/store marker and auth masks"], + ["scan", "[--repo PATH]", "read-only", "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"], - ["update", "--from remotes|store", "selected", "run scan or write with explicit direction"], + ["update", "--from remotes|store", "selected", "copy token data in an explicit direction"], ], ) print() @@ -1810,7 +1895,7 @@ def build_parser() -> argparse.ArgumentParser: tokens_scan_parser = tokens_subparsers.add_parser( "scan", - help="Scan repo remotes and tokens.json, then print paired endpoint rows.", + help="Read repo remotes and tokens.json, then print paired token rows.", ) add_repo_option(tokens_scan_parser) @@ -1872,7 +1957,6 @@ def main() -> int: parser = build_parser() args = parser.parse_args() config = load_config(args.config) - sync_tokens_from_repo(config, Path.cwd()) if args.command == "show-config": print_config(config)