389 lines
14 KiB
Python
389 lines
14 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""Founder-run directory side of the LLDAP bind split (NK-WP-0054-T05).
|
||
|
|
|
||
|
|
Subcommands, in runbook order:
|
||
|
|
|
||
|
|
prepare-custody ROOT add LLDAP_SVC_*_PASS and a new ld-admin LLDAP_LDAP_USER_PASS to the
|
||
|
|
private custody copy; refuses if already prepared. Prints names only.
|
||
|
|
plan --endpoint URL read-only: which target accounts exist, with which groups.
|
||
|
|
accounts --endpoint URL ROOT [--apply]
|
||
|
|
create ld-admin and the svc-* accounts, set their groups and their
|
||
|
|
passwords from custody. Without --apply it only reports what it would do.
|
||
|
|
verify --endpoint URL ROOT [--old-admin-retired]
|
||
|
|
every target account signs in with its custody password and holds exactly
|
||
|
|
its groups; `admin` keeps lldap_admin; optionally the old shared password
|
||
|
|
no longer signs in as `admin`.
|
||
|
|
|
||
|
|
The human `admin` signs in at a hidden prompt (or `--admin-password-fd` for an attended pipe); the
|
||
|
|
helper never takes a password on argv and never prints one. Each run writes a value-free receipt.
|
||
|
|
Passwords are set with the pinned image's own `lldap_set_password` (OPAQUE), run locally, with the
|
||
|
|
password in its environment only. This tool changes the directory, not any Kubernetes object.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import getpass
|
||
|
|
import hashlib
|
||
|
|
import importlib.util
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import secrets
|
||
|
|
import string
|
||
|
|
import subprocess
|
||
|
|
import sys
|
||
|
|
import urllib.request
|
||
|
|
from datetime import UTC, datetime
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
HERE = Path(__file__).resolve().parent
|
||
|
|
ROOT = HERE.parents[2]
|
||
|
|
IMAGE = "docker.io/lldap/lldap@sha256:9e605a66c02514bfcffd1b67cafb1e98d50992216bb2871d7ae44622047dd09d"
|
||
|
|
EMAIL_DOMAIN = "service.netkingdom.invalid"
|
||
|
|
# account -> (custody key, groups it must hold exactly)
|
||
|
|
TARGETS = {
|
||
|
|
"ld-admin": ("LLDAP_LDAP_USER_PASS", ["lldap_admin"]),
|
||
|
|
"svc-authelia": ("LLDAP_SVC_AUTHELIA_PASS", ["lldap_strict_readonly"]),
|
||
|
|
"svc-keycape": ("LLDAP_SVC_KEYCAPE_PASS", ["lldap_strict_readonly"]),
|
||
|
|
"svc-privacyidea": ("LLDAP_SVC_PRIVACYIDEA_PASS", ["lldap_strict_readonly"]),
|
||
|
|
"svc-provisioner": (
|
||
|
|
"LLDAP_SVC_PROVISIONER_PASS",
|
||
|
|
["lldap_admin"],
|
||
|
|
), # exception SI-X6
|
||
|
|
}
|
||
|
|
SERVICE_KEYS = [key for key, _ in TARGETS.values() if key != "LLDAP_LDAP_USER_PASS"]
|
||
|
|
# apply-service-secrets.literal() accepts only [A-Za-z0-9]{32,128}.
|
||
|
|
ALPHABET = string.ascii_letters + string.digits
|
||
|
|
|
||
|
|
|
||
|
|
class Refused(Exception):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
def _load(name, path):
|
||
|
|
spec = importlib.util.spec_from_file_location(name, path)
|
||
|
|
module = importlib.util.module_from_spec(spec)
|
||
|
|
spec.loader.exec_module(module)
|
||
|
|
return module
|
||
|
|
|
||
|
|
|
||
|
|
custody_module = _load(
|
||
|
|
"bind_split_custody", ROOT / "sso-mfa/bootstrap/apply-service-secrets.py"
|
||
|
|
)
|
||
|
|
lldap_admin = _load("bind_split_lldap_admin", HERE / "lldap_admin.py")
|
||
|
|
|
||
|
|
|
||
|
|
def new_value():
|
||
|
|
return "".join(secrets.choice(ALPHABET) for _ in range(40))
|
||
|
|
|
||
|
|
|
||
|
|
def custody_values(root):
|
||
|
|
with custody_module.custody(Path(root), ["lldap"]) as directories:
|
||
|
|
values = custody_module.read_env(directories["lldap"])
|
||
|
|
missing = [key for key, _ in TARGETS.values() if not values.get(key)]
|
||
|
|
if missing:
|
||
|
|
raise Refused(
|
||
|
|
"custody lacks " + ", ".join(missing) + "; run prepare-custody first"
|
||
|
|
)
|
||
|
|
return {account: values[key] for account, (key, _) in TARGETS.items()}
|
||
|
|
|
||
|
|
|
||
|
|
def prepare_custody(root):
|
||
|
|
with custody_module.custody(Path(root), ["lldap"]) as directories:
|
||
|
|
parent = directories["lldap"]
|
||
|
|
values = custody_module.read_env(parent)
|
||
|
|
if any(key in values for key in SERVICE_KEYS):
|
||
|
|
raise Refused("custody is already prepared; nothing changed")
|
||
|
|
if not values.get("LLDAP_LDAP_USER_PASS"):
|
||
|
|
raise Refused("custody lacks LLDAP_LDAP_USER_PASS")
|
||
|
|
lines = [
|
||
|
|
line
|
||
|
|
for line in custody_module.read_private(parent, "secrets.env")
|
||
|
|
.decode()
|
||
|
|
.splitlines()
|
||
|
|
if not line.startswith("LLDAP_LDAP_USER_PASS=")
|
||
|
|
]
|
||
|
|
lines += [
|
||
|
|
"# NK-WP-0054-T05 bind split: ld-admin (configured super-admin) and per-consumer accounts.",
|
||
|
|
"# The human admin password is personal and no longer kept here.",
|
||
|
|
f"LLDAP_LDAP_USER_PASS={new_value()}",
|
||
|
|
*(f"{key}={new_value()}" for key in SERVICE_KEYS),
|
||
|
|
]
|
||
|
|
fd = os.open(
|
||
|
|
".secrets.env.next",
|
||
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||
|
|
0o600,
|
||
|
|
dir_fd=parent,
|
||
|
|
)
|
||
|
|
with os.fdopen(fd, "w") as out:
|
||
|
|
out.write("\n".join(lines) + "\n")
|
||
|
|
out.flush()
|
||
|
|
os.fsync(out.fileno())
|
||
|
|
os.replace(
|
||
|
|
".secrets.env.next", "secrets.env", src_dir_fd=parent, dst_dir_fd=parent
|
||
|
|
)
|
||
|
|
return {"changed_keys": ["LLDAP_LDAP_USER_PASS", *SERVICE_KEYS]}
|
||
|
|
|
||
|
|
|
||
|
|
class Directory:
|
||
|
|
def __init__(self, endpoint, opener=None):
|
||
|
|
self.base = lldap_admin.endpoint(endpoint)
|
||
|
|
self.opener = opener or lldap_admin.Transport(self.base)
|
||
|
|
|
||
|
|
def _post(self, path, payload, token=None):
|
||
|
|
headers = {"Content-Type": "application/json"}
|
||
|
|
if token:
|
||
|
|
headers["Authorization"] = f"Bearer {token}"
|
||
|
|
request = urllib.request.Request(
|
||
|
|
self.base + path,
|
||
|
|
data=json.dumps(payload).encode(),
|
||
|
|
headers=headers,
|
||
|
|
method="POST",
|
||
|
|
)
|
||
|
|
return json.load(self.opener(request, timeout=15))
|
||
|
|
|
||
|
|
def login(self, user, password):
|
||
|
|
try:
|
||
|
|
return self._post(
|
||
|
|
"/auth/simple/login", {"username": user, "password": password}
|
||
|
|
)["token"]
|
||
|
|
except lldap_admin.Refused:
|
||
|
|
return None
|
||
|
|
|
||
|
|
def gql(self, token, query, variables=None):
|
||
|
|
body = self._post(
|
||
|
|
"/api/graphql", {"query": query, "variables": variables or {}}, token
|
||
|
|
)
|
||
|
|
if body.get("errors"):
|
||
|
|
raise Refused("directory refused a request")
|
||
|
|
return body["data"]
|
||
|
|
|
||
|
|
def snapshot(self, token):
|
||
|
|
users = self.gql(token, "{ users { id email groups { displayName } } }")[
|
||
|
|
"users"
|
||
|
|
]
|
||
|
|
groups = self.gql(token, "{ groups { id displayName } }")["groups"]
|
||
|
|
return (
|
||
|
|
{
|
||
|
|
u["id"]: {
|
||
|
|
"email": u["email"],
|
||
|
|
"groups": sorted(g["displayName"] for g in u["groups"]),
|
||
|
|
}
|
||
|
|
for u in users
|
||
|
|
},
|
||
|
|
{g["displayName"]: g["id"] for g in groups},
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def set_password(endpoint, token, user, password, runner=subprocess.run):
|
||
|
|
done = runner(
|
||
|
|
[
|
||
|
|
"docker",
|
||
|
|
"run",
|
||
|
|
"--rm",
|
||
|
|
"--pull=never",
|
||
|
|
"--network",
|
||
|
|
"host",
|
||
|
|
"--env",
|
||
|
|
"LLDAP_USER_PASSWORD",
|
||
|
|
"--entrypoint",
|
||
|
|
"/app/lldap_set_password",
|
||
|
|
IMAGE,
|
||
|
|
"--base-url",
|
||
|
|
endpoint,
|
||
|
|
"--token",
|
||
|
|
token,
|
||
|
|
"--username",
|
||
|
|
user,
|
||
|
|
],
|
||
|
|
env={
|
||
|
|
"LLDAP_USER_PASSWORD": password,
|
||
|
|
"PATH": os.environ.get("PATH", "/usr/bin:/bin"),
|
||
|
|
},
|
||
|
|
capture_output=True,
|
||
|
|
check=False,
|
||
|
|
)
|
||
|
|
if done.returncode:
|
||
|
|
raise Refused(f"setting the password of {user} failed")
|
||
|
|
|
||
|
|
|
||
|
|
def admin_token(directory, fd):
|
||
|
|
if fd is not None:
|
||
|
|
with os.fdopen(fd, "r", closefd=True) as stream:
|
||
|
|
password = stream.readline().rstrip("\n")
|
||
|
|
else:
|
||
|
|
password = getpass.getpass("Password of the human admin account: ")
|
||
|
|
token = directory.login("admin", password)
|
||
|
|
if token is None:
|
||
|
|
raise Refused("admin sign-in refused")
|
||
|
|
return token
|
||
|
|
|
||
|
|
|
||
|
|
def assess(users):
|
||
|
|
report = {}
|
||
|
|
for account, (_, groups) in TARGETS.items():
|
||
|
|
found = users.get(account)
|
||
|
|
if found is None:
|
||
|
|
report[account] = {"exists": False, "action": "create"}
|
||
|
|
elif not found["email"].endswith("@" + EMAIL_DOMAIN):
|
||
|
|
report[account] = {
|
||
|
|
"exists": True,
|
||
|
|
"action": "refuse",
|
||
|
|
"reason": "email outside " + EMAIL_DOMAIN,
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
extra = sorted(set(found["groups"]) - set(groups))
|
||
|
|
report[account] = {
|
||
|
|
"exists": True,
|
||
|
|
"groups": found["groups"],
|
||
|
|
"action": "refuse"
|
||
|
|
if extra
|
||
|
|
else ("ok" if found["groups"] == groups else "add-groups"),
|
||
|
|
**(
|
||
|
|
{"reason": "unexpected groups " + ", ".join(extra)} if extra else {}
|
||
|
|
),
|
||
|
|
}
|
||
|
|
report["admin"] = {
|
||
|
|
"exists": "admin" in users,
|
||
|
|
"in_lldap_admin": "lldap_admin" in users.get("admin", {}).get("groups", []),
|
||
|
|
}
|
||
|
|
return report
|
||
|
|
|
||
|
|
|
||
|
|
def accounts(directory, token, passwords, apply, runner=subprocess.run):
|
||
|
|
users, group_ids = directory.snapshot(token)
|
||
|
|
report = assess(users)
|
||
|
|
if any(entry.get("action") == "refuse" for entry in report.values()):
|
||
|
|
return {"applied": False, "accounts": report}
|
||
|
|
if not apply:
|
||
|
|
return {"applied": False, "accounts": report}
|
||
|
|
for account, (_, groups) in TARGETS.items():
|
||
|
|
if not report[account]["exists"]:
|
||
|
|
directory.gql(
|
||
|
|
token,
|
||
|
|
"mutation($u: CreateUserInput!) { createUser(user: $u) { id } }",
|
||
|
|
{
|
||
|
|
"u": {
|
||
|
|
"id": account,
|
||
|
|
"email": f"{account}@{EMAIL_DOMAIN}",
|
||
|
|
"displayName": account,
|
||
|
|
}
|
||
|
|
},
|
||
|
|
)
|
||
|
|
have = set(users.get(account, {}).get("groups", []))
|
||
|
|
for group in groups:
|
||
|
|
if group not in have:
|
||
|
|
directory.gql(
|
||
|
|
token,
|
||
|
|
"mutation($u: String!, $g: Int!) { addUserToGroup(userId: $u, groupId: $g) { ok } }",
|
||
|
|
{"u": account, "g": group_ids[group]},
|
||
|
|
)
|
||
|
|
set_password(directory.base, token, account, passwords[account], runner)
|
||
|
|
return {
|
||
|
|
"applied": True,
|
||
|
|
"accounts": assess(directory.snapshot(token)[0]),
|
||
|
|
"signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def verify(directory, token, passwords, old_admin_password=None):
|
||
|
|
users, _ = directory.snapshot(token)
|
||
|
|
result = {
|
||
|
|
"accounts": assess(users),
|
||
|
|
"signs_in": {a: directory.login(a, passwords[a]) is not None for a in TARGETS},
|
||
|
|
}
|
||
|
|
if old_admin_password is not None:
|
||
|
|
result["old_shared_password_refused"] = (
|
||
|
|
directory.login("admin", old_admin_password) is None
|
||
|
|
)
|
||
|
|
checks = [
|
||
|
|
result["signs_in"][a] and result["accounts"][a].get("action") == "ok"
|
||
|
|
for a in TARGETS
|
||
|
|
]
|
||
|
|
checks.append(result["accounts"]["admin"]["in_lldap_admin"])
|
||
|
|
if old_admin_password is not None:
|
||
|
|
checks.append(result["old_shared_password_refused"])
|
||
|
|
result["verdict"] = "pass" if all(checks) else "fail"
|
||
|
|
return result
|
||
|
|
|
||
|
|
|
||
|
|
def write_receipt(path, command, endpoint, result):
|
||
|
|
record = {
|
||
|
|
"schema": "netkingdom.lldap-bind-split.v1",
|
||
|
|
"task": "NK-WP-0054-T05",
|
||
|
|
"command": command,
|
||
|
|
"observed_at": datetime.now(UTC).isoformat(),
|
||
|
|
"endpoint_sha256": hashlib.sha256(endpoint.encode()).hexdigest()
|
||
|
|
if endpoint
|
||
|
|
else None,
|
||
|
|
"values_recorded": False,
|
||
|
|
"result": result,
|
||
|
|
}
|
||
|
|
fd = os.open(
|
||
|
|
Path(path).expanduser(),
|
||
|
|
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||
|
|
0o600,
|
||
|
|
)
|
||
|
|
with os.fdopen(fd, "w") as out:
|
||
|
|
out.write(json.dumps(record, indent=2, sort_keys=True) + "\n")
|
||
|
|
return record
|
||
|
|
|
||
|
|
|
||
|
|
def main(argv=None):
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
||
|
|
prep = sub.add_parser("prepare-custody")
|
||
|
|
prep.add_argument("root")
|
||
|
|
for name in ("plan", "accounts", "verify"):
|
||
|
|
p = sub.add_parser(name)
|
||
|
|
p.add_argument("--endpoint", required=True)
|
||
|
|
p.add_argument("--admin-password-fd", type=int)
|
||
|
|
if name != "plan":
|
||
|
|
p.add_argument("root")
|
||
|
|
if name == "accounts":
|
||
|
|
p.add_argument("--apply", action="store_true")
|
||
|
|
if name == "verify":
|
||
|
|
p.add_argument(
|
||
|
|
"--old-admin-retired",
|
||
|
|
action="store_true",
|
||
|
|
help="also prompt for the old shared password and require that it is refused",
|
||
|
|
)
|
||
|
|
for p in sub.choices.values():
|
||
|
|
p.add_argument("--receipt", required=True)
|
||
|
|
args = parser.parse_args(argv)
|
||
|
|
try:
|
||
|
|
if Path(args.receipt).expanduser().exists():
|
||
|
|
raise Refused("receipt exists")
|
||
|
|
endpoint = getattr(args, "endpoint", None)
|
||
|
|
if args.command == "prepare-custody":
|
||
|
|
result = prepare_custody(args.root)
|
||
|
|
else:
|
||
|
|
directory = Directory(endpoint)
|
||
|
|
passwords = custody_values(args.root) if args.command != "plan" else None
|
||
|
|
token = admin_token(directory, args.admin_password_fd)
|
||
|
|
if args.command == "plan":
|
||
|
|
result = {"accounts": assess(directory.snapshot(token)[0])}
|
||
|
|
elif args.command == "accounts":
|
||
|
|
result = accounts(directory, token, passwords, args.apply)
|
||
|
|
else:
|
||
|
|
old = (
|
||
|
|
getpass.getpass("Old shared admin password (must now be refused): ")
|
||
|
|
if args.old_admin_retired
|
||
|
|
else None
|
||
|
|
)
|
||
|
|
result = verify(directory, token, passwords, old)
|
||
|
|
write_receipt(args.receipt, args.command, endpoint, result)
|
||
|
|
except (Refused, lldap_admin.Refused, custody_module.Refused) as error:
|
||
|
|
print(f"refused: {error}", file=sys.stderr)
|
||
|
|
return 1
|
||
|
|
print(json.dumps(result, indent=2, sort_keys=True))
|
||
|
|
if result.get("verdict") == "fail" or any(
|
||
|
|
entry.get("action") == "refuse" for entry in result.get("accounts", {}).values()
|
||
|
|
):
|
||
|
|
return 1
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
sys.exit(main())
|