blob: 04dce169ab9bf4fec4a534c55f8da047021fc89b [file] [log] [blame]
margaretha335cd782026-03-19 11:57:30 +01001#!/usr/bin/env python3
2"""
3import-c2-groups.py – Import c2-grps group definitions into Kustvakt.
4
5Input file format (one entry per line):
6 G_groupName=username1,username2,username3
7
8Lines starting with '#' or blank lines are ignored.
9The 'G_' prefix is stripped from group names before importing.
10
11The script obtains a Bearer token via the OAuth2 password grant and calls:
12 PUT <base-url>/<api-version>/group/@<groupName>
13 PUT <base-url>/<api-version>/group/@<groupName>/member
14
15Usage:
16 python3 import-c2-groups.py --file data/c2-grps.txt \\
17 --url http://localhost:8080/api \\
18 --user admin --password pass \\
19 --client-id <client_id> --client-secret <client_secret>
20
21Run with --help for all options.
22"""
23
24import argparse
25import json
26import logging
27import sys
28import urllib.error
29import urllib.parse
30import urllib.request
31from pathlib import Path
32
33logging.basicConfig(
34 level=logging.INFO,
35 format="%(levelname)s %(message)s",
36)
37log = logging.getLogger(__name__)
38
39
40# ---------------------------------------------------------------------------
41# HTTP helpers
42# ---------------------------------------------------------------------------
43
44def fetch_bearer_token(
45 base_url: str,
46 api_version: str,
47 user: str,
48 password: str,
49 client_id: str,
50 client_secret: str,
51) -> str:
52 """Obtain an OAuth2 Bearer token via the resource-owner password grant.
53
54 Returns the raw access-token string.
55 Raises SystemExit on failure.
56 """
57 token_url = f"{base_url}/{api_version}/oauth2/token"
58 body = urllib.parse.urlencode({
59 "grant_type": "password",
60 "username": user,
61 "password": password,
62 "client_id": client_id,
63 "client_secret": client_secret,
64 }).encode()
65
66 req = urllib.request.Request(
67 token_url,
68 data=body,
69 method="POST",
70 headers={"Content-Type": "application/x-www-form-urlencoded"},
71 )
72 try:
73 with urllib.request.urlopen(req) as resp:
74 data = json.loads(resp.read().decode())
75 except urllib.error.HTTPError as exc:
76 log.error("Token request failed (HTTP %s): %s",
77 exc.code, exc.read().decode(errors="replace"))
78 sys.exit(1)
79 except urllib.error.URLError as exc:
80 log.error("Token request network error: %s", exc.reason)
81 sys.exit(1)
82
83 token = data.get("access_token")
84 if not token:
85 log.error("No access_token in response: %s", data)
86 sys.exit(1)
87
88 log.debug("Bearer token obtained successfully.")
89 return token
90
91
92def _put(url: str, form: dict, auth_header: str, dry_run: bool) -> int:
93 """Send a PUT request with application/x-www-form-urlencoded body.
94
95 Returns the HTTP status code, or 0 on network/URL error.
96 """
97 body = urllib.parse.urlencode(form).encode()
98 req = urllib.request.Request(
99 url,
100 data=body,
101 method="PUT",
102 headers={
103 "Authorization": auth_header,
104 "Content-Type": "application/x-www-form-urlencoded",
105 },
106 )
107 if dry_run:
108 log.info("[DRY-RUN] PUT %s body=%s", url, form)
109 return 201
110
111 try:
112 with urllib.request.urlopen(req) as resp:
113 return resp.status
114 except urllib.error.HTTPError as exc:
115 body_text = exc.read().decode(errors="replace")
116 log.error("HTTP %s for %s – %s", exc.code, url, body_text)
117 return exc.code
118 except urllib.error.URLError as exc:
119 log.error("Network error for %s – %s", url, exc.reason)
120 return 0
121
122
123# ---------------------------------------------------------------------------
124# Parsing
125# ---------------------------------------------------------------------------
126
127def parse_groups(path: Path) -> list[tuple[str, list[str]]]:
128 """Parse the c2-grps file and return [(groupName, [member, ...]), ...]."""
129 groups = []
130 for lineno, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
131 line = raw.strip()
132 if not line or line.startswith("#"):
133 continue
134 if "=" not in line:
135 log.warning("Line %d: no '=' found, skipping: %s", lineno, line)
136 continue
137 left, _, right = line.partition("=")
138 raw_name = left.strip()
139 group_name = raw_name[2:] if raw_name.startswith("G_") else raw_name
140 members = [m.strip() for m in right.split(",") if m.strip()]
141 # deduplicate while preserving order
142 seen: set[str] = set()
143 unique_members: list[str] = []
144 for m in members:
145 if m not in seen:
146 seen.add(m)
147 unique_members.append(m)
148 groups.append((group_name, unique_members))
149 return groups
150
151
152# ---------------------------------------------------------------------------
153# Import
154# ---------------------------------------------------------------------------
155
156def import_groups(
157 groups: list[tuple[str, list[str]]],
158 base_url: str,
159 api_version: str,
160 auth_header: str,
161 add_members: bool,
162 dry_run: bool,
163) -> None:
164 created = updated = member_ok = member_err = 0
165
166 for group_name, members in groups:
167 group_url = f"{base_url}/{api_version}/group/@{group_name}"
168
169 # --- create / update group ---
170 status = _put(group_url, {}, auth_header, dry_run)
171 if status == 201:
172 log.info("Created group '%s'", group_name)
173 created += 1
174 elif status == 204:
175 log.info("Exists group '%s' (no-op)", group_name)
176 updated += 1
177 else:
178 log.error("Failed to create/update group '%s' (HTTP %s)", group_name, status)
179 continue
180
181 # --- add members ---
182 if add_members and members:
183 member_url = f"{group_url}/member"
184 status = _put(
185 member_url,
186 {"members": ",".join(members)},
187 auth_header,
188 dry_run,
189 )
190 if status in (200, 201, 204):
191 log.info(" Added %d member(s) to '%s'", len(members), group_name)
192 member_ok += 1
193 else:
194 log.error(
195 " Failed to add members to '%s' (HTTP %s)", group_name, status
196 )
197 member_err += 1
198 elif add_members and not members:
199 log.debug(" No members listed for '%s'", group_name)
200
201 log.info(
202 "Done: %d created, %d already existed, "
203 "%d member imports OK, %d member imports failed.",
204 created, updated, member_ok, member_err,
205 )
206
207
208# ---------------------------------------------------------------------------
209# CLI
210# ---------------------------------------------------------------------------
211
212def build_parser() -> argparse.ArgumentParser:
213 p = argparse.ArgumentParser(
214 description=__doc__,
215 formatter_class=argparse.RawDescriptionHelpFormatter,
216 )
217 p.add_argument(
218 "--file",
219 default="data/c2-grps.txt",
220 metavar="PATH",
221 help="Path to the c2-grps input file (default: data/c2-grps.txt)",
222 )
223 p.add_argument(
224 "--url",
225 default="http://localhost:8080/api",
226 metavar="URL",
227 help="Kustvakt base URL, without trailing slash "
228 "(default: http://localhost:8080/api)",
229 )
230 p.add_argument(
231 "--api-version",
232 default="v1.0",
233 metavar="VER",
234 help="API version segment, e.g. v1.0 or v1.1 (default: v1.0)",
235 )
236 p.add_argument(
237 "--user",
238 default="admin",
239 metavar="USERNAME",
240 help="Resource-owner username (default: admin)",
241 )
242 p.add_argument(
243 "--password",
244 default=None,
245 metavar="PASSWORD",
246 help="Resource-owner password (prompted if omitted)",
247 )
248 p.add_argument(
249 "--client-id",
250 required=True,
251 metavar="CLIENT_ID",
252 help="OAuth2 client_id for the password grant",
253 )
254 p.add_argument(
255 "--client-secret",
256 default="",
257 metavar="CLIENT_SECRET",
258 help="OAuth2 client_secret (default: empty, for public clients)",
259 )
260 p.add_argument(
261 "--skip-members",
262 action="store_true",
263 help="Create groups only, do not add members",
264 )
265 p.add_argument(
266 "--dry-run",
267 action="store_true",
268 help="Print what would be done without sending any requests",
269 )
270 p.add_argument(
271 "--verbose",
272 action="store_true",
273 help="Enable DEBUG-level logging",
274 )
275 return p
276
277
278def main() -> None:
279 args = build_parser().parse_args()
280
281 if args.verbose:
282 logging.getLogger().setLevel(logging.DEBUG)
283
284 path = Path(args.file)
285 if not path.exists():
286 log.error("Input file not found: %s", path)
287 sys.exit(1)
288
289 base_url = args.url.rstrip("/")
290
291 if args.dry_run:
292 auth_header = "Bearer <dry-run>"
293 else:
294 password = args.password
295 if password is None:
296 import getpass
297 password = getpass.getpass(f"Password for '{args.user}': ")
298 token = fetch_bearer_token(
299 base_url=base_url,
300 api_version=args.api_version,
301 user=args.user,
302 password=password,
303 client_id=args.client_id,
304 client_secret=args.client_secret,
305 )
306 auth_header = f"Bearer {token}"
307
308 groups = parse_groups(path)
309 log.info("Parsed %d group(s) from %s", len(groups), path)
310
311 if not groups:
312 log.warning("No groups found – nothing to do.")
313 return
314
315 import_groups(
316 groups=groups,
317 base_url=base_url,
318 api_version=args.api_version,
319 auth_header=auth_header,
320 add_members=not args.skip_members,
321 dry_run=args.dry_run,
322 )
323
324
325if __name__ == "__main__":
326 main()