| margaretha | 335cd78 | 2026-03-19 11:57:30 +0100 | [diff] [blame^] | 1 | #!/usr/bin/env python3 |
| 2 | """ |
| 3 | import-c2-groups.py – Import c2-grps group definitions into Kustvakt. |
| 4 | |
| 5 | Input file format (one entry per line): |
| 6 | G_groupName=username1,username2,username3 |
| 7 | |
| 8 | Lines starting with '#' or blank lines are ignored. |
| 9 | The 'G_' prefix is stripped from group names before importing. |
| 10 | |
| 11 | The 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 | |
| 15 | Usage: |
| 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 | |
| 21 | Run with --help for all options. |
| 22 | """ |
| 23 | |
| 24 | import argparse |
| 25 | import json |
| 26 | import logging |
| 27 | import sys |
| 28 | import urllib.error |
| 29 | import urllib.parse |
| 30 | import urllib.request |
| 31 | from pathlib import Path |
| 32 | |
| 33 | logging.basicConfig( |
| 34 | level=logging.INFO, |
| 35 | format="%(levelname)s %(message)s", |
| 36 | ) |
| 37 | log = logging.getLogger(__name__) |
| 38 | |
| 39 | |
| 40 | # --------------------------------------------------------------------------- |
| 41 | # HTTP helpers |
| 42 | # --------------------------------------------------------------------------- |
| 43 | |
| 44 | def 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 | |
| 92 | def _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 | |
| 127 | def 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 | |
| 156 | def 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 | |
| 212 | def 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 | |
| 278 | def 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 | |
| 325 | if __name__ == "__main__": |
| 326 | main() |