101 lines
3.6 KiB
Python
101 lines
3.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Revoke a Fleet Ops demo bearer token by jti before its natural expiry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tempfile
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
_JTI_PATTERN = re.compile(r"^[0-9a-f]{32}$")
|
|
|
|
|
|
def _validate_jti(jti: str) -> str:
|
|
if not _JTI_PATTERN.fullmatch(jti):
|
|
raise ValueError(
|
|
"jti must be the 32-character lowercase hexadecimal value emitted at mint time"
|
|
)
|
|
return jti
|
|
|
|
|
|
def _load_document(path: Path) -> dict[str, object]:
|
|
if not path.exists():
|
|
return {"schemaVersion": "1.0", "revokedJtis": []}
|
|
if not path.is_file():
|
|
raise ValueError("revocations file is not a regular file")
|
|
try:
|
|
document = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError) as exc:
|
|
raise ValueError("revocations file is invalid JSON") from exc
|
|
revoked = document.get("revokedJtis") if isinstance(document, dict) else None
|
|
if (
|
|
not isinstance(document, dict)
|
|
or document.get("schemaVersion") != "1.0"
|
|
or not isinstance(revoked, list)
|
|
or not all(isinstance(item, str) and _JTI_PATTERN.fullmatch(item) for item in revoked)
|
|
):
|
|
raise ValueError("revocations file does not match schemaVersion 1.0")
|
|
return cast(dict[str, object], document)
|
|
|
|
|
|
def _atomic_write(path: Path, document: dict[str, object]) -> None:
|
|
if not path.parent.is_dir():
|
|
raise ValueError("revocations file parent directory does not exist")
|
|
existing_metadata = path.stat() if path.exists() else None
|
|
temporary_name: str | None = None
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", delete=False
|
|
) as temporary:
|
|
temporary_name = temporary.name
|
|
os.fchmod(temporary.fileno(), 0o600)
|
|
if existing_metadata is not None and os.name != "nt":
|
|
fchown = getattr(os, "fchown", None)
|
|
if not callable(fchown):
|
|
raise ValueError("platform cannot preserve revocation file ownership")
|
|
fchown(temporary.fileno(), existing_metadata.st_uid, existing_metadata.st_gid)
|
|
json.dump(document, temporary, indent=2)
|
|
temporary.write("\n")
|
|
temporary.flush()
|
|
os.fsync(temporary.fileno())
|
|
os.replace(temporary_name, path)
|
|
finally:
|
|
if temporary_name is not None and Path(temporary_name).exists():
|
|
Path(temporary_name).unlink()
|
|
|
|
|
|
def revoke(path: Path, jti: str) -> list[str]:
|
|
document = _load_document(path)
|
|
current = cast(list[str], document["revokedJtis"])
|
|
revoked = {_validate_jti(item) for item in current}
|
|
revoked.add(_validate_jti(jti))
|
|
updated: dict[str, object] = {"schemaVersion": "1.0", "revokedJtis": sorted(revoked)}
|
|
_atomic_write(path, updated)
|
|
return cast(list[str], updated["revokedJtis"])
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--jti", required=True)
|
|
parser.add_argument(
|
|
"--revocations-file",
|
|
type=Path,
|
|
required=True,
|
|
help="Path to the HOST-side revocations file -- MUST be the exact path set as "
|
|
"DEMO_TOKEN_REVOCATIONS_FILE_HOST in the operator env file. "
|
|
"A mismatch here means revocation silently does nothing.",
|
|
)
|
|
args = parser.parse_args()
|
|
revoked = revoke(args.revocations_file, args.jti)
|
|
print(f"revocation recorded: count={len(revoked)} file={args.revocations_file}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|