Skip to content

Instantly share code, notes, and snippets.

@r00ta
Created September 21, 2026 13:17
Show Gist options
  • Select an option

  • Save r00ta/724cc68fd88b22a044d6620e1d602afb to your computer and use it in GitHub Desktop.

Select an option

Save r00ta/724cc68fd88b22a044d6620e1d602afb to your computer and use it in GitHub Desktop.
RBAC/Candid migration tool
#!/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