384 lines
18 KiB
Python
384 lines
18 KiB
Python
|
|
"""Coulomb-only identity bootstrap; stdin auth is temporary, API pairs stay private.
|
||
|
|
|
||
|
|
No personal Console session is a routine credential. Default preview has no
|
||
|
|
effects. Native S3/escrow boundary checks remain mandatory after this IAM stage.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import argparse
|
||
|
|
import fcntl
|
||
|
|
import json
|
||
|
|
import os
|
||
|
|
import stat
|
||
|
|
import sys
|
||
|
|
import tempfile
|
||
|
|
from datetime import UTC, datetime, timedelta
|
||
|
|
from pathlib import Path
|
||
|
|
from urllib.error import HTTPError
|
||
|
|
from urllib.parse import urlencode
|
||
|
|
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||
|
|
|
||
|
|
PUBLISHER = "681d00bf-b965-4323-b657-97bfde423511"
|
||
|
|
PROJECT = "coulomb-social-recovery"
|
||
|
|
DECISION = "27bbc1ac-e275-42be-ba34-6cf16eae4e78"
|
||
|
|
PROGRAMMATIC_DIRECTION = "6139e02b-3fb7-4eff-be8a-efe127c45ce5"
|
||
|
|
ROLES = {
|
||
|
|
"bootstrap": {"name": "coulomb-social-recovery-bootstrap", "permissions": [
|
||
|
|
"ObjectStorageFullAccess", "SecretManagerFullAccess"]},
|
||
|
|
"reader": {"name": "coulomb-social-recovery-reader", "permissions": [
|
||
|
|
"ObjectStorageReadOnly"]},
|
||
|
|
"custodian": {"name": "coulomb-social-recovery-custodian", "permissions": [
|
||
|
|
"ObjectStorageReadOnly", "ObjectStorageObjectsWrite", "SecretManagerReadOnly",
|
||
|
|
"SecretManagerSecretAccess"]},
|
||
|
|
"publisher": {"name": "coulomb-social-backup-primary", "permissions": [
|
||
|
|
"ObjectStorageObjectsRead", "ObjectStorageObjectsWrite", "ObjectStorageObjectsDelete",
|
||
|
|
"ObjectStorageBucketsRead"]},
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
class Refused(RuntimeError):
|
||
|
|
pass
|
||
|
|
|
||
|
|
|
||
|
|
class ProviderError(Refused):
|
||
|
|
def __init__(self, status):
|
||
|
|
super().__init__("provider_request_refused")
|
||
|
|
self.status = status
|
||
|
|
|
||
|
|
|
||
|
|
def inside_git(path):
|
||
|
|
return any((parent / ".git").exists() for parent in [path.resolve(), *path.resolve().parents])
|
||
|
|
|
||
|
|
|
||
|
|
def private_directory(path):
|
||
|
|
info = path.lstat()
|
||
|
|
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||
|
|
raise Refused("private_owner_directory_required")
|
||
|
|
if inside_git(path):
|
||
|
|
raise Refused("credential_directory_must_be_outside_git")
|
||
|
|
|
||
|
|
|
||
|
|
def fsync_directory(path):
|
||
|
|
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||
|
|
try:
|
||
|
|
os.fsync(fd)
|
||
|
|
finally:
|
||
|
|
os.close(fd)
|
||
|
|
|
||
|
|
|
||
|
|
def private_json(path):
|
||
|
|
info = path.lstat()
|
||
|
|
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
||
|
|
raise Refused("private_owner_file_required")
|
||
|
|
return json.loads(path.read_text())
|
||
|
|
|
||
|
|
|
||
|
|
def checkpoint(path, value):
|
||
|
|
fd, name = tempfile.mkstemp(prefix=".checkpoint-", dir=path.parent)
|
||
|
|
try:
|
||
|
|
with os.fdopen(fd, "w") as stream:
|
||
|
|
json.dump(value, stream, sort_keys=True, indent=2)
|
||
|
|
stream.write("\n")
|
||
|
|
stream.flush()
|
||
|
|
os.fsync(stream.fileno())
|
||
|
|
os.replace(name, path)
|
||
|
|
fsync_directory(path.parent)
|
||
|
|
finally:
|
||
|
|
if os.path.exists(name):
|
||
|
|
os.unlink(name)
|
||
|
|
|
||
|
|
|
||
|
|
class Transport:
|
||
|
|
def __init__(self, auth):
|
||
|
|
if set(auth) != {"session_token", "user_agent"} or any(
|
||
|
|
not isinstance(v, str) or not v or "\n" in v or "\r" in v for v in auth.values()
|
||
|
|
):
|
||
|
|
raise Refused("temporary_stdin_auth_required")
|
||
|
|
self.headers = {"X-Session-Token": auth["session_token"],
|
||
|
|
"User-Agent": auth["user_agent"], "Content-Type": "application/json"}
|
||
|
|
|
||
|
|
class NoRedirect(HTTPRedirectHandler):
|
||
|
|
def redirect_request(self, *args, **kwargs):
|
||
|
|
raise Refused("provider_redirect_refused")
|
||
|
|
|
||
|
|
self.opener = build_opener(NoRedirect())
|
||
|
|
|
||
|
|
def __call__(self, method, path, data=None):
|
||
|
|
if not path.startswith(("/iam/v1alpha1/", "/account/v3/")) or ".." in path:
|
||
|
|
raise Refused("provider_api_scope_refused")
|
||
|
|
request = Request("https://api.scaleway.com" + path, method=method,
|
||
|
|
headers=self.headers,
|
||
|
|
data=None if data is None else json.dumps(data).encode())
|
||
|
|
try:
|
||
|
|
with self.opener.open(request, timeout=20) as response:
|
||
|
|
raw = response.read(8 * 1024**2)
|
||
|
|
return json.loads(raw) if raw else {}
|
||
|
|
except HTTPError as error:
|
||
|
|
raise ProviderError(error.code) from None
|
||
|
|
|
||
|
|
|
||
|
|
class Bootstrap:
|
||
|
|
def __init__(self, api, directory, now=None):
|
||
|
|
self.api, self.directory = api, directory
|
||
|
|
private_directory(directory)
|
||
|
|
self.now = now or datetime.now(UTC)
|
||
|
|
self.path = directory / "identity-state.json"
|
||
|
|
self.state = private_json(self.path) if self.path.exists() else {
|
||
|
|
"schema": "platform.coulomb-recovery-identities.v1", "decision": DECISION,
|
||
|
|
"access_decision": PROGRAMMATIC_DIRECTION, "apps": {}, "policies": {}, "keys": {},
|
||
|
|
}
|
||
|
|
if self.state.get("decision") != DECISION or self.state.get("access_decision") != (
|
||
|
|
PROGRAMMATIC_DIRECTION
|
||
|
|
) or self.state.get("schema") != "platform.coulomb-recovery-identities.v1":
|
||
|
|
raise Refused("journal_scope_refused")
|
||
|
|
if self.state.get("pending_key_issue"):
|
||
|
|
raise Refused("uncertain_key_issue_needs_reconciliation")
|
||
|
|
|
||
|
|
def save(self):
|
||
|
|
checkpoint(self.path, self.state)
|
||
|
|
|
||
|
|
def inventory(self, path, key, **filters):
|
||
|
|
rows = []
|
||
|
|
for page in range(1, 101):
|
||
|
|
value = self.api("GET", path + "?" + urlencode({**filters, "page_size": 100,
|
||
|
|
"page": page}))
|
||
|
|
batch = value[key]
|
||
|
|
if not isinstance(batch, list):
|
||
|
|
raise Refused("invalid_provider_inventory")
|
||
|
|
rows.extend(batch)
|
||
|
|
count = int(value["total_count"])
|
||
|
|
if len(rows) == count:
|
||
|
|
return rows
|
||
|
|
if not batch or len(rows) > count:
|
||
|
|
break
|
||
|
|
raise Refused("complete_inventory_required")
|
||
|
|
|
||
|
|
def project(self, organization):
|
||
|
|
if self.state.get("project"):
|
||
|
|
project = self.api("GET", "/account/v3/projects/" + self.state["project"]["id"])
|
||
|
|
else:
|
||
|
|
found = [v for v in self.inventory("/account/v3/projects", "projects",
|
||
|
|
organization_id=organization) if v["name"] == PROJECT]
|
||
|
|
if found:
|
||
|
|
raise Refused("untracked_existing_project_requires_review")
|
||
|
|
project = self.api("POST", "/account/v3/projects", {
|
||
|
|
"name": PROJECT, "organization_id": organization,
|
||
|
|
"description": "Coulomb-only recovery custody; decision " + DECISION,
|
||
|
|
})
|
||
|
|
self.state["project"] = project
|
||
|
|
self.save()
|
||
|
|
if project["name"] != PROJECT or project["organization_id"] != organization:
|
||
|
|
raise Refused("project_binding_refused")
|
||
|
|
return project
|
||
|
|
|
||
|
|
def application(self, role, organization, applications):
|
||
|
|
spec = ROLES[role]
|
||
|
|
if role == "publisher":
|
||
|
|
app = self.api("GET", "/iam/v1alpha1/applications/" + PUBLISHER)
|
||
|
|
elif role in self.state["apps"]:
|
||
|
|
app = self.api("GET", "/iam/v1alpha1/applications/" + self.state["apps"][role]["id"])
|
||
|
|
else:
|
||
|
|
if any(v["name"] == spec["name"] for v in applications):
|
||
|
|
raise Refused("untracked_existing_application_requires_review")
|
||
|
|
app = self.api("POST", "/iam/v1alpha1/applications", {
|
||
|
|
"name": spec["name"], "organization_id": organization,
|
||
|
|
"description": "Coulomb-only " + role + "; " + DECISION,
|
||
|
|
})
|
||
|
|
self.state["apps"][role] = app
|
||
|
|
self.save()
|
||
|
|
if app["name"] != spec["name"] or app["organization_id"] != organization:
|
||
|
|
raise Refused("application_binding_refused")
|
||
|
|
return app
|
||
|
|
|
||
|
|
def policy(self, role, app, project, organization):
|
||
|
|
name = "coulomb-recovery-project-" + role
|
||
|
|
if role in self.state["policies"]:
|
||
|
|
policy = self.api("GET", "/iam/v1alpha1/policies/" + self.state["policies"][role]["id"])
|
||
|
|
else:
|
||
|
|
policies = self.inventory("/iam/v1alpha1/policies", "policies",
|
||
|
|
organization_id=organization)
|
||
|
|
if any(p["name"] == name for p in policies):
|
||
|
|
raise Refused("untracked_existing_policy_requires_review")
|
||
|
|
policy = self.api("POST", "/iam/v1alpha1/policies", {
|
||
|
|
"name": name, "description": "Coulomb recovery project only; " + DECISION,
|
||
|
|
"organization_id": organization, "application_id": app["id"],
|
||
|
|
"rules": [{"project_ids": [project["id"]],
|
||
|
|
"permission_set_names": ROLES[role]["permissions"]}],
|
||
|
|
})
|
||
|
|
self.state["policies"][role] = policy
|
||
|
|
self.save()
|
||
|
|
if (policy["application_id"] != app["id"] or policy["name"] != name
|
||
|
|
or policy["organization_id"] != organization):
|
||
|
|
raise Refused("policy_binding_refused")
|
||
|
|
rules = self.api("GET", "/iam/v1alpha1/rules?" + urlencode({"policy_id": policy["id"]}))
|
||
|
|
if len(rules["rules"]) != 1:
|
||
|
|
raise Refused("single_project_rule_required")
|
||
|
|
rule = rules["rules"][0]
|
||
|
|
if (rule.get("organization_id") or rule.get("project_ids") != [project["id"]]
|
||
|
|
or set(rule.get("permission_set_names", [])) != set(ROLES[role]["permissions"])
|
||
|
|
or rule.get("condition") or rule.get("deny")):
|
||
|
|
raise Refused("effective_project_rule_refused")
|
||
|
|
|
||
|
|
def key(self, role, app, project):
|
||
|
|
path = self.directory / (role + "-pair.json")
|
||
|
|
if role in self.state["keys"]:
|
||
|
|
pair = private_json(path)
|
||
|
|
if set(pair) != {"AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}:
|
||
|
|
raise Refused("exact_pair_fields_required")
|
||
|
|
current = self.api("GET", "/iam/v1alpha1/api-keys/" + pair["AWS_ACCESS_KEY_ID"])
|
||
|
|
if (current["application_id"] != app["id"]
|
||
|
|
or current["default_project_id"] != project["id"]):
|
||
|
|
raise Refused("existing_key_binding_refused")
|
||
|
|
return
|
||
|
|
if path.exists() or path.is_symlink():
|
||
|
|
raise Refused("untracked_pair_requires_review")
|
||
|
|
self.state["pending_key_issue"] = {"role": role, "application_id": app["id"]}
|
||
|
|
self.save()
|
||
|
|
payload = {"application_id": app["id"], "default_project_id": project["id"],
|
||
|
|
"description": "Coulomb recovery " + role + "; " + DECISION}
|
||
|
|
if role == "bootstrap":
|
||
|
|
payload["expires_at"] = (self.now + timedelta(hours=2)).isoformat()
|
||
|
|
try:
|
||
|
|
issued = self.api("POST", "/iam/v1alpha1/api-keys", payload)
|
||
|
|
except ProviderError as error:
|
||
|
|
if error.status in {400, 401, 403, 404, 422, 429}:
|
||
|
|
self.state.pop("pending_key_issue")
|
||
|
|
self.save()
|
||
|
|
raise
|
||
|
|
try:
|
||
|
|
if issued.get("application_id") != app["id"]:
|
||
|
|
raise Refused("issued_key_binding_refused")
|
||
|
|
if role == "bootstrap":
|
||
|
|
expires = datetime.fromisoformat(
|
||
|
|
(issued.get("expires_at") or "").replace("Z", "+00:00"))
|
||
|
|
if not self.now < expires <= self.now + timedelta(hours=2):
|
||
|
|
raise Refused("bounded_bootstrap_expiry_required")
|
||
|
|
pair = {"AWS_ACCESS_KEY_ID": issued["access_key"],
|
||
|
|
"AWS_SECRET_ACCESS_KEY": issued["secret_key"]}
|
||
|
|
if any(not isinstance(v, str) or not v for v in pair.values()):
|
||
|
|
raise Refused("complete_pair_required")
|
||
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||
|
|
with os.fdopen(fd, "w") as stream:
|
||
|
|
json.dump(pair, stream)
|
||
|
|
stream.write("\n")
|
||
|
|
stream.flush()
|
||
|
|
os.fsync(stream.fileno())
|
||
|
|
fsync_directory(path.parent)
|
||
|
|
except (Refused, OSError, KeyError, ValueError, TypeError):
|
||
|
|
if issued.get("access_key") and issued.get("application_id") == app["id"]:
|
||
|
|
try:
|
||
|
|
self.api("DELETE", "/iam/v1alpha1/api-keys/" + issued["access_key"])
|
||
|
|
self.state.pop("pending_key_issue")
|
||
|
|
self.save()
|
||
|
|
except (Refused, OSError, KeyError, ValueError, TypeError):
|
||
|
|
raise Refused("pair_persistence_and_revocation_failed_review_required") from None
|
||
|
|
raise Refused("pair_persistence_failed_review_required") from None
|
||
|
|
self.state["keys"][role] = {"application_id": app["id"], "file": path.name,
|
||
|
|
"expires_at": issued.get("expires_at")}
|
||
|
|
self.state.pop("pending_key_issue")
|
||
|
|
self.save()
|
||
|
|
|
||
|
|
def run(self):
|
||
|
|
publisher = self.api("GET", "/iam/v1alpha1/applications/" + PUBLISHER)
|
||
|
|
if publisher["id"] != PUBLISHER or publisher["name"] != ROLES["publisher"]["name"]:
|
||
|
|
raise Refused("publisher_binding_refused")
|
||
|
|
organization = publisher["organization_id"]
|
||
|
|
permissions = self.inventory("/iam/v1alpha1/permission-sets", "permission_sets",
|
||
|
|
organization_id=organization)
|
||
|
|
names = {v["name"] for v in permissions}
|
||
|
|
if any(set(role["permissions"]) - names for role in ROLES.values()):
|
||
|
|
raise Refused("required_permission_sets_unavailable")
|
||
|
|
applications = self.inventory("/iam/v1alpha1/applications", "applications",
|
||
|
|
organization_id=organization)
|
||
|
|
project = self.project(organization)
|
||
|
|
for role in ROLES:
|
||
|
|
app = self.application(role, organization, applications)
|
||
|
|
self.policy(role, app, project, organization)
|
||
|
|
if role != "publisher":
|
||
|
|
self.key(role, app, project)
|
||
|
|
self.state["status"] = "identity_bootstrap_complete_native_boundaries_pending"
|
||
|
|
self.save()
|
||
|
|
return {"status": self.state["status"], "new_project": PROJECT,
|
||
|
|
"managed_recovery_applications": 3, "project_policies_verified": 4,
|
||
|
|
"private_pairs": 3,
|
||
|
|
"publisher_pair_replaced": False, "organization_wide_grants": False,
|
||
|
|
"bootstrap_key_max_hours": 2, "native_s3_escrow_boundaries_verified": False,
|
||
|
|
"credential_values_printed": False}
|
||
|
|
|
||
|
|
def retire_bootstrap(self):
|
||
|
|
if self.state.get("status") != "identity_bootstrap_complete_native_boundaries_pending":
|
||
|
|
raise Refused("completed_identity_stage_required")
|
||
|
|
app = self.state["apps"]["bootstrap"]
|
||
|
|
actual = self.api("GET", "/iam/v1alpha1/applications/" + app["id"])
|
||
|
|
if actual["name"] != ROLES["bootstrap"]["name"]:
|
||
|
|
raise Refused("bootstrap_application_binding_refused")
|
||
|
|
path = self.directory / "bootstrap-pair.json"
|
||
|
|
if self.state["keys"]["bootstrap"].get("revoked_verified") and not path.exists():
|
||
|
|
return {"status": "bootstrap_api_key_retired", "credential_values_printed": False}
|
||
|
|
pair = private_json(path)
|
||
|
|
key_path = "/iam/v1alpha1/api-keys/" + pair["AWS_ACCESS_KEY_ID"]
|
||
|
|
try:
|
||
|
|
current = self.api("GET", key_path)
|
||
|
|
except ProviderError as error:
|
||
|
|
if error.status != 404:
|
||
|
|
raise
|
||
|
|
else:
|
||
|
|
if (current["application_id"] != app["id"] or current["default_project_id"] != (
|
||
|
|
self.state["project"]["id"]
|
||
|
|
)):
|
||
|
|
raise Refused("retirement_key_binding_refused")
|
||
|
|
self.api("DELETE", key_path)
|
||
|
|
try:
|
||
|
|
self.api("GET", key_path)
|
||
|
|
except ProviderError as error:
|
||
|
|
if error.status != 404:
|
||
|
|
raise
|
||
|
|
else:
|
||
|
|
raise Refused("bootstrap_revocation_unconfirmed")
|
||
|
|
self.state["keys"]["bootstrap"]["revoked_verified"] = True
|
||
|
|
self.save()
|
||
|
|
path.unlink() # Only this just-revoked temporary pair, never retained archives/pairs.
|
||
|
|
fsync_directory(path.parent)
|
||
|
|
return {"status": "bootstrap_api_key_retired", "own_temporary_pair_removed": True,
|
||
|
|
"credential_values_printed": False}
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
||
|
|
parser.add_argument("--execute", action="store_true")
|
||
|
|
parser.add_argument("--directory", type=Path)
|
||
|
|
parser.add_argument("--retire-bootstrap", action="store_true")
|
||
|
|
args = parser.parse_args()
|
||
|
|
if not args.execute:
|
||
|
|
print(json.dumps({"status": "preview", "project": PROJECT,
|
||
|
|
"applications": [v["name"] for k, v in ROLES.items() if k != "publisher"],
|
||
|
|
"provider_contacted": False, "credential_values_printed": False}))
|
||
|
|
return 0
|
||
|
|
if not args.directory or not args.directory.is_absolute():
|
||
|
|
raise Refused("private_absolute_directory_required")
|
||
|
|
private_directory(args.directory)
|
||
|
|
fd = os.open(args.directory / "identity-bootstrap.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW,
|
||
|
|
0o600)
|
||
|
|
with os.fdopen(fd, "w") as lock:
|
||
|
|
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||
|
|
raw = sys.stdin.read(65537)
|
||
|
|
if len(raw) > 65536:
|
||
|
|
raise Refused("bounded_temporary_auth_required")
|
||
|
|
auth = json.loads(raw)
|
||
|
|
bootstrap = Bootstrap(Transport(auth), args.directory)
|
||
|
|
print(json.dumps(bootstrap.retire_bootstrap() if args.retire_bootstrap else bootstrap.run()))
|
||
|
|
return 0
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
try:
|
||
|
|
raise SystemExit(main())
|
||
|
|
except (Refused, OSError, KeyError, ValueError, TypeError) as error:
|
||
|
|
print(json.dumps({"status": "failed", "failure_type": type(error).__name__,
|
||
|
|
"http_status": getattr(error, "status", None),
|
||
|
|
"refusal": str(error) if isinstance(error, Refused) and all(
|
||
|
|
c.islower() or c == "_" for c in str(error)) else None,
|
||
|
|
"credential_values_printed": False}))
|
||
|
|
raise SystemExit(1) from None
|