Created
September 21, 2026 13:17
-
-
Save r00ta/724cc68fd88b22a044d6620e1d602afb to your computer and use it in GitHub Desktop.
RBAC/Candid migration tool
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # Copyright 2026 Canonical Ltd. This software is licensed under the | |
| # GNU Affero General Public License version 3 (see the file LICENSE). | |
| """Prepare a MAAS deployment for the removal of Candid/RBAC external auth. | |
| Support for the legacy Candid/RBAC (macaroon-based) external authentication is | |
| removed in MAAS 4.0. During the upgrade, the Alembic migration | |
| ``0038_block_upgrade_if_external_auth_configured`` permanently deletes every | |
| Candid/RBAC user account, but it aborts the upgrade if any of those accounts | |
| still owns nodes, IP ranges or static IP addresses. | |
| This standalone helper lets an administrator inspect and fix their deployment | |
| *before* upgrading, while still running the old MAAS release. It connects | |
| directly to the MAAS PostgreSQL database and offers commands to: | |
| * list local users; | |
| * list Candid/RBAC (external) users; | |
| * list the nodes, IP ranges and static IP addresses those users own; | |
| * re-assign each of those resources to a local user; | |
| * check whether the deployment is ready to be upgraded. | |
| Candid/RBAC users are identified exactly as the migration does: non-local | |
| accounts without an OIDC provider, i.e. ``maasserver_userprofile`` rows with | |
| ``is_local = false`` and ``provider_id IS NULL`` (OIDC users are also non-local | |
| but always have a ``provider_id``). | |
| This script assumes the deployment is running MAAS 3.8 with all of its latest | |
| database migrations successfully applied. It relies on that schema, so running | |
| it against any other version may give incorrect results or fail. | |
| The only dependency is ``psycopg2``, which ships with every deb-MAAS installation. | |
| If using snap, you can install the package with `sudo apt-get install python3-psycopg2` | |
| The database connection details are read from the environment variables | |
| ``MAAS_DB_HOST``, ``MAAS_DB_PORT``, ``MAAS_DB_NAME``, ``MAAS_DB_USER`` and | |
| ``MAAS_DB_PASS``. Any value that is not set in the environment must be provided | |
| on the command line with the matching ``--host``, ``--port``, ``--dbname``, | |
| ``--user`` or ``--password`` option (the command-line option takes precedence | |
| over the environment variable). | |
| Usage examples:: | |
| export MAAS_DB_HOST=localhost MAAS_DB_PORT=5432 MAAS_DB_NAME=maasdb | |
| export MAAS_DB_USER=maas MAAS_DB_PASS=secret | |
| ./migrate-external-auth-users.py external-users | |
| ./migrate-external-auth-users.py nodes | |
| ./migrate-external-auth-users.py reassign-node --system-id abc123 --to alice | |
| ./migrate-external-auth-users.py check | |
| # or, providing everything on the command line: | |
| ./migrate-external-auth-users.py --host localhost --port 5432 \ | |
| --dbname maasdb --user maas --password secret check | |
| """ | |
| import argparse | |
| import os | |
| import sys | |
| try: | |
| import psycopg2 | |
| except ImportError: # pragma: no cover - dependency provided by MAAS | |
| sys.exit( | |
| "The 'psycopg2' module is required. Install it with `sudo apt-get install python3-psycopg2`." | |
| ) | |
| EXTERNAL_AUTH_SECRET_PATH = "global/external-auth" | |
| # Database connection fields: attribute on the parsed args, environment | |
| # variable, command-line flag and human-readable label. | |
| DB_FIELDS = ( | |
| ("host", "MAAS_DB_HOST", "--host", "database host"), | |
| ("port", "MAAS_DB_PORT", "--port", "database port"), | |
| ("dbname", "MAAS_DB_NAME", "--dbname", "database name"), | |
| ("user", "MAAS_DB_USER", "--user", "database user"), | |
| ("password", "MAAS_DB_PASS", "--password", "database password"), | |
| ) | |
| # Candid/RBAC users are non-local accounts without an OIDC provider. | |
| EXTERNAL_USER_PREDICATE = "up.is_local = false AND up.provider_id IS NULL" | |
| NODE_TYPE_LABELS = { | |
| 0: "machine", | |
| 1: "device", | |
| 2: "rack controller", | |
| 3: "region controller", | |
| 4: "region+rack controller", | |
| } | |
| ALLOC_TYPE_LABELS = { | |
| 0: "AUTO", | |
| 1: "STICKY", | |
| 4: "USER_RESERVED", | |
| 5: "DHCP", | |
| 6: "DISCOVERED", | |
| } | |
| class MigrationError(Exception): | |
| """A user-facing error that should abort the command cleanly.""" | |
| def resolve_db_config(args): | |
| """Build the connection settings from CLI options and environment. | |
| For each field the command-line option takes precedence over the matching | |
| environment variable. Any field that is set in neither place is reported as | |
| missing. | |
| """ | |
| config = {} | |
| missing = [] | |
| for attr, envvar, flag, label in DB_FIELDS: | |
| value = getattr(args, attr) | |
| if value is None: | |
| value = os.environ.get(envvar) | |
| if value is None: | |
| missing.append(f" - {label}: set {envvar} or pass {flag}") | |
| else: | |
| config[attr] = value | |
| if missing: | |
| raise MigrationError( | |
| "Missing database connection details:\n" + "\n".join(missing) | |
| ) | |
| try: | |
| config["port"] = int(config["port"]) | |
| except ValueError: | |
| raise MigrationError( # noqa: B904 | |
| f"Invalid database port: {config['port']!r}. It must be an integer." | |
| ) | |
| return config | |
| def connect(config): | |
| """Open a psycopg2 connection using the resolved configuration.""" | |
| try: | |
| return psycopg2.connect( | |
| host=str(config["host"]), | |
| port=config["port"], | |
| dbname=str(config["dbname"]), | |
| user=str(config["user"]), | |
| password=str(config["password"]), | |
| ) | |
| except psycopg2.Error as error: | |
| raise MigrationError( # noqa: B904 | |
| f"Could not connect to the MAAS database:\n {str(error).strip()}" | |
| ) | |
| def print_table(headers, rows): | |
| """Render a simple aligned text table to stdout.""" | |
| if not rows: | |
| print("(none)") | |
| return | |
| str_rows = [ | |
| [("" if cell is None else str(cell)) for cell in row] for row in rows | |
| ] | |
| widths = [len(header) for header in headers] | |
| for row in str_rows: | |
| for index, cell in enumerate(row): | |
| widths[index] = max(widths[index], len(cell)) | |
| line = " ".join( | |
| header.ljust(widths[i]) for i, header in enumerate(headers) | |
| ) | |
| print(line) | |
| print(" ".join("-" * widths[i] for i in range(len(headers)))) | |
| for row in str_rows: | |
| print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))) | |
| print(f"\n{len(rows)} row(s).") | |
| def external_user_ids(conn): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT up.user_id FROM maasserver_userprofile up " | |
| f"WHERE {EXTERNAL_USER_PREDICATE}" | |
| ) | |
| return [row[0] for row in cur.fetchall()] | |
| def cmd_local_users(conn, _args): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT u.id, u.username, u.email, u.is_superuser, u.is_active " | |
| "FROM auth_user u " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| "WHERE up.is_local = true " | |
| "ORDER BY u.username" | |
| ) | |
| rows = cur.fetchall() | |
| print_table(["id", "username", "email", "admin", "active"], rows) | |
| def cmd_external_users(conn, _args): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT u.id, u.username, u.email, u.is_superuser, u.is_active " | |
| "FROM auth_user u " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| f"WHERE {EXTERNAL_USER_PREDICATE} " | |
| "ORDER BY u.username" | |
| ) | |
| rows = cur.fetchall() | |
| print_table(["id", "username", "email", "admin", "active"], rows) | |
| def cmd_nodes(conn, _args): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT n.system_id, n.hostname, n.node_type, u.username " | |
| "FROM maasserver_node n " | |
| "JOIN auth_user u ON u.id = n.owner_id " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| f"WHERE {EXTERNAL_USER_PREDICATE} " | |
| "ORDER BY u.username, n.hostname" | |
| ) | |
| rows = [ | |
| ( | |
| system_id, | |
| hostname, | |
| NODE_TYPE_LABELS.get(node_type, node_type), | |
| owner, | |
| ) | |
| for system_id, hostname, node_type, owner in cur.fetchall() | |
| ] | |
| print_table(["system_id", "hostname", "type", "owner"], rows) | |
| def cmd_ip_ranges(conn, _args): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT r.id, r.type, host(r.start_ip), host(r.end_ip), " | |
| "r.comment, u.username " | |
| "FROM maasserver_iprange r " | |
| "JOIN auth_user u ON u.id = r.user_id " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| f"WHERE {EXTERNAL_USER_PREDICATE} " | |
| "ORDER BY u.username, r.id" | |
| ) | |
| rows = cur.fetchall() | |
| print_table(["id", "type", "start_ip", "end_ip", "comment", "owner"], rows) | |
| def cmd_static_ips(conn, _args): | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT s.id, host(s.ip), s.alloc_type, u.username " | |
| "FROM maasserver_staticipaddress s " | |
| "JOIN auth_user u ON u.id = s.user_id " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| f"WHERE {EXTERNAL_USER_PREDICATE} " | |
| "ORDER BY u.username, s.id" | |
| ) | |
| rows = [ | |
| (ip_id, ip, ALLOC_TYPE_LABELS.get(alloc_type, alloc_type), owner) | |
| for ip_id, ip, alloc_type, owner in cur.fetchall() | |
| ] | |
| print_table(["id", "ip", "alloc_type", "owner"], rows) | |
| def resolve_local_user(conn, username): | |
| """Return the id of a local user, or raise if it is missing/non-local.""" | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT u.id, up.is_local FROM auth_user u " | |
| "JOIN maasserver_userprofile up ON up.user_id = u.id " | |
| "WHERE u.username = %s", | |
| (username,), | |
| ) | |
| row = cur.fetchone() | |
| if row is None: | |
| raise MigrationError(f"No such user: {username!r}.") | |
| user_id, is_local = row | |
| if not is_local: | |
| raise MigrationError( | |
| f"User {username!r} is not a local user. Resources may only be " | |
| "re-assigned to local users." | |
| ) | |
| return user_id | |
| def reassign_resource( | |
| conn, table, id_column, owner_column, resource_id, target_username, label | |
| ): | |
| """Re-assign a single resource owned by a Candid/RBAC user to a local one. | |
| The update is refused unless the resource currently exists and is owned by | |
| a Candid/RBAC user, and the target is a local user. | |
| """ | |
| target_id = resolve_local_user(conn, target_username) | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| f"SELECT r.{owner_column}, up.is_local, up.provider_id " | |
| f"FROM {table} r " | |
| f"LEFT JOIN maasserver_userprofile up ON up.user_id = r.{owner_column} " | |
| f"WHERE r.{id_column} = %s", | |
| (resource_id,), | |
| ) | |
| row = cur.fetchone() | |
| if row is None: | |
| raise MigrationError(f"No such {label}: {resource_id!r}.") | |
| owner_id, is_local, provider_id = row | |
| if owner_id is None: | |
| raise MigrationError( | |
| f"This {label} is not owned by any user; refusing to re-assign it." | |
| ) | |
| is_external = is_local is False and provider_id is None | |
| if not is_external: | |
| raise MigrationError( | |
| f"This {label} is not owned by a Candid/RBAC user; refusing to " | |
| "re-assign it. Only resources owned by Candid/RBAC users can be " | |
| "re-assigned with this tool." | |
| ) | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| f"UPDATE {table} SET {owner_column} = %s WHERE {id_column} = %s", | |
| (target_id, resource_id), | |
| ) | |
| conn.commit() | |
| print( | |
| f"Re-assigned {label} {resource_id!r} to local user " | |
| f"{target_username!r}." | |
| ) | |
| def cmd_reassign_node(conn, args): | |
| reassign_resource( | |
| conn, | |
| table="maasserver_node", | |
| id_column="system_id", | |
| owner_column="owner_id", | |
| resource_id=args.system_id, | |
| target_username=args.to, | |
| label="node", | |
| ) | |
| def cmd_reassign_ip_range(conn, args): | |
| reassign_resource( | |
| conn, | |
| table="maasserver_iprange", | |
| id_column="id", | |
| owner_column="user_id", | |
| resource_id=args.id, | |
| target_username=args.to, | |
| label="IP range", | |
| ) | |
| def cmd_reassign_static_ip(conn, args): | |
| reassign_resource( | |
| conn, | |
| table="maasserver_staticipaddress", | |
| id_column="id", | |
| owner_column="user_id", | |
| resource_id=args.id, | |
| target_username=args.to, | |
| label="static IP address", | |
| ) | |
| def external_auth_configured(conn): | |
| """Whether the external-auth config secret is still present. | |
| Mirrors the logic in the Alembic migration: when Vault is enabled the | |
| secret lives in Vault and is tracked by a non-deleted vaultsecret row, | |
| otherwise it is stored in the local secret table. | |
| """ | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT value = 'true'::jsonb FROM maasserver_config " | |
| "WHERE name = 'vault_enabled'" | |
| ) | |
| row = cur.fetchone() | |
| vault_enabled = bool(row[0]) if row else False | |
| if vault_enabled: | |
| cur.execute( | |
| "SELECT 1 FROM maasserver_vaultsecret " | |
| "WHERE path = %s AND deleted = false LIMIT 1", | |
| (EXTERNAL_AUTH_SECRET_PATH,), | |
| ) | |
| else: | |
| cur.execute( | |
| "SELECT 1 FROM maasserver_secret WHERE path = %s LIMIT 1", | |
| (EXTERNAL_AUTH_SECRET_PATH,), | |
| ) | |
| return cur.fetchone() is not None | |
| def users_owning_resources(conn, user_ids): | |
| if not user_ids: | |
| return [] | |
| with conn.cursor() as cur: | |
| cur.execute( | |
| "SELECT u.username FROM auth_user u WHERE u.id = ANY(%s) AND (" | |
| " EXISTS (SELECT 1 FROM maasserver_node n " | |
| "WHERE n.owner_id = u.id)" | |
| " OR EXISTS (SELECT 1 FROM maasserver_iprange r " | |
| "WHERE r.user_id = u.id)" | |
| " OR EXISTS (SELECT 1 FROM maasserver_staticipaddress s " | |
| "WHERE s.user_id = u.id)" | |
| ") ORDER BY u.username", | |
| (user_ids,), | |
| ) | |
| return [row[0] for row in cur.fetchall()] | |
| def cmd_check(conn, _args): | |
| ready = True | |
| if external_auth_configured(conn): | |
| ready = False | |
| print( | |
| "[FAIL] External (Candid/RBAC) authentication is still " | |
| "configured.\n" | |
| " Disable it on the current release with `maas configauth` " | |
| "(leave the RBAC URL and Candid agent file blank), and make sure " | |
| "a local administrator with a usable password exists.\n" | |
| ) | |
| else: | |
| print( | |
| "[OK] External (Candid/RBAC) authentication is not configured." | |
| ) | |
| user_ids = external_user_ids(conn) | |
| if not user_ids: | |
| print("[OK] No Candid/RBAC user accounts remain.") | |
| else: | |
| owners = users_owning_resources(conn, user_ids) | |
| if owners: | |
| ready = False | |
| joined = "\n".join(f" - {name}" for name in owners) | |
| print( | |
| "[FAIL] The following Candid/RBAC users still own nodes, " | |
| "IP ranges or static IP addresses:\n" | |
| f"{joined}\n" | |
| " Re-assign those resources to a local user (see the " | |
| "reassign-* commands) before upgrading.\n" | |
| ) | |
| else: | |
| print( | |
| f"[OK] {len(user_ids)} Candid/RBAC user account(s) will be " | |
| "deleted during the upgrade, and none own any resources." | |
| ) | |
| print() | |
| if ready: | |
| print("This deployment is ready to be upgraded.") | |
| return 0 | |
| print( | |
| "This deployment is NOT ready to be upgraded. See [FAIL] items above." | |
| ) | |
| return 1 | |
| def build_parser(): | |
| parser = argparse.ArgumentParser( | |
| description=( | |
| "Inspect and re-assign resources owned by Candid/RBAC users to " | |
| "prepare a MAAS deployment for the removal of external " | |
| "authentication." | |
| ), | |
| epilog=( | |
| "Database connection details are read from the MAAS_DB_HOST, " | |
| "MAAS_DB_PORT, MAAS_DB_NAME, MAAS_DB_USER and MAAS_DB_PASS " | |
| "environment variables. Any value not set in the environment must " | |
| "be provided with the matching --host / --port / --dbname / " | |
| "--user / --password option." | |
| ), | |
| ) | |
| parser.add_argument( | |
| "--host", | |
| dest="host", | |
| help="Database host (overrides MAAS_DB_HOST).", | |
| ) | |
| parser.add_argument( | |
| "--port", | |
| dest="port", | |
| help="Database port (overrides MAAS_DB_PORT).", | |
| ) | |
| parser.add_argument( | |
| "--dbname", | |
| dest="dbname", | |
| help="Database name (overrides MAAS_DB_NAME).", | |
| ) | |
| parser.add_argument( | |
| "--user", | |
| dest="user", | |
| help="Database user (overrides MAAS_DB_USER).", | |
| ) | |
| parser.add_argument( | |
| "--password", | |
| dest="password", | |
| help="Database password (overrides MAAS_DB_PASS).", | |
| ) | |
| subparsers = parser.add_subparsers(dest="command", required=True) | |
| subparsers.add_parser( | |
| "local-users", help="List local user accounts." | |
| ).set_defaults(func=cmd_local_users) | |
| subparsers.add_parser( | |
| "external-users", help="List Candid/RBAC user accounts." | |
| ).set_defaults(func=cmd_external_users) | |
| subparsers.add_parser( | |
| "nodes", help="List nodes owned by Candid/RBAC users." | |
| ).set_defaults(func=cmd_nodes) | |
| subparsers.add_parser( | |
| "ip-ranges", help="List IP ranges owned by Candid/RBAC users." | |
| ).set_defaults(func=cmd_ip_ranges) | |
| subparsers.add_parser( | |
| "static-ips", | |
| help="List static IP addresses owned by Candid/RBAC users.", | |
| ).set_defaults(func=cmd_static_ips) | |
| reassign_node = subparsers.add_parser( | |
| "reassign-node", | |
| help="Re-assign a node owned by a Candid/RBAC user to a local user.", | |
| ) | |
| reassign_node.add_argument( | |
| "--system-id", required=True, help="System ID of the node." | |
| ) | |
| reassign_node.add_argument( | |
| "--to", required=True, help="Username of the target local user." | |
| ) | |
| reassign_node.set_defaults(func=cmd_reassign_node) | |
| reassign_ip_range = subparsers.add_parser( | |
| "reassign-ip-range", | |
| help="Re-assign an IP range owned by a Candid/RBAC user to a local " | |
| "user.", | |
| ) | |
| reassign_ip_range.add_argument( | |
| "--id", required=True, type=int, help="ID of the IP range." | |
| ) | |
| reassign_ip_range.add_argument( | |
| "--to", required=True, help="Username of the target local user." | |
| ) | |
| reassign_ip_range.set_defaults(func=cmd_reassign_ip_range) | |
| reassign_static_ip = subparsers.add_parser( | |
| "reassign-static-ip", | |
| help="Re-assign a static IP address owned by a Candid/RBAC user to a " | |
| "local user.", | |
| ) | |
| reassign_static_ip.add_argument( | |
| "--id", required=True, type=int, help="ID of the static IP address." | |
| ) | |
| reassign_static_ip.add_argument( | |
| "--to", required=True, help="Username of the target local user." | |
| ) | |
| reassign_static_ip.set_defaults(func=cmd_reassign_static_ip) | |
| subparsers.add_parser( | |
| "check", | |
| help="Check whether the deployment is ready to be upgraded.", | |
| ).set_defaults(func=cmd_check) | |
| return parser | |
| def main(argv=None): | |
| parser = build_parser() | |
| args = parser.parse_args(argv) | |
| try: | |
| config = resolve_db_config(args) | |
| conn = connect(config) | |
| except MigrationError as error: | |
| sys.exit(str(error)) | |
| try: | |
| result = args.func(conn, args) | |
| except MigrationError as error: | |
| conn.rollback() | |
| sys.exit(str(error)) | |
| finally: | |
| conn.close() | |
| return result or 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment