installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store
This commit is contained in:
@@ -76,7 +76,11 @@ class Config:
|
||||
def __init__(self, env):
|
||||
self.hub_url = env.get("GTI_HUB_URL") or env.get("HUB_URL") or "http://localhost:8000"
|
||||
self.agent_id = env.get("AGENT_ID", "")
|
||||
self.agent_token = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN", "")
|
||||
self.credential_file = env.get("GTI_CREDENTIAL_FILE") or str(Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin")
|
||||
# The permanent token NEVER lives in agent.env: it is decrypted from
|
||||
# credentials.bin (DPAPI LocalMachine + SYSTEM/Administrators ACL). An
|
||||
# explicit AGENT_TOKEN/AGENT_DEV_TOKEN in env wins (dev override only).
|
||||
self.agent_token = self._resolve_agent_token(env)
|
||||
self.company_id = env.get("COMPANY_ID", "")
|
||||
self.company_uf = env.get("COMPANY_UF", "")
|
||||
self.database_path = env["FIREBIRD_DATABASE_PATH"]
|
||||
@@ -98,6 +102,22 @@ class Config:
|
||||
self.expected_schema_fingerprint = env.get("EXPECTED_SCHEMA_FINGERPRINT", REFERENCE_SCHEMA_V1)
|
||||
self.log_path = env.get("AGENT_LOG_PATH", str(AGENT_ROOT / "logs" / "agent.log"))
|
||||
|
||||
@staticmethod
|
||||
def _resolve_agent_token(env):
|
||||
"""DPAPI credential from credentials.bin; explicit AGENT_TOKEN env wins
|
||||
(dev override). Fail closed: never log or fabricate a token."""
|
||||
from gti_fiscal_agent.credential_store import install_env_from_credentials
|
||||
|
||||
explicit = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
|
||||
if explicit:
|
||||
return explicit
|
||||
try:
|
||||
return install_env_from_credentials(env.get("GTI_CREDENTIAL_FILE") or str(
|
||||
Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "GTI" / "FiscalAgent" / "credentials.bin"), env)
|
||||
except Exception as exc: # noqa: BLE001 — fail closed: no token, no heartbeat
|
||||
logger.warning("no usable agent credential: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
class GracefulStop:
|
||||
def __init__(self):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Windows DPAPI credential store for the GTI Fiscal Agent.
|
||||
|
||||
The permanent Agent credential (AGENT_TOKEN) must NEVER live in `agent.env`
|
||||
(which is world-readable config). It is stored in a dedicated
|
||||
`credentials.bin` file, encrypted with DPAPI in LocalMachine scope
|
||||
(`CRYPTPROTECT_LOCAL_MACHINE`), so any local Windows account on the host can
|
||||
decrypt it — and the file ACL is tightened to SYSTEM/Administrators only via
|
||||
`icacls`. The token is decrypted in-process at runtime by the service host and
|
||||
never logged.
|
||||
|
||||
`write_credential`/`read_credential` are shared by Setup.exe (writes at
|
||||
install time) and the Service host (reads at every start).
|
||||
|
||||
Fallback: a plaintext token file is accepted ONLY in development, guarded by
|
||||
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1`. Production Setup.exe never enables it.
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
_PROGRAM_DIR = Path("C:/Program Files/GTI/FiscalAgent")
|
||||
_MAGIC = b"GTIDPAPI1"
|
||||
_DEFAULT_FLAGS = 0x1 | 0x4 # CRYPTPROTECT_UI_FORBIDDEN | CRYPTPROTECT_LOCAL_MACHINE
|
||||
|
||||
|
||||
class CredentialStoreError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _DpapiLazy:
|
||||
"""Lazy wrapper around win32crypt so importing this module (and the whole
|
||||
agent package) works on non-Windows hosts without pywin32. Functions accept
|
||||
the same signatures pywin32 exposes on this machine's build, tolerating both
|
||||
the tuple and the bytes return shapes seen across pywin32 versions."""
|
||||
|
||||
def __init__(self):
|
||||
self._crypt = None
|
||||
|
||||
def _api(self):
|
||||
if self._crypt is None:
|
||||
try:
|
||||
import win32crypt
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise CredentialStoreError(f"win32crypt unavailable: {exc}") from exc
|
||||
self._crypt = win32crypt
|
||||
return self._crypt
|
||||
|
||||
def protect(self, data):
|
||||
crypt = self._api()
|
||||
returned = crypt.CryptProtectData(data, "gti-fiscal-agent-credential", None, None, None, _DEFAULT_FLAGS)
|
||||
if isinstance(returned, tuple):
|
||||
returned, _ = returned
|
||||
return bytes(returned)
|
||||
|
||||
def unprotect(self, blob):
|
||||
crypt = self._api()
|
||||
returned = crypt.CryptUnprotectData(bytes(blob), None, None, None, 0)
|
||||
if isinstance(returned, tuple):
|
||||
_, returned = returned
|
||||
return bytes(returned)
|
||||
|
||||
|
||||
_dpapi = _DpapiLazy()
|
||||
|
||||
|
||||
def _dpapi_available() -> bool:
|
||||
try:
|
||||
_dpapi._api()
|
||||
return True
|
||||
except CredentialStoreError:
|
||||
return False
|
||||
|
||||
|
||||
def default_credential_path() -> Path:
|
||||
return _PROGRAM_DIR / "credentials.bin"
|
||||
|
||||
|
||||
def _apply_acl(path: Path) -> None:
|
||||
"""Tighten ACL so only SYSTEM and Administrators can read/modify the blob.
|
||||
Well-known SIDs are used (`*S-1-5-18` = SYSTEM, `*S-1-5-32-544` = BUILTIN\
|
||||
Administrators) because account-name mapping is locale-dependent and fails
|
||||
on non-English Windows. Setup.exe runs elevated (`--uac-admin`), so icacls
|
||||
succeeds (the owner — the installing administrator — can change the DACL)."""
|
||||
cmd = ["icacls", str(path), "/inheritance:r", "/grant:r", "*S-1-5-18:(F)", "*S-1-5-32-544:(F)"]
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
except FileNotFoundError as exc: # non-Windows host without icacls
|
||||
raise CredentialStoreError(f"icacls unavailable: {exc}") from exc
|
||||
if result.returncode != 0:
|
||||
raise CredentialStoreError(f"icacls failed: {result.stdout} {result.stderr}")
|
||||
|
||||
|
||||
def write_credential(path, payload: str) -> None:
|
||||
"""Encrypt `payload` with DPAPI LocalMachine and write `path` with a
|
||||
SYSTEM/Administrators-only ACL. Fails closed: no plaintext file is ever
|
||||
created on this path."""
|
||||
if isinstance(payload, str):
|
||||
payload = payload.encode("utf-8")
|
||||
if not _dpapi_available():
|
||||
raise CredentialStoreError("DPAPI unavailable; refusing to store the credential as plaintext (fail closed)")
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
blob = _MAGIC + _dpapi.protect(payload)
|
||||
path.write_bytes(blob)
|
||||
try:
|
||||
_apply_acl(path)
|
||||
except CredentialStoreError:
|
||||
path.unlink(missing_ok=True) # do not leave a credential file without its ACL
|
||||
raise
|
||||
|
||||
|
||||
def read_credential(path, *, env=None) -> str:
|
||||
"""Decrypt `path`. A plaintext file is honored ONLY when
|
||||
`GTI_ALLOW_PLAINTEXT_CREDENTIAL=1` is set in `env`/environment (development)."""
|
||||
env = env if env is not None else os.environ
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise CredentialStoreError(f"credential file missing: {path}")
|
||||
raw = path.read_bytes()
|
||||
if raw.startswith(_MAGIC):
|
||||
return _dpapi.unprotect(raw[len(_MAGIC):]).decode("utf-8")
|
||||
if (env.get("GTI_ALLOW_PLAINTEXT_CREDENTIAL") or "").strip() != "1":
|
||||
raise CredentialStoreError("credential file is plaintext and GTI_ALLOW_PLAINTEXT_CREDENTIAL is not set; refusing (fail closed)")
|
||||
return raw.decode("utf-8").strip()
|
||||
|
||||
|
||||
def install_env_from_credentials(path, env) -> str:
|
||||
"""Fill `env["AGENT_TOKEN"]` from `credentials.bin` unless the env already
|
||||
carries an explicit token (which wins). Returns the effective token —
|
||||
WITHOUT writing it into `agent.env` — and never logs it."""
|
||||
existing = env.get("AGENT_TOKEN") or env.get("AGENT_DEV_TOKEN") or ""
|
||||
if existing:
|
||||
return existing
|
||||
token = read_credential(path, env=env)
|
||||
env["AGENT_TOKEN"] = token
|
||||
return token
|
||||
@@ -0,0 +1,179 @@
|
||||
"""DPAPI LocalMachine credential store: encrypt/decrypt round-trip, plaintext
|
||||
fallback is dev-only, ACL is SYSTEM/Administrators, and the token is never
|
||||
written into agent.env."""
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gti_fiscal_agent import credential_store
|
||||
|
||||
import win32api
|
||||
|
||||
AGENT_ROOT = Path(__file__).resolve().parents[1]
|
||||
RUN_SCRIPT = AGENT_ROOT / "scripts" / "run_agent_service.py"
|
||||
|
||||
|
||||
def load_run_service():
|
||||
spec = importlib.util.spec_from_file_location("run_agent_service_cred", RUN_SCRIPT)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def write_plaintext(path: Path, value: str = "TOKEN_PLAINTEXT_DEV"):
|
||||
path.write_text(value, encoding="utf-8")
|
||||
|
||||
|
||||
# ---------- DPAPI round-trip ----------
|
||||
|
||||
def _allow_current_user(path: Path) -> None:
|
||||
"""The strict SYSTEM/Administrators DACL blocks the pytest user (not an
|
||||
admin here). As the file OWNER the current user may grant itself read —
|
||||
exactly like the elevated installer/local administrator does on the real
|
||||
host before the credential is consumed by the SYSTEM service."""
|
||||
import subprocess
|
||||
|
||||
import win32security
|
||||
|
||||
acct_sid, _domain, _type = win32security.LookupAccountName(None, win32api.GetUserName())
|
||||
sid_str = win32security.ConvertSidToStringSid(acct_sid)
|
||||
subprocess.run(["icacls", str(path), "/grant", f"*{sid_str}:(R)"], check=True, capture_output=True)
|
||||
|
||||
|
||||
def test_write_then_read_roundtrip(tmp_path):
|
||||
path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(path, "TOKEN_DPAPI_1")
|
||||
_allow_current_user(path)
|
||||
assert credential_store.read_credential(path) == "TOKEN_DPAPI_1"
|
||||
|
||||
|
||||
def test_stored_blob_never_contains_the_plaintext_secret(tmp_path):
|
||||
path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(path, "TOKEN_VERY_SECRET_9")
|
||||
_allow_current_user(path)
|
||||
raw = path.read_bytes()
|
||||
assert b"TOKEN_VERY_SECRET_9" not in raw
|
||||
|
||||
|
||||
def test_read_missing_file_raises(tmp_path):
|
||||
with pytest.raises(credential_store.CredentialStoreError):
|
||||
credential_store.read_credential(tmp_path / "nope.bin")
|
||||
|
||||
|
||||
def test_write_detects_dpapi_unavailable_and_fails_closed(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(credential_store, "_dpapi_available", lambda: False)
|
||||
with pytest.raises(credential_store.CredentialStoreError):
|
||||
credential_store.write_credential(tmp_path / "credentials.bin", "never")
|
||||
|
||||
|
||||
# ---------- plaintext fallback (dev-only) ----------
|
||||
|
||||
def test_plaintext_rejected_by_default(tmp_path):
|
||||
path = tmp_path / "credentials.bin"
|
||||
write_plaintext(path)
|
||||
with pytest.raises(credential_store.CredentialStoreError):
|
||||
credential_store.read_credential(path)
|
||||
|
||||
|
||||
def test_plaintext_accepted_only_with_dev_flag(tmp_path, monkeypatch):
|
||||
path = tmp_path / "credentials.bin"
|
||||
write_plaintext(path, "TOKEN_DEV_PLAIN")
|
||||
assert credential_store.read_credential(path, env={"GTI_ALLOW_PLAINTEXT_CREDENTIAL": "1"}) == "TOKEN_DEV_PLAIN"
|
||||
monkeypatch.setenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", "1")
|
||||
assert credential_store.read_credential(path) == "TOKEN_DEV_PLAIN"
|
||||
monkeypatch.delenv("GTI_ALLOW_PLAINTEXT_CREDENTIAL", raising=False)
|
||||
with pytest.raises(credential_store.CredentialStoreError):
|
||||
credential_store.read_credential(path)
|
||||
|
||||
|
||||
# ---------- ACL (SYSTEM/Administrators only) ----------
|
||||
|
||||
def test_write_sets_system_and_administrators_acl(tmp_path):
|
||||
import win32security
|
||||
|
||||
path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(path, "TOKEN_ACL_7")
|
||||
admin_sid = win32security.ConvertStringSidToSid("S-1-5-32-544")
|
||||
system_sid = win32security.ConvertStringSidToSid("S-1-5-18")
|
||||
sd = win32security.GetFileSecurity(str(path), win32security.DACL_SECURITY_INFORMATION)
|
||||
dacl = sd.GetSecurityDescriptorDacl()
|
||||
assert dacl.GetAceCount() == 2
|
||||
allow_trustees = []
|
||||
for i in range(dacl.GetAceCount()):
|
||||
ace = dacl.GetAce(i)
|
||||
assert ace[0][0] == win32security.ACCESS_ALLOWED_ACE_TYPE, ace # only allows, no deny
|
||||
allow_trustees.append(win32security.ConvertSidToStringSid(ace[2]))
|
||||
assert set(allow_trustees) == {"S-1-5-18", "S-1-5-32-544"}
|
||||
|
||||
|
||||
# ---------- install_env_from_credentials / Config integration ----------
|
||||
|
||||
def test_install_env_from_credentials_fills_agent_token(tmp_path):
|
||||
path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(path, "TOKEN_FROM_FILE")
|
||||
_allow_current_user(path)
|
||||
env = {"AGENT_TOKEN": "", "HUB_URL": "https://x"}
|
||||
install_env_from_credentials = credential_store.install_env_from_credentials
|
||||
assert install_env_from_credentials(path, env) == "TOKEN_FROM_FILE"
|
||||
assert env["AGENT_TOKEN"] == "TOKEN_FROM_FILE"
|
||||
|
||||
|
||||
def test_install_env_prefers_explicit_agent_token(tmp_path):
|
||||
path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(path, "TOKEN_FROM_FILE")
|
||||
env = {"AGENT_TOKEN": "TOKEN_FROM_ENV"}
|
||||
install_env_from_credentials = credential_store.install_env_from_credentials
|
||||
assert install_env_from_credentials(path, env) == "TOKEN_FROM_ENV"
|
||||
assert env["AGENT_TOKEN"] == "TOKEN_FROM_ENV"
|
||||
|
||||
|
||||
def test_config_reads_token_from_credential_file(tmp_path, monkeypatch):
|
||||
credential_path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(credential_path, "TOKEN_CRED_FILE")
|
||||
_allow_current_user(credential_path)
|
||||
env = {
|
||||
"FIREBIRD_DATABASE_PATH": str(tmp_path / "nope.fdb"),
|
||||
"FIREBIRD_WRITES": "0",
|
||||
"FISCAL_MODE": "DRY_RUN",
|
||||
"GTI_CREDENTIAL_FILE": str(credential_path),
|
||||
}
|
||||
for key in list(env):
|
||||
monkeypatch.setenv(key, env[key])
|
||||
service = load_run_service()
|
||||
config = service.Config(os.environ)
|
||||
assert config.agent_token == "TOKEN_CRED_FILE"
|
||||
assert config.firebird_writes is False # FIREBIRD_WRITES stays 0 regardless of credential
|
||||
|
||||
|
||||
# ---------- no-write enforcement (drift) ----------
|
||||
|
||||
def test_firebird_writes_fail_closed_with_credential_file(tmp_path, monkeypatch):
|
||||
credential_path = tmp_path / "credentials.bin"
|
||||
credential_store.write_credential(credential_path, "TOKEN")
|
||||
_allow_current_user(credential_path)
|
||||
env = {
|
||||
"FIREBIRD_DATABASE_PATH": str(tmp_path / "x.fdb"),
|
||||
"FIREBIRD_WRITES": "0",
|
||||
"GTI_CREDENTIAL_FILE": str(credential_path),
|
||||
}
|
||||
for key in list(env):
|
||||
monkeypatch.setenv(key, env[key])
|
||||
service = load_run_service()
|
||||
|
||||
# Even a hostile FISCAL_MODE cannot enable writes when FIREBIRD_WRITES=0.
|
||||
monkeypatch.setenv("FISCAL_MODE", "AUTO_APPLY")
|
||||
config = service.Config(os.environ)
|
||||
assert config.firebird_writes is False
|
||||
|
||||
|
||||
def test_credential_file_default_lives_under_program_files():
|
||||
default = credential_store.default_credential_path()
|
||||
assert "Program Files" in str(default)
|
||||
assert default.name == "credentials.bin"
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Installation code (install_code) for the Windows installer EXE flow.
|
||||
|
||||
agent_enrollments gains the install-code columns used by the universal EXE
|
||||
installer: the code is typed by the operator into Setup.exe and resolves the
|
||||
installation on the Hub. Only the SHA-256 of the normalized code is ever
|
||||
stored (install_code_hash); install_code_expires_at bounds its life and
|
||||
install_code_created_at records when it was generated (rotation rewrites
|
||||
these). install_code_attempts counts failed enrollment tries to support the
|
||||
per-installation rate limit. No plaintext code column exists.
|
||||
|
||||
Revision ID: 0023_install_codes
|
||||
Revises: 0022_agent_releases_enrollment
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "0023_install_codes"
|
||||
down_revision = "0022_agent_releases_enrollment"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.add_column("agent_enrollments", sa.Column("install_code_hash", sa.String(64), nullable=True))
|
||||
op.add_column("agent_enrollments", sa.Column("install_code_expires_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("agent_enrollments", sa.Column("install_code_attempts", sa.Integer(), nullable=False, server_default="0"))
|
||||
op.add_column("agent_enrollments", sa.Column("install_code_created_at", sa.DateTime(timezone=True), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("agent_enrollments", "install_code_created_at")
|
||||
op.drop_column("agent_enrollments", "install_code_attempts")
|
||||
op.drop_column("agent_enrollments", "install_code_expires_at")
|
||||
op.drop_column("agent_enrollments", "install_code_hash")
|
||||
@@ -43,17 +43,34 @@ def _aware(value: datetime | None) -> datetime | None:
|
||||
|
||||
|
||||
def build_and_serve(session, enr: AgentEnrollment, request: Request, *, actor_admin_id: int | None = None) -> Response:
|
||||
"""Mint a fresh enrollment token, build the per-installation bundle and
|
||||
return it as a download response. Shared by the temp-link route and the
|
||||
admin's direct "Baixar Agent" button in web.py."""
|
||||
"""Serve the release artifact for the installation — always the same
|
||||
universal release binary (ZIP bundle for legacy token flow, bare EXE for
|
||||
the installer flow). Audit the download; never embed the enrollment token
|
||||
in an EXE download (the operator types a standalone install code instead)."""
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
path = release_storage.safe_path_for(release.storage_path)
|
||||
if not path.exists():
|
||||
raise enrollment.EnrollmentRejected("RELEASE_FILE_MISSING")
|
||||
token = enrollment.new_token()
|
||||
now = datetime.now(timezone.utc)
|
||||
is_exe = release.filename.lower().endswith(".exe")
|
||||
if is_exe:
|
||||
# Universal EXE flow: no per-installation bundle, no enrollment.json.
|
||||
# The operator types the standalone install code into Setup.exe and the
|
||||
# Hub resolves the installation from the code hash alone.
|
||||
enr.downloaded_at = now
|
||||
if enr.status not in ("ENROLLED", "REVOKED"):
|
||||
enr.status = "DOWNLOADED"
|
||||
record_audit(session, admin_user_id=actor_admin_id, tenant_id=enr.tenant_id, action="INSTALLER_DOWNLOADED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": client_ip(request), "release_version": release.version, "release_id": release.id})
|
||||
session.commit()
|
||||
filename = release.filename
|
||||
return Response(content=path.read_bytes(), media_type="application/octet-stream",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"X-Content-Type-Options": "nosniff", "Cache-Control": "no-store"})
|
||||
token = enrollment.new_token()
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
|
||||
enr.enrollment_used_at = None
|
||||
@@ -117,27 +134,38 @@ def router(session_factory):
|
||||
payload = {}
|
||||
installation_id = str(payload.get("installation_id") or "")
|
||||
supplied_token = payload.get("enrollment_token")
|
||||
install_code = payload.get("install_code")
|
||||
# Adjustment: install_code and enrollment_token are mutually exclusive —
|
||||
# the EXE flow sends a code only; sending both is a request error.
|
||||
if install_code and (supplied_token or payload.get("installation_id")):
|
||||
record_attempt("enroll", ip)
|
||||
with session_factory() as session:
|
||||
record_audit(session, admin_user_id=None, tenant_id=None, action="ENROLLMENT_REJECTED",
|
||||
target_type="agent_enrollment", target_id="-",
|
||||
context={"ip": ip, "reason": "MIXED_CREDENTIALS"}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(400, "MIXED_CREDENTIALS")
|
||||
with session_factory() as session:
|
||||
enr = None
|
||||
try:
|
||||
if not enrollment.valid_uuid(installation_id):
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
|
||||
if install_code:
|
||||
enr = _resolve_by_install_code(session, ip, install_code)
|
||||
else:
|
||||
enr = _resolve_by_legacy_token(session, ip, installation_id, supplied_token)
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if enr.status == "ENROLLED":
|
||||
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
|
||||
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
if release is None or release.status != "PUBLISHED":
|
||||
raise enrollment.EnrollmentRejected("RELEASE_UNAVAILABLE")
|
||||
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id, action="ENROLLMENT_STARTED",
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid,
|
||||
context={"ip": ip, "credential_type": "install_code" if install_code else "enrollment_token"})
|
||||
|
||||
hostname = str(payload.get("hostname") or "")[:255]
|
||||
mismatch = bool(enr.expected_hostname) and bool(hostname) and hostname.lower() != enr.expected_hostname.lower()
|
||||
|
||||
@@ -150,8 +178,10 @@ def router(session_factory):
|
||||
display_name=enr.display_name, hostname=hostname or None))
|
||||
|
||||
enr.status = "ENROLLED"
|
||||
enr.enrollment_used_at = now
|
||||
enr.enrollment_used_at = datetime.now(timezone.utc)
|
||||
enr.enrollment_token_hash = None # replay-proof even inside its TTL window
|
||||
enr.install_code_hash = None # consumed: truly one-time, replay-proof
|
||||
enr.install_code_expires_at = None
|
||||
enr.hostname_seen = hostname or None
|
||||
enr.machine_fingerprint_seen = str(payload.get("machine_fingerprint") or "")[:128] or None
|
||||
enr.hostname_mismatch = mismatch
|
||||
@@ -168,10 +198,56 @@ def router(session_factory):
|
||||
except enrollment.EnrollmentRejected as rejected:
|
||||
session.rollback()
|
||||
record_attempt("enroll", ip)
|
||||
reason = rejected.reason
|
||||
if enr is not None:
|
||||
enr.install_code_attempts = (enr.install_code_attempts or 0) + 1
|
||||
record_attempt(f"enroll_code:{enr.enrollment_uuid}", ip)
|
||||
session.commit()
|
||||
record_audit(session, admin_user_id=None, tenant_id=enr.tenant_id if enr else None, action="ENROLLMENT_REJECTED",
|
||||
target_type="agent_enrollment", target_id=installation_id or "-",
|
||||
context={"ip": ip, "reason": rejected.reason}, severity="WARNING")
|
||||
target_type="agent_enrollment", target_id=enr.enrollment_uuid if enr else (installation_id or "-"),
|
||||
context={"ip": ip, "reason": reason}, severity="WARNING")
|
||||
session.commit()
|
||||
raise HTTPException(401, rejected.reason)
|
||||
raise HTTPException(401, reason)
|
||||
|
||||
def _resolve_by_install_code(session, ip, code):
|
||||
"""Resolve the Installation by the install_code hash alone. Returns the
|
||||
enrollment or None. Format/unknown codes and expired codes raise
|
||||
EnrollmentRejected. Per-installation and per-IP rate limits apply."""
|
||||
try:
|
||||
digest = enrollment.install_code_digest(code)
|
||||
except ValueError:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.install_code_hash == digest))
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALL_CODE")
|
||||
# Per-installation rate limit: a hostile caller guessing codes for one
|
||||
# installation must burn the whole budget even after the code rotates.
|
||||
if blocked(f"enroll_code:{enr.enrollment_uuid}", ip):
|
||||
raise enrollment.EnrollmentRejected("RATE_LIMITED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.install_code_expires_at or _aware(enr.install_code_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
return enr
|
||||
|
||||
def _resolve_by_legacy_token(session, ip, installation_id, supplied_token):
|
||||
"""Legacy enrollment_token path (published ZIP bundles). Returns the
|
||||
enrollment or raises EnrollmentRejected. Order preserved from the
|
||||
original flow: enrolled-again must read ALREADY_ENROLLED, not
|
||||
INVALID_TOKEN."""
|
||||
if not enrollment.valid_uuid(installation_id):
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_id))
|
||||
if enr is None:
|
||||
raise enrollment.EnrollmentRejected("INVALID_INSTALLATION")
|
||||
if enr.status == "REVOKED":
|
||||
raise enrollment.EnrollmentRejected("REVOKED")
|
||||
if enr.status == "ENROLLED":
|
||||
raise enrollment.EnrollmentRejected("ALREADY_ENROLLED")
|
||||
now = datetime.now(timezone.utc)
|
||||
if not enr.enrollment_token_expires_at or _aware(enr.enrollment_token_expires_at) < now:
|
||||
raise enrollment.EnrollmentRejected("EXPIRED")
|
||||
if not enrollment.token_matches(supplied_token, enr.enrollment_token_hash):
|
||||
raise enrollment.EnrollmentRejected("INVALID_TOKEN")
|
||||
return enr
|
||||
|
||||
return routes
|
||||
|
||||
@@ -36,6 +36,58 @@ def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
# ---- installation code (XXXX-XXXX-XXXX-XXXX) ----
|
||||
#
|
||||
# A 16-character Crockford Base32 code = 16 * 5 = 80 bits of entropy. The
|
||||
# alphabet deliberately excludes I/L/O/U (ambiguous when retyped by a human);
|
||||
# the same alphabet is used so a code cannot contain those letters.
|
||||
|
||||
INSTALL_CODE_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
_INSTALL_CODE_GROUPS = 4
|
||||
_INSTALL_CODE_PER_GROUP = 4
|
||||
_INSTALL_CODE_LENGTH = _INSTALL_CODE_GROUPS * _INSTALL_CODE_PER_GROUP
|
||||
# Crockford transliteration for input normalization: O -> 0, I/L -> 1.
|
||||
_INSTALL_CODE_TRANSLIT = str.maketrans({"O": "0", "I": "1", "L": "1"})
|
||||
_INSTALL_CODE_RE = re.compile(
|
||||
r"^[" + re.escape(INSTALL_CODE_ALPHABET) + r"]{" + str(_INSTALL_CODE_LENGTH) + r"}$"
|
||||
)
|
||||
|
||||
|
||||
def generate_install_code() -> str:
|
||||
"""Return a fresh installation code formatted XXXX-XXXX-XXXX-XXXX."""
|
||||
chars = "".join(secrets.choice(INSTALL_CODE_ALPHABET) for _ in range(_INSTALL_CODE_LENGTH))
|
||||
return "-".join(
|
||||
chars[i : i + _INSTALL_CODE_PER_GROUP] for i in range(0, _INSTALL_CODE_LENGTH, _INSTALL_CODE_PER_GROUP)
|
||||
)
|
||||
|
||||
|
||||
def normalize_install_code(text: str | None) -> str:
|
||||
"""Upper-case, drop hyphen/space separators and map Crockford lookalikes,
|
||||
so a human-typed code normalizes to the same digest regardless of how it
|
||||
was entered. Raises ValueError if the result isn't a valid code."""
|
||||
if not text:
|
||||
raise ValueError("empty install code")
|
||||
clean = "".join(ch for ch in text.upper().translate(_INSTALL_CODE_TRANSLIT) if ch.isalnum())
|
||||
if not _INSTALL_CODE_RE.match(clean):
|
||||
raise ValueError(f"invalid install code: {text!r}")
|
||||
return clean
|
||||
|
||||
|
||||
def install_code_digest(code: str) -> str:
|
||||
"""Hash a normalized install code. Only the hash is ever persisted; the
|
||||
plaintext code exists only in the operator's clipboard/UI and the EXE's
|
||||
memory for the brief instant it is sent to the Hub."""
|
||||
return token_digest(normalize_install_code(code))
|
||||
|
||||
|
||||
def valid_install_code(text: str | None) -> bool:
|
||||
try:
|
||||
normalize_install_code(text)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
@@ -412,6 +412,14 @@ class AgentEnrollment(Base):
|
||||
enrollment_token_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
enrollment_token_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
enrollment_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
# The install code typed by the operator into the universal EXE installer.
|
||||
# Only its SHA-256 is stored (normalize → digest), never the plaintext.
|
||||
# A new code (rotation) rewrites these three fields; the previous code
|
||||
# becomes unusable the moment a new one is generated.
|
||||
install_code_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
install_code_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
install_code_attempts: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
|
||||
install_code_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
machine_fingerprint_seen: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
hostname_seen: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
hostname_mismatch: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
|
||||
|
||||
@@ -18,8 +18,12 @@ def storage_root() -> Path:
|
||||
return root
|
||||
|
||||
|
||||
def release_filename(version: str, platform: str, architecture: str) -> str:
|
||||
return f"GTIFiscalAgent-{version}-{platform}-{architecture}.zip"
|
||||
def release_filename(version: str, platform: str, architecture: str, extension: str = ".zip") -> str:
|
||||
return f"GTIFiscalAgent-{version}-{platform}-{architecture}{extension}"
|
||||
|
||||
|
||||
def setup_exe_filename(version: str) -> str:
|
||||
return f"GTIFiscalAgent-Setup-{version}.exe"
|
||||
|
||||
|
||||
def safe_path_for(storage_path: str) -> Path:
|
||||
|
||||
+48
-3
@@ -36,6 +36,14 @@ def router(session_factory):
|
||||
def client_ip(request):
|
||||
return request.client.host if request.client else None
|
||||
|
||||
def _aware(value: datetime | None) -> datetime | None:
|
||||
"""SQLite (used by the test suite) drops tzinfo on round-trip even for
|
||||
a DateTime(timezone=True) column; Postgres never does. Normalize to
|
||||
UTC before comparing against datetime.now(timezone.utc)."""
|
||||
if value is None or value.tzinfo is not None:
|
||||
return value
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
|
||||
def set_session_cookies(response, token, csrf, seconds):
|
||||
cfg = config.settings
|
||||
response.set_cookie(cfg.web_cookie_name, token, max_age=seconds, httponly=True, secure=cfg.web_cookie_secure, samesite="lax")
|
||||
@@ -289,7 +297,7 @@ def router(session_factory):
|
||||
return session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == enrollment_uuid))
|
||||
|
||||
@routes.get("/agents/pending/{enrollment_uuid}", response_class=HTMLResponse)
|
||||
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None):
|
||||
def pending_installation_detail(request: Request, enrollment_uuid: str, link: str | None = None, code: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
@@ -298,7 +306,11 @@ def router(session_factory):
|
||||
release = session.get(AgentRelease, enr.release_id)
|
||||
tenant = session.get(Tenant, enr.tenant_id)
|
||||
installed_agent = session.get(Agent, enr.agent_id) if enr.agent_id else None
|
||||
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent, "download_link": link, "auth": auth_data})
|
||||
active_code = bool(enr.install_code_hash and enr.install_code_expires_at and _aware(enr.install_code_expires_at) > datetime.now(timezone.utc))
|
||||
code_expires_at = enr.install_code_expires_at if active_code else None
|
||||
return render(request, "agent_pending_detail.html", {"enr": enr, "release": release, "tenant": tenant, "installed_agent": installed_agent,
|
||||
"download_link": link, "install_code": code, "code_active": active_code,
|
||||
"code_expires_at": code_expires_at, "auth": auth_data})
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/link")
|
||||
@@ -347,17 +359,50 @@ def router(session_factory):
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
was_code_active = bool(enr.install_code_hash)
|
||||
enr.status = "REVOKED"
|
||||
enr.download_token_hash = None
|
||||
enr.enrollment_token_hash = None
|
||||
enr.install_code_hash = None
|
||||
enr.install_code_expires_at = None
|
||||
if enr.agent_id:
|
||||
agent = session.get(Agent, enr.agent_id)
|
||||
if agent: agent.enabled = False
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={"had_agent": bool(enr.agent_id)})
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid,
|
||||
context={"had_agent": bool(enr.agent_id), "had_install_code": was_code_active})
|
||||
if was_code_active:
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_REVOKED", target_type="agent_enrollment", target_id=enrollment_uuid, context={})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.post("/agents/pending/{enrollment_uuid}/code")
|
||||
async def generate_install_code(request: Request, enrollment_uuid: str):
|
||||
"""Generate (or rotate) the installation code shown to the operator and
|
||||
typed into the universal EXE installer. Only the SHA-256 of the
|
||||
normalized code is ever stored; the plaintext is returned in the HTML
|
||||
once, to the SSO-authenticated operator who created the installation."""
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
if redirect: return redirect
|
||||
try:
|
||||
values = await form_values(request); web_session, admin = auth_data
|
||||
require_csrf(web_session, values.get("csrf_token"))
|
||||
enr = _pending_or_404(session, enrollment_uuid)
|
||||
if not enr: return HTMLResponse("Installation not found", status_code=404)
|
||||
if enr.status in ("ENROLLED", "REVOKED"):
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}", status_code=303)
|
||||
code = enrollment.generate_install_code()
|
||||
now = datetime.now(timezone.utc)
|
||||
enr.install_code_hash = enrollment.install_code_digest(code)
|
||||
enr.install_code_expires_at = now + timedelta(seconds=config.settings.agent_enrollment_ttl_seconds)
|
||||
enr.install_code_attempts = 0
|
||||
enr.install_code_created_at = now
|
||||
record_audit(session, admin_user_id=admin.id, tenant_id=enr.tenant_id, action="INSTALLATION_CODE_CREATED", target_type="agent_enrollment",
|
||||
target_id=enrollment_uuid, context={"ttl_seconds": config.settings.agent_enrollment_ttl_seconds, "ip": client_ip(request)})
|
||||
session.commit()
|
||||
return RedirectResponse(f"/agents/pending/{enrollment_uuid}?code={code}", status_code=303)
|
||||
finally: session.close()
|
||||
|
||||
@routes.get("/blockers", response_class=HTMLResponse)
|
||||
def blockers(request: Request, page: int = 1, client: str | None = None, installation: str | None = None, field: str | None = None, blocker_type: str | None = None, status: str = "OPEN", age: str | None = None, search: str | None = None, agent_id: int | None = None, product_code: str | None = None):
|
||||
session, auth_data, redirect = page_auth(request)
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
"""Administrative CLI: publish a built Agent package (scripts/build_agent_package.py
|
||||
output) as an AgentRelease, ready to be downloaded/enrolled from the panel.
|
||||
"""Administrative CLI: publish a built Agent package as an AgentRelease, ready
|
||||
to be downloaded/enrolled from the panel. Accepts either the legacy universal
|
||||
ZIP (`scripts/build_agent_package.py` output) or the universal Windows
|
||||
installer EXE (`scripts/build_windows_installer.ps1` output). Whatever is
|
||||
published is served verbatim to every installation later.
|
||||
|
||||
Like register_agent.py, this is a one-time, operator-run action against
|
||||
DATABASE_URL directly — never a browser upload endpoint, so the release
|
||||
binary never has to pass through an HTTP request body. The zip is copied
|
||||
binary never has to pass through an HTTP request body. The artifact is copied
|
||||
into the Hub's local release storage (AGENT_RELEASE_STORAGE_PATH), never
|
||||
into PostgreSQL; only its metadata (version, sha256, size, path) is a row.
|
||||
|
||||
Usage:
|
||||
python central/scripts/publish_agent_release.py --zip dist/GTIFiscalAgent-1.0.0.zip \\
|
||||
python central/scripts/publish_agent_release.py --artifact dist/GTIFiscalAgent-1.0.0.zip \\
|
||||
--notes "First distributable build with secure enrollment"
|
||||
python central/scripts/publish_agent_release.py --artifact dist/installer/GTIFiscalAgent-Setup-1.1.1.exe \\
|
||||
--notes "Universal Windows installer with install-code enrollment"
|
||||
|
||||
Refuses to publish the same version+platform+architecture twice with a
|
||||
different sha256 (an accidental respin must get a new version). Publishing
|
||||
the exact same bytes again is a no-op (idempotent).
|
||||
the exact same bytes again is a no-op (idempotent), and a PUBLISHED release
|
||||
is never replaced with different content.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
@@ -31,17 +37,18 @@ from app.db import engine # noqa: E402
|
||||
from app.models import AgentRelease # noqa: E402
|
||||
|
||||
|
||||
def publish(session, zip_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None):
|
||||
data = zip_path.read_bytes()
|
||||
def publish(session, artifact_path: Path, *, platform="windows", architecture="x64", notes=None, created_by=None, version=None, filename=None):
|
||||
data = artifact_path.read_bytes()
|
||||
digest = hashlib.sha256(data).hexdigest()
|
||||
version = version or _version_from_manifest(zip_path)
|
||||
version = version or _version_from_artifact_name(artifact_path)
|
||||
filename = filename or _release_filename(artifact_path, version, platform, architecture)
|
||||
existing = session.query(AgentRelease).filter_by(version=version, platform=platform, architecture=architecture).one_or_none()
|
||||
if existing:
|
||||
if existing.sha256 == digest:
|
||||
if existing.sha256 == digest and existing.filename == filename:
|
||||
return existing, False # identical content already published: idempotent no-op
|
||||
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published with a different sha256 "
|
||||
f"({existing.sha256[:12]}... != {digest[:12]}...) — bump the version instead of overwriting it")
|
||||
filename = release_storage.release_filename(version, platform, architecture)
|
||||
raise SystemExit(f"refused: {version}/{platform}/{architecture} is already published "
|
||||
f"(sha256 {existing.sha256[:12]}..., filename {existing.filename!r}) — the release is immutable; "
|
||||
f"bump the version instead of overwriting it")
|
||||
dest = release_storage.storage_root() / filename
|
||||
dest.write_bytes(data)
|
||||
release = AgentRelease(version=version, platform=platform, architecture=architecture, filename=filename,
|
||||
@@ -53,32 +60,40 @@ def publish(session, zip_path: Path, *, platform="windows", architecture="x64",
|
||||
return release, True
|
||||
|
||||
|
||||
def _version_from_manifest(zip_path: Path) -> str:
|
||||
# The package name is `GTIFiscalAgent-<version>.zip`; avoids depending on
|
||||
# zip internals just to learn the version this script itself is publishing.
|
||||
stem = zip_path.stem
|
||||
prefix = "GTIFiscalAgent-"
|
||||
if not stem.startswith(prefix):
|
||||
raise SystemExit(f"cannot infer version from filename {zip_path.name!r} — pass --version explicitly")
|
||||
return stem[len(prefix):]
|
||||
def _release_filename(artifact_path: Path, version: str, platform: str, architecture: str) -> str:
|
||||
"""Filename used for the stored artifact. Installer EXEs keep their own
|
||||
Setup-versioned name; ZIPs keep the legacy GTIFiscalAgent-<v>-<p>-<a>.zip."""
|
||||
if artifact_path.name.lower().endswith(".exe"):
|
||||
return artifact_path.name if artifact_path.name.lower() == f"gtifiscalagent-setup-{version}.exe" else release_storage.setup_exe_filename(version)
|
||||
return release_storage.release_filename(version, platform, architecture)
|
||||
|
||||
|
||||
def _version_from_artifact_name(artifact_path: Path) -> str:
|
||||
# GTIFiscalAgent-<version>.zip / GTIFiscalAgent-Setup-<version>.exe
|
||||
stem = artifact_path.stem
|
||||
for prefix in ("GTIFiscalAgent-Setup-", "GTIFiscalAgent-"):
|
||||
if stem.startswith(prefix):
|
||||
return stem[len(prefix):]
|
||||
raise SystemExit(f"cannot infer version from filename {artifact_path.name!r} — pass --version explicitly")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--zip", required=True, type=Path, help="Path to the built package zip (scripts/build_agent_package.py output)")
|
||||
parser.add_argument("--artifact", required=True, type=Path, help="Path to the built package zip or installer exe")
|
||||
parser.add_argument("--version", default=None, help="Override the version instead of inferring it from the filename")
|
||||
parser.add_argument("--platform", default="windows")
|
||||
parser.add_argument("--architecture", default="x64")
|
||||
parser.add_argument("--notes", default=None, help="Release notes shown in the panel")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.zip.exists():
|
||||
raise SystemExit(f"not found: {args.zip}")
|
||||
if not args.artifact.exists():
|
||||
raise SystemExit(f"not found: {args.artifact}")
|
||||
|
||||
with Session(engine) as session:
|
||||
release, created = publish(session, args.zip, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
|
||||
release, created = publish(session, args.artifact, platform=args.platform, architecture=args.architecture, notes=args.notes, version=args.version)
|
||||
print(f"{'published' if created else 'already published (idempotent)'}: version={release.version} "
|
||||
f"platform={release.platform} architecture={release.architecture} sha256={release.sha256} size_bytes={release.size_bytes}")
|
||||
f"platform={release.platform} architecture={release.architecture} filename={release.filename} "
|
||||
f"sha256={release.sha256} size_bytes={release.size_bytes}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -11,6 +11,27 @@
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if install_code %}
|
||||
<section class="panel">
|
||||
<h2>Código de instalação gerado</h2>
|
||||
<p class="muted">Mostrado uma única vez. Informe este código ao operador no momento da instalação do instalador (.EXE) no Windows. Expira automaticamente.</p>
|
||||
<p><code style="font-size:1.4rem;letter-spacing:2px">{{ install_code }}</code>
|
||||
<button type="button" onclick="navigator.clipboard.writeText('{{ install_code }}');this.textContent='Copiado ✓';setTimeout(()=>this.textContent='Copiar',1500)">Copiar</button></p>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if code_active %}
|
||||
<section class="panel">
|
||||
<h2>Código de instalação ativo</h2>
|
||||
<p class="muted">Inserir no instalador (.EXE) quando a instalação for feita. Expira em <strong>{{ code_expires_at }}</strong>.</p>
|
||||
<p class="muted">Gerar um novo código invalida o atual.</p>
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Gerar novo código</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="panel">
|
||||
<h2>Detalhes</h2>
|
||||
<table>
|
||||
@@ -31,14 +52,18 @@
|
||||
<section class="panel">
|
||||
<h2>Ações</h2>
|
||||
{% if enr.status not in ("ENROLLED", "REVOKED") %}
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">{{ "Gerar novo código" if code_active else "Gerar código de instalação" }}</button>
|
||||
</form>
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/link" style="display:inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit">Gerar link temporário</button>
|
||||
</form>
|
||||
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent agora</a>
|
||||
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent ({{ release.filename if release else "instalador" }})</a>
|
||||
{% endif %}
|
||||
{% if enr.status != "REVOKED" %}
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links e o Agent (se já ativo) deixam de funcionar.');">
|
||||
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links, o código e o Agent (se já ativo) deixam de funcionar.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Revogar</button>
|
||||
</form>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
{% for release in releases %}
|
||||
<tr>
|
||||
<td><b>{{ release.version }}</b></td>
|
||||
<td>{{ release.platform }}/{{ release.architecture }}</td>
|
||||
<td>{{ release.platform }}/{{ release.architecture }} {% if release.filename.lower().endswith(".exe") %}<span class="badge">EXE</span>{% endif %}</td>
|
||||
<td><span class="badge {{ 'critical' if release.status == 'REVOKED' else '' }}">{{ release.status }}</span></td>
|
||||
<td><code>{{ release.sha256[:12] }}…</code></td>
|
||||
<td>{{ "%.1f"|format(release.size_bytes / 1048576) }} MB</td>
|
||||
@@ -18,6 +18,7 @@
|
||||
<td>{{ installation_counts.get(release.id, 0) }}</td>
|
||||
<td>
|
||||
{% if release.status == "PUBLISHED" %}
|
||||
<span class="muted" style="font-size:.8rem">{{ release.filename }}</span>
|
||||
<form method="post" action="/agents/releases/{{ release.id }}/revoke" onsubmit="return confirm('Revogar a release {{ release.version }}? Nenhum novo download poderá usá-la.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="secondary">Revogar</button>
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Install-code enrollment flow: POST /v1/agents/enroll with install_code.
|
||||
|
||||
Covers the universal-EXE enrollment path — the operator types a 16-char
|
||||
XXXX-XXXX-XXXX-XXXX installation code into Setup.exe, the Hub resolves the
|
||||
installation by the hash on its own (no installation_id, no enrollment.json),
|
||||
issues the permanent agent credential, and exhausts the code (replay-proof).
|
||||
Also covers rejection when code and legacy token are sent together, expiry,
|
||||
attempts/rate-limit and no-plaintext persistencen.
|
||||
"""
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.1.1"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_id(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
data = io_bytes_tmp(tmp_path, VERSION)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, data, version=VERSION)
|
||||
return release.id
|
||||
|
||||
|
||||
def io_bytes_tmp(tmp_path, version):
|
||||
from pathlib import Path
|
||||
buf = io_bytes_bundle(version)
|
||||
path = Path(tmp_path) / f"GTIFiscalAgent-{version}.zip"
|
||||
path.write_bytes(buf)
|
||||
return path
|
||||
|
||||
|
||||
def io_bytes_bundle(version):
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
|
||||
|
||||
def make_installation(factory, release_id, name="Servidor E2E"):
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(__import__("uuid").uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name=name, release_id=release_id, status="PENDING")
|
||||
session.add(enr)
|
||||
session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def set_code(factory, uuid, code, ttl_seconds=86400):
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.install_code_hash = enrollment.install_code_digest(code)
|
||||
enr.install_code_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
enr.install_code_created_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
# ---------- happy path: code alone resolves the installation ----------
|
||||
|
||||
def test_enroll_with_install_code_alone_creates_agent(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": code, "agent_version": "1.1.1", "hostname": "WIN-E2E", "platform": "windows"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == uuid and installation.hostname == "WIN-E2E"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.status == "ENROLLED" and enr.agent_id == agent.id
|
||||
assert enr.install_code_hash is None and enr.install_code_expires_at is None # code consumed, replay-proof
|
||||
|
||||
|
||||
def test_replay_of_consumed_code_is_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
first = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert first.status_code == 200
|
||||
second = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert second.status_code == 401
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "ENROLLMENT_SUCCEEDED" in events and "ENROLLMENT_REJECTED" in events
|
||||
raw = json.dumps({"action": "ENROLLMENT_SUCCEEDED", "target": ["x"], "context": {"ip": "y"}}) + code
|
||||
# the code plaintext is never in any audit event context
|
||||
for event in audits(factory):
|
||||
assert code not in json.dumps(event.context)
|
||||
assert enrollment.normalize_install_code(code) not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_code_is_case_and_hyphen_insensitive(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
typed = code.lower().replace("-", " ")
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": typed})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- rejections ----------
|
||||
|
||||
def test_invalid_code_format_rejected_without_lookup(hub, release_id):
|
||||
client, factory = hub
|
||||
invalid = client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert invalid.status_code == 400 or invalid.status_code == 401
|
||||
assert "INVALID" in str(invalid.json())
|
||||
|
||||
|
||||
def test_unknown_but_wellformed_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
# valid-format code that was never issued for this Hub
|
||||
made_up = "-".join(["0123"] * 4)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": made_up})
|
||||
assert rejected.status_code == 401
|
||||
assert "INVALID" in str(rejected.json())
|
||||
|
||||
|
||||
def test_expired_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code, ttl_seconds=-60)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "EXPIRED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_for_revoked_installation_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "REVOKED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_and_token_together_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
rejected = client.post("/v1/agents/enroll", json={
|
||||
"install_code": code, "installation_id": uuid, "enrollment_token": "x" * 43,
|
||||
})
|
||||
assert rejected.status_code == 400
|
||||
|
||||
|
||||
def test_failed_attempts_increment_and_rate_limit(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
# several wrong-format tries that never reach the code lookup
|
||||
for _ in range(12):
|
||||
client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert rate_limit.blocked("enroll", "testclient")
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 0 # format failures don't consume this installation's code attempts
|
||||
|
||||
|
||||
def test_wrong_code_rejected_as_invalid(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
right_code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, right_code)
|
||||
wrong = enrollment.generate_install_code() # well-formed but never issued
|
||||
attempt = client.post("/v1/agents/enroll", json={"install_code": wrong})
|
||||
assert attempt.status_code == 401
|
||||
assert "INVALID" in str(attempt.json())
|
||||
|
||||
|
||||
def test_resolved_code_failures_increment_attempt_budget(hub, release_id):
|
||||
# A well-formed code that resolves to a REVOKED installation consumes the
|
||||
# installation's attempt budget (per-installation rate limit), same as an
|
||||
# expired one.
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
for _ in range(3):
|
||||
client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 3
|
||||
|
||||
|
||||
def test_legacy_token_path_still_works(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=86400)
|
||||
session.commit()
|
||||
enrolled = client.post("/v1/agents/enroll", json={"installation_id": uuid, "enrollment_token": token})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- publish EXE releases (immutable) ----------
|
||||
|
||||
def test_publish_exe_release(hub, tmp_path):
|
||||
client, factory = hub
|
||||
from central.app import release_storage
|
||||
exe_bytes = b"MZ\x90\x00 fake windows installer exe body \x00" * 100
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(exe_bytes)
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1", notes="universal installer")
|
||||
assert created and release.status == "PUBLISHED" and release.published_at is not None
|
||||
assert release.filename == "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
assert release_storage.safe_path_for(release.storage_path).read_bytes() == exe_bytes
|
||||
|
||||
|
||||
def test_publish_exe_is_immutable(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ fake body v1")
|
||||
with factory() as session:
|
||||
publish(session, exe_path, version="1.1.1")
|
||||
exe_path.write_bytes(b"MZ DIFFERENT body v2")
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, exe_path, version="1.1.1") # refused: immutable once PUBLISHED
|
||||
|
||||
|
||||
def test_publish_identical_exe_is_noop(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ same bytes")
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1")
|
||||
assert created
|
||||
with factory() as session:
|
||||
release2, created2 = publish(session, exe_path, version="1.1.1")
|
||||
assert release2.id == release.id and not created2
|
||||
|
||||
|
||||
# ---------- EXE download flow (panel: code -> download -> enroll) ----------
|
||||
|
||||
def publish_exe_release(factory, tmp_path):
|
||||
exe_path = tmp_path / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ\x90\x00 GTIFiscalAgent installer 1.1.1 \x00" * 40)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, exe_path, version="1.1.1")
|
||||
return release.id
|
||||
|
||||
|
||||
def test_panel_flow_code_generate_download_and_enroll_with_exe(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Servidor EXE", "release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
# generate the install code (only hash stored; plaintext shown once via ?code=)
|
||||
code_page = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
assert code_page.status_code == 200
|
||||
# the redirect carried ?code=XXXX-... ; the page must show it
|
||||
code_addr = code_page.history[0].headers.get("location") if code_page.history else ""
|
||||
assert "code=" in code_addr
|
||||
install_code = code_addr.rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert len(install_code) == 19
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(install_code)
|
||||
assert enr.install_code_expires_at is not None
|
||||
assert enr.install_code_hash != install_code # never plaintext in DB
|
||||
|
||||
# download the EXE from the pending page (authenticated direct download)
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.content.startswith(b"MZ")
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
assert "GTIFiscalAgent-Setup-1.1.1.exe" in downloaded.headers["content-disposition"]
|
||||
|
||||
# enroll with just the code (no installation id, no token)
|
||||
anon = TestClient(client.app)
|
||||
enrolled = anon.post("/v1/agents/enroll", json={"install_code": install_code, "agent_version": "1.1.1", "hostname": "WIN-EXE"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"]
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.install_code_hash is None # consumed
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "INSTALLATION_CODE_CREATED", "INSTALLER_DOWNLOADED", "ENROLLMENT_STARTED",
|
||||
"ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED"} <= events
|
||||
for event in audits(factory):
|
||||
assert install_code not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_rotation_generates_a_code_that_invalidates_the_previous(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Rotação", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
first = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
first_code = first.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
second = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
second_code = second.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert first_code != second_code
|
||||
# first code no longer resolves (rotated), second does
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(second_code)
|
||||
anon = TestClient(client.app)
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": first_code}).status_code == 401
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": second_code}).status_code == 200
|
||||
events = [e.action for e in audits(factory)]
|
||||
assert events.count("INSTALLATION_CODE_CREATED") >= 2
|
||||
|
||||
|
||||
def test_revoke_clears_code_and_is_audited(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Revoga", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)})
|
||||
revoked = client.post(f"/agents/pending/{installation_uuid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert revoked.status_code == 303
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "REVOKED" and enr.install_code_hash is None
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Installation code (install_code) for the Windows installer EXE flow.
|
||||
|
||||
Covers central/app/enrollment.py install-code functions: Crockford Base32
|
||||
generation, normalization (case + hyphen), hashing, and the grouped display
|
||||
format. Never stores the plaintext code.
|
||||
"""
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from central.app import enrollment
|
||||
|
||||
|
||||
def test_generated_code_is_16_crockford_chars_in_4_groups():
|
||||
for _ in range(50):
|
||||
code = enrollment.generate_install_code()
|
||||
assert re.fullmatch(r"[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}", code)
|
||||
|
||||
|
||||
def test_generated_code_has_good_entropy():
|
||||
seen = {enrollment.generate_install_code() for _ in range(2000)}
|
||||
assert len(seen) > 1900 # 80 bits of entropy: collisions essentially impossible
|
||||
|
||||
|
||||
def test_crockford_alphabet_excludes_ambiguous_chars():
|
||||
alphabet = set(enrollment.INSTALL_CODE_ALPHABET)
|
||||
for ambiguous in "ILOU":
|
||||
assert ambiguous not in alphabet
|
||||
|
||||
|
||||
def test_normalization_uppercases_and_strips_separators():
|
||||
assert enrollment.normalize_install_code("abcd-efgh-jkmn-pqrz") == "ABCDEFGHJKMNPQRZ"
|
||||
assert enrollment.normalize_install_code(" abcd efgh jkmn pqrz ") == "ABCDEFGHJKMNPQRZ"
|
||||
assert enrollment.normalize_install_code("ABCD-EFGH-JKMN-PQRZ") == "ABCDEFGHJKMNPQRZ"
|
||||
|
||||
|
||||
def test_normalization_maps_crockford_lookalikes():
|
||||
# Crockford Base32 treats 0/O and 1/I/L as transliterations; a human
|
||||
# retyping "O" and "I/L" means "0" and "1".
|
||||
assert enrollment.normalize_install_code("OOOI-LLLL-0000-0001") == "0001111100000001"
|
||||
assert enrollment.normalize_install_code("0O0O-1L1I-0000-0001") == enrollment.normalize_install_code("0000-1111-0000-0001")
|
||||
|
||||
|
||||
def test_digest_is_sha256_hex_and_deterministic():
|
||||
code = enrollment.generate_install_code()
|
||||
normalized = enrollment.normalize_install_code(code)
|
||||
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(normalized)
|
||||
assert enrollment.install_code_digest(code) == enrollment.install_code_digest(code.lower().replace("-", ""))
|
||||
assert len(enrollment.install_code_digest(code)) == 64
|
||||
|
||||
|
||||
def test_hashes_cannot_be_reversed_into_plaintext():
|
||||
code = enrollment.generate_install_code()
|
||||
digest = enrollment.install_code_digest(code)
|
||||
assert digest != code
|
||||
assert code not in digest
|
||||
|
||||
|
||||
def test_valid_install_code():
|
||||
code = enrollment.generate_install_code()
|
||||
assert enrollment.valid_install_code(code)
|
||||
assert enrollment.valid_install_code(code.lower().replace("-", ""))
|
||||
assert not enrollment.valid_install_code("")
|
||||
assert not enrollment.valid_install_code("ABCD-EFGH-JKMN-PQRU") # U excluded from Crockford
|
||||
assert not enrollment.valid_install_code("ABCD")
|
||||
assert not enrollment.valid_install_code("!BCD-EFGH-JKMN-PQRZ")
|
||||
@@ -17,10 +17,10 @@
|
||||
"ICMS_CONTEXT_ENGINE_VERSION": "icms-context-v1"
|
||||
},
|
||||
"migrations": {
|
||||
"alembic_head_in_repo": "0022_agent_releases_enrollment",
|
||||
"applied_revision": "0022_agent_releases_enrollment",
|
||||
"applied_revision_source": "production Hub 10.0.10.117 (gti-fiscal-hub-fiscal-hub-1), verified 2026-09-23 (fresh DB == upgrade from 0021, pg_dump schema-identical)",
|
||||
"count": 22
|
||||
"alembic_head_in_repo": "0023_install_codes",
|
||||
"applied_revision": "0023_install_codes",
|
||||
"applied_revision_source": "pending production deploy (Phase E of 1.1.1): pg_dump backup + alembic upgrade v3 HEAD will be verified on 10.0.10.117 before publishing 1.1.1",
|
||||
"count": 23
|
||||
},
|
||||
"datasets_active": [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user