-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbootstrap_admin.py
More file actions
149 lines (122 loc) · 5.11 KB
/
Copy pathbootstrap_admin.py
File metadata and controls
149 lines (122 loc) · 5.11 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
#!/usr/bin/env python3
"""Production bootstrap: create the first organization + admin user.
seed_demo.py (rightly) refuses to run with ENVIRONMENT=production because it
plants well-known demo credentials — which left a fresh production database
with NO way to create the first admin. This command closes that gap:
DATABASE_URL=postgresql+psycopg://... python bootstrap_admin.py \
--org "Acme Fab" --email admin@acme.example
Behavior:
- Password comes from BOOTSTRAP_ADMIN_PASSWORD (env) or --password; when
neither is given a cryptographically random one is GENERATED and printed
exactly once. No credential is ever hardcoded.
- Idempotent and refuse-to-clobber: if the org already has ANY admin the
command exits non-zero without touching anything (bootstrap is for empty
databases, not credential resets).
- Safe in every environment, INCLUDING production — that is its purpose.
- In containers: docker compose run --rm analysis python bootstrap_admin.py …
(the analysis image bakes this file alongside seed_demo.py).
"""
from __future__ import annotations
import argparse
import os
import re
import secrets
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from sqlalchemy import text
from sqlalchemy.orm import Session
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
MIN_PASSWORD_LEN = 12
def _slugify(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
return slug or "org"
def create_admin(db: Session, org_name: str, email: str, password: str) -> dict:
"""Create (or reuse) the organization and create its first admin.
Raises ValueError on invalid input or if an admin already exists in the
target org. Returns a summary dict (no password included).
"""
from services.shared.auth.jwt import hash_password
from services.shared.db.models import Organization, User, UserRole
if not EMAIL_RE.match(email):
raise ValueError(f"'{email}' is not a valid email address")
if len(password) < MIN_PASSWORD_LEN:
raise ValueError(f"password must be at least {MIN_PASSWORD_LEN} characters")
if not org_name.strip():
raise ValueError("organization name must not be empty")
org = db.query(Organization).filter(Organization.name == org_name).first()
created_org = False
if org is None:
org = Organization(name=org_name, slug=_slugify(org_name))
db.add(org)
db.flush()
created_org = True
existing_admin = (
db.query(User).filter(User.organization_id == org.id, User.role == UserRole.ADMIN).first()
)
if existing_admin is not None:
raise ValueError(
f"organization '{org_name}' already has an admin ({existing_admin.email}) — "
"bootstrap only runs against a fresh org. Use the normal user-management "
"path (or reset the password directly) instead."
)
if db.query(User).filter(User.organization_id == org.id, User.email == email).first():
raise ValueError(f"user {email} already exists in '{org_name}'")
user = User(
organization_id=org.id,
email=email,
name=email.split("@")[0],
role=UserRole.ADMIN,
password_hash=hash_password(password),
is_active=True,
)
db.add(user)
db.commit()
return {
"organization": org_name,
"organization_id": str(org.id),
"created_org": created_org,
"admin_email": email,
"admin_id": str(user.id),
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--org", required=True, help="Organization name")
parser.add_argument("--email", required=True, help="Admin email address")
parser.add_argument(
"--password",
default=None,
help="Admin password (min 12 chars). Prefer BOOTSTRAP_ADMIN_PASSWORD "
"env; omit both to auto-generate.",
)
args = parser.parse_args()
password = args.password or os.getenv("BOOTSTRAP_ADMIN_PASSWORD")
generated = False
if not password:
password = secrets.token_urlsafe(18)
generated = True
from services.shared.db.base import SessionLocal
db = SessionLocal()
try:
# Server-controlled bootstrap: opt out of RLS for this connection
# (same convention as seed_demo.py; required under the NOBYPASSRLS
# app role).
db.execute(text("SET spectra.current_org_id = 'admin'"))
summary = create_admin(db, args.org, args.email, password)
except ValueError as exc:
print(f"REFUSED: {exc}", file=sys.stderr)
return 2
except Exception as exc: # noqa: BLE001 — surface the real failure
print(f"ERROR: {exc}", file=sys.stderr)
return 1
finally:
db.close()
print("Bootstrap complete:")
for k, v in summary.items():
print(f" {k}: {v}")
if generated:
print("\nGenerated admin password (shown ONCE — store it in your password manager):")
print(f" {password}")
return 0
if __name__ == "__main__":
sys.exit(main())