Make token scan read-only

This commit is contained in:
mpabi
2026-04-26 21:12:16 +02:00
parent f92a7c8257
commit 1a66e161aa
3 changed files with 171 additions and 71 deletions
+3 -1
View File
@@ -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`.
+25 -11
View File
@@ -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
+129 -45
View File
@@ -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,13 +1199,14 @@ 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))
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:
@@ -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)