757 lines
29 KiB
Python
757 lines
29 KiB
Python
import sqlite3
|
|
from pathlib import Path
|
|
from urllib.parse import quote
|
|
|
|
from alembic.command import downgrade as alembic_downgrade
|
|
from click.testing import CliRunner
|
|
|
|
from mlflow.server.auth.db import cli
|
|
from mlflow.server.auth.db.utils import _get_alembic_config
|
|
|
|
|
|
def test_upgrade(tmp_path: Path) -> None:
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
res = runner.invoke(cli.upgrade, ["--url", f"sqlite:///{db}"], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
tables = cursor.fetchall()
|
|
|
|
# The legacy per-resource permission tables are created by earlier migrations
|
|
# and intentionally retained by ``e5f6a7b8c9d0`` so operators can still roll
|
|
# back without restoring from backup. A future migration will drop them.
|
|
assert sorted(tables) == sorted([
|
|
("alembic_version_auth",),
|
|
("users",),
|
|
("roles",),
|
|
("role_permissions",),
|
|
("user_role_assignments",),
|
|
("experiment_permissions",),
|
|
("registered_model_permissions",),
|
|
("scorer_permissions",),
|
|
("gateway_secret_permissions",),
|
|
("gateway_endpoint_permissions",),
|
|
("gateway_model_definition_permissions",),
|
|
("workspace_permissions",),
|
|
])
|
|
|
|
|
|
def test_auth_and_tracking_store_coexist(tmp_path: Path) -> None:
|
|
from mlflow.store.db.utils import _safe_initialize_tables, create_sqlalchemy_engine_with_retry
|
|
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
tracking_engine = create_sqlalchemy_engine_with_retry(db_url)
|
|
_safe_initialize_tables(tracking_engine)
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
tables = {t[0] for t in cursor.fetchall()}
|
|
|
|
assert "alembic_version" in tables
|
|
assert "alembic_version_auth" in tables
|
|
assert "users" in tables
|
|
assert "roles" in tables
|
|
assert "role_permissions" in tables
|
|
assert "user_role_assignments" in tables
|
|
assert "experiments" in tables
|
|
assert "runs" in tables
|
|
# Legacy per-resource permission tables are intentionally retained by the
|
|
# ``e5f6a7b8c9d0`` migration so operators can still roll back without
|
|
# restoring from backup.
|
|
assert "experiment_permissions" in tables
|
|
assert "registered_model_permissions" in tables
|
|
assert "scorer_permissions" in tables
|
|
assert "gateway_secret_permissions" in tables
|
|
assert "gateway_endpoint_permissions" in tables
|
|
assert "gateway_model_definition_permissions" in tables
|
|
assert "workspace_permissions" in tables
|
|
|
|
|
|
def test_upgrade_from_legacy_database(tmp_path: Path) -> None:
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("""
|
|
CREATE TABLE users (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
username VARCHAR(255),
|
|
password_hash VARCHAR(255),
|
|
is_admin BOOLEAN,
|
|
UNIQUE (username)
|
|
)
|
|
""")
|
|
cursor.execute("""
|
|
CREATE TABLE experiment_permissions (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
experiment_id VARCHAR(255) NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
permission VARCHAR(255),
|
|
FOREIGN KEY(user_id) REFERENCES users (id),
|
|
UNIQUE (experiment_id, user_id)
|
|
)
|
|
""")
|
|
cursor.execute("""
|
|
CREATE TABLE registered_model_permissions (
|
|
id INTEGER NOT NULL PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
user_id INTEGER NOT NULL,
|
|
permission VARCHAR(255),
|
|
FOREIGN KEY(user_id) REFERENCES users (id),
|
|
UNIQUE (name, user_id)
|
|
)
|
|
""")
|
|
cursor.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)",
|
|
("testuser", "hash123", True),
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
|
tables = {t[0] for t in cursor.fetchall()}
|
|
|
|
cursor.execute("SELECT version_num FROM alembic_version_auth;")
|
|
version = cursor.fetchone()
|
|
|
|
cursor.execute("SELECT username, is_admin FROM users;")
|
|
user = cursor.fetchone()
|
|
|
|
assert "alembic_version_auth" in tables
|
|
assert "users" in tables
|
|
assert "roles" in tables
|
|
assert "role_permissions" in tables
|
|
assert "user_role_assignments" in tables
|
|
# Legacy tables are intentionally retained by ``e5f6a7b8c9d0`` so operators
|
|
# can still roll back without restoring from backup. A future migration
|
|
# will drop them once the simplified RBAC model has bedded in.
|
|
assert "experiment_permissions" in tables
|
|
assert "scorer_permissions" in tables
|
|
assert "registered_model_permissions" in tables
|
|
assert "workspace_permissions" in tables
|
|
assert version[0] == "f1a2b3c4d5e6"
|
|
assert user == ("testuser", 1)
|
|
|
|
|
|
def test_upgrade_filters_legacy_rows_per_simplified_model(tmp_path: Path) -> None:
|
|
"""Migration drops resource-scope ``NO_PERMISSIONS`` rows; workspace-scope
|
|
``READ`` rewrites to a single ``USE`` grant; workspace-scope ``EDIT`` fans
|
|
out to ``USE`` plus a type-wildcard ``EDIT`` grant on every concrete
|
|
resource type; other values pass through.
|
|
|
|
Walks the migration chain in two steps: first up to the revision **before**
|
|
the backfill so the legacy tables exist with the expected schema, seeds
|
|
legacy data, then runs the backfill migration. This avoids pre-creating
|
|
legacy tables by hand (which would collide with earlier migrations that
|
|
create them).
|
|
"""
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
# Walk to the revision that immediately precedes the backfill migration.
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "c3d4e5f6a7b8"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.executemany(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
|
|
[
|
|
(1, "alice", "h", False),
|
|
(2, "bob", "h", False),
|
|
(3, "carol", "h", False),
|
|
(4, "dan", "h", False),
|
|
],
|
|
)
|
|
# Resource-scope rows: NO_PERMISSIONS dropped, READ / MANAGE preserved.
|
|
cursor.executemany(
|
|
"INSERT INTO experiment_permissions (experiment_id, user_id, permission)"
|
|
" VALUES (?, ?, ?)",
|
|
[
|
|
("exp-1", 1, "READ"),
|
|
("exp-2", 2, "NO_PERMISSIONS"),
|
|
("exp-3", 3, "MANAGE"),
|
|
],
|
|
)
|
|
# Workspace-scope rows: NO_PERMISSIONS dropped; READ rewrites to a
|
|
# single USE row; EDIT fans out to USE + per-resource-type EDIT;
|
|
# USE / MANAGE migrate unchanged.
|
|
cursor.executemany(
|
|
"INSERT INTO workspace_permissions (workspace, user_id, permission) VALUES (?, ?, ?)",
|
|
[
|
|
("ws-default", 1, "READ"),
|
|
("ws-default", 2, "EDIT"),
|
|
("ws-default", 3, "NO_PERMISSIONS"),
|
|
("ws-default", 4, "MANAGE"),
|
|
],
|
|
)
|
|
conn.commit()
|
|
|
|
# Run the backfill migration.
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT u.username, rp.resource_type, rp.resource_pattern, rp.permission "
|
|
"FROM role_permissions rp "
|
|
"JOIN roles r ON r.id = rp.role_id "
|
|
"JOIN user_role_assignments ura ON ura.role_id = r.id "
|
|
"JOIN users u ON u.id = ura.user_id "
|
|
"ORDER BY u.username, rp.resource_type, rp.resource_pattern"
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
# alice: experiment READ preserved, workspace READ rewritten to USE
|
|
# bob: experiment NO_PERMISSIONS skipped, workspace EDIT fans out to
|
|
# ('workspace','*','USE') + ('<each resource_type>','*','EDIT')
|
|
# carol: experiment MANAGE preserved, workspace NO_PERMISSIONS skipped
|
|
# dan: workspace MANAGE preserved
|
|
assert rows == [
|
|
("alice", "experiment", "exp-1", "READ"),
|
|
("alice", "workspace", "*", "USE"),
|
|
("bob", "experiment", "*", "EDIT"),
|
|
("bob", "gateway_endpoint", "*", "EDIT"),
|
|
("bob", "gateway_model_definition", "*", "EDIT"),
|
|
("bob", "gateway_secret", "*", "EDIT"),
|
|
("bob", "registered_model", "*", "EDIT"),
|
|
("bob", "scorer", "*", "EDIT"),
|
|
("bob", "workspace", "*", "USE"),
|
|
("carol", "experiment", "exp-3", "MANAGE"),
|
|
("dan", "workspace", "*", "MANAGE"),
|
|
]
|
|
|
|
|
|
def test_upgrade_workspace_edit_fans_out_to_per_type_grants(tmp_path: Path) -> None:
|
|
"""Workspace ``EDIT`` had no single equivalent in the simplified two-tier
|
|
model (workspace tier is USE / MANAGE only). To preserve the user's
|
|
pre-simplification per-resource EDIT capability, the migration emits one
|
|
workspace-wide ``USE`` grant (for visibility + create) plus a type-wildcard
|
|
``EDIT`` grant on every concrete resource type. Together those reproduce
|
|
the legacy "EDIT on every resource in this workspace" behavior without
|
|
smuggling EDIT into the workspace tier itself.
|
|
|
|
This test is a focused pin for the fan-out so a future migration tweak
|
|
that drops a resource type (or collapses EDIT back to USE) is caught.
|
|
"""
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "c3d4e5f6a7b8"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
|
|
(1, "alice", "h", False),
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO workspace_permissions (workspace, user_id, permission) VALUES (?, ?, ?)",
|
|
("ws-team-a", 1, "EDIT"),
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT r.workspace, rp.resource_type, rp.resource_pattern, rp.permission "
|
|
"FROM role_permissions rp "
|
|
"JOIN roles r ON r.id = rp.role_id "
|
|
"JOIN user_role_assignments ura ON ura.role_id = r.id "
|
|
"JOIN users u ON u.id = ura.user_id "
|
|
"WHERE u.username = 'alice' "
|
|
"ORDER BY rp.resource_type, rp.resource_pattern"
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
# All seven grants land in alice's synthetic role for ``ws-team-a``.
|
|
# Sorted by ``rp.resource_type`` then ``rp.resource_pattern`` (the query's
|
|
# ORDER BY clause) — so per-type EDIT rows come first alphabetically and
|
|
# the workspace-wide USE anchor comes last.
|
|
assert rows == [
|
|
("ws-team-a", "experiment", "*", "EDIT"),
|
|
("ws-team-a", "gateway_endpoint", "*", "EDIT"),
|
|
("ws-team-a", "gateway_model_definition", "*", "EDIT"),
|
|
("ws-team-a", "gateway_secret", "*", "EDIT"),
|
|
("ws-team-a", "registered_model", "*", "EDIT"),
|
|
("ws-team-a", "scorer", "*", "EDIT"),
|
|
("ws-team-a", "workspace", "*", "USE"),
|
|
]
|
|
|
|
|
|
def test_upgrade_encodes_scorer_names_with_special_characters(tmp_path: Path) -> None:
|
|
"""Scorer names may contain ``/`` (validated only against empty/whitespace)
|
|
and other URL-special chars. The migration URL-encodes the name component
|
|
of the scorer compound key so the resulting ``resource_pattern`` is
|
|
unambiguous, and the runtime store uses the same encoding — verify the
|
|
round-trip survives the migration.
|
|
"""
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "c3d4e5f6a7b8"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
scorer_names = [
|
|
"plain-name",
|
|
"with/slash",
|
|
"with%percent",
|
|
"with space and #hash",
|
|
]
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
|
|
(1, "alice", "h", False),
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO scorer_permissions (experiment_id, scorer_name, user_id, permission)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
[("exp-42", name, 1, "READ") for name in scorer_names],
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT rp.resource_pattern FROM role_permissions rp "
|
|
"WHERE rp.resource_type = 'scorer' "
|
|
"ORDER BY rp.resource_pattern"
|
|
)
|
|
patterns = [row[0] for row in cursor.fetchall()]
|
|
|
|
expected = sorted(f"exp-42/{quote(name, safe='')}" for name in scorer_names)
|
|
assert patterns == expected
|
|
|
|
|
|
def _alembic_downgrade(db_url: str, revision: str) -> None:
|
|
import sqlalchemy
|
|
|
|
engine = sqlalchemy.create_engine(db_url)
|
|
cfg = _get_alembic_config(db_url)
|
|
with engine.begin() as conn:
|
|
cfg.attributes["connection"] = conn
|
|
alembic_downgrade(cfg, revision)
|
|
engine.dispose()
|
|
|
|
|
|
def test_upgrade_then_downgrade_then_upgrade_is_idempotent(tmp_path: Path) -> None:
|
|
"""End-to-end exercise of the ``e5f6a7b8c9d0`` migration's upgrade/downgrade
|
|
pair. Seeds legacy rows of every kind, runs upgrade, downgrades back to the
|
|
pre-backfill revision, and re-runs upgrade. Pins three invariants:
|
|
|
|
1. ``downgrade()`` removes every synthetic ``__user_<id>__`` role plus its
|
|
``role_permissions`` and ``user_role_assignments`` rows, but leaves the
|
|
legacy per-resource tables untouched (rollback safety — operators can
|
|
resume on the legacy code path without restoring from backup).
|
|
2. The legacy table contents survive the round trip byte-for-byte, so
|
|
re-running ``upgrade()`` produces the same ``role_permissions`` output as
|
|
the first run (idempotent backfill).
|
|
3. The downgrade does *not* delete admin-managed roles whose names happen
|
|
to start/end with underscores (regression for the ``__user_admin__``
|
|
false-positive risk that ``_SYNTHETIC_ROLE_NAME_RE`` guards against).
|
|
"""
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
# Step to the revision immediately before the backfill, then seed legacy data.
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "c3d4e5f6a7b8"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.executemany(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (?, ?, ?, ?)",
|
|
[
|
|
(1, "alice", "h", False),
|
|
(2, "bob", "h", False),
|
|
],
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO experiment_permissions (experiment_id, user_id, permission)"
|
|
" VALUES (?, ?, ?)",
|
|
[("exp-1", 1, "READ"), ("exp-2", 2, "MANAGE")],
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO registered_model_permissions (workspace, name, user_id, permission)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
[("ws-default", "model-1", 1, "EDIT")],
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO scorer_permissions (experiment_id, scorer_name, user_id, permission)"
|
|
" VALUES (?, ?, ?, ?)",
|
|
[("exp-1", "scorer-a", 1, "USE")],
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO workspace_permissions (workspace, user_id, permission) VALUES (?, ?, ?)",
|
|
[("ws-default", 1, "READ"), ("ws-default", 2, "MANAGE")],
|
|
)
|
|
# An admin-managed role whose name starts/ends with underscores but
|
|
# is not a synthetic per-user role. Downgrade must leave it alone.
|
|
cursor.execute(
|
|
"INSERT INTO roles (id, workspace, name) VALUES (?, ?, ?)",
|
|
(999, "ws-default", "__user_admin__"),
|
|
)
|
|
conn.commit()
|
|
|
|
# Snapshot legacy state pre-upgrade so we can verify it survives.
|
|
legacy_snapshot = _snapshot_legacy_tables(db)
|
|
|
|
# First upgrade.
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
rp_after_first_upgrade = _snapshot_role_permissions(db)
|
|
assert rp_after_first_upgrade, "first upgrade produced no role_permissions rows"
|
|
|
|
# Sanity: legacy tables still hold their pre-upgrade contents.
|
|
assert _snapshot_legacy_tables(db) == legacy_snapshot
|
|
|
|
# Downgrade one step (back to the pre-backfill revision).
|
|
_alembic_downgrade(db_url, "c3d4e5f6a7b8")
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id, name FROM roles WHERE name LIKE '__user_%'")
|
|
surviving_underscore_roles = cursor.fetchall()
|
|
cursor.execute("SELECT COUNT(*) FROM role_permissions")
|
|
rp_count = cursor.fetchone()[0]
|
|
cursor.execute("SELECT COUNT(*) FROM user_role_assignments")
|
|
ura_count = cursor.fetchone()[0]
|
|
|
|
# All synthetic roles are gone; the admin role with the lookalike name stays.
|
|
assert surviving_underscore_roles == [(999, "__user_admin__")]
|
|
# Synthetic roles owned every role_permissions / user_role_assignments row, so
|
|
# both should now be empty.
|
|
assert rp_count == 0
|
|
assert ura_count == 0
|
|
# Legacy contents survive the downgrade — that's the rollback contract.
|
|
assert _snapshot_legacy_tables(db) == legacy_snapshot
|
|
|
|
# Re-run upgrade. Output must match the first run row-for-row.
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
assert _snapshot_role_permissions(db) == rp_after_first_upgrade
|
|
|
|
|
|
def _snapshot_role_permissions(db: Path) -> list[tuple[object, ...]]:
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT u.username, rp.resource_type, rp.resource_pattern, rp.permission "
|
|
"FROM role_permissions rp "
|
|
"JOIN roles r ON r.id = rp.role_id "
|
|
"JOIN user_role_assignments ura ON ura.role_id = r.id "
|
|
"JOIN users u ON u.id = ura.user_id "
|
|
"ORDER BY u.username, rp.resource_type, rp.resource_pattern, rp.permission"
|
|
)
|
|
return cursor.fetchall()
|
|
|
|
|
|
def _snapshot_legacy_tables(db: Path) -> dict[str, list[tuple[object, ...]]]:
|
|
queries = {
|
|
"experiment_permissions": (
|
|
"SELECT experiment_id, user_id, permission FROM experiment_permissions "
|
|
"ORDER BY experiment_id, user_id"
|
|
),
|
|
"registered_model_permissions": (
|
|
"SELECT workspace, name, user_id, permission FROM registered_model_permissions "
|
|
"ORDER BY workspace, name, user_id"
|
|
),
|
|
"scorer_permissions": (
|
|
"SELECT experiment_id, scorer_name, user_id, permission FROM scorer_permissions "
|
|
"ORDER BY experiment_id, scorer_name, user_id"
|
|
),
|
|
"workspace_permissions": (
|
|
"SELECT workspace, user_id, permission FROM workspace_permissions "
|
|
"ORDER BY workspace, user_id"
|
|
),
|
|
}
|
|
snapshot: dict[str, list[tuple[object, ...]]] = {}
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
for table, query in queries.items():
|
|
cursor.execute(query)
|
|
snapshot[table] = cursor.fetchall()
|
|
return snapshot
|
|
|
|
|
|
def _seed_registered_model_tags(db: Path, prompt_names: list[str]) -> None:
|
|
"""Create a minimal stand-in for the tracking ``registered_model_tags``
|
|
table and seed it with ``is_prompt='true'`` rows for each given name. The
|
|
real schema has more columns; the migration only joins on
|
|
``key``, ``value`` and ``name``, so this is enough for the classifier.
|
|
"""
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"CREATE TABLE registered_model_tags ("
|
|
"workspace VARCHAR(63) NOT NULL DEFAULT 'default', "
|
|
"name VARCHAR(256) NOT NULL, "
|
|
"key VARCHAR(250) NOT NULL, "
|
|
"value VARCHAR(5000), "
|
|
"PRIMARY KEY (workspace, key, name))"
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO registered_model_tags (workspace, name, key, value) "
|
|
"VALUES ('default', ?, 'mlflow.prompt.is_prompt', 'true')",
|
|
[(n,) for n in prompt_names],
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def test_promote_prompt_resource_type_rewrites_prompt_grants(tmp_path: Path) -> None:
|
|
# Prompt-tagged names flip to `prompt`; non-prompt names + wildcard rows stay
|
|
# on `registered_model`.
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
# Step to the pre-promotion head.
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "e5f6a7b8c9d0"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
_seed_registered_model_tags(db, prompt_names=["prompt-foo", "prompt-bar"])
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'alice', 'h', 0)"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO roles (id, workspace, name) VALUES (1, 'default', '__user_1__')"
|
|
)
|
|
cursor.execute("INSERT INTO user_role_assignments (user_id, role_id) VALUES (1, 1)")
|
|
cursor.executemany(
|
|
"INSERT INTO role_permissions "
|
|
"(role_id, resource_type, resource_pattern, permission) VALUES (?, ?, ?, ?)",
|
|
[
|
|
(1, "registered_model", "prompt-foo", "READ"),
|
|
(1, "registered_model", "prompt-bar", "MANAGE"),
|
|
(1, "registered_model", "model-baz", "EDIT"),
|
|
(1, "registered_model", "*", "EDIT"),
|
|
],
|
|
)
|
|
conn.commit()
|
|
|
|
# Run the prompt-promotion migration.
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT resource_type, resource_pattern, permission FROM role_permissions "
|
|
"ORDER BY resource_pattern, resource_type"
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
assert rows == [
|
|
# Wildcard stays on registered_model (covers RMs in workspace; rewriting
|
|
# would silently revoke RM access).
|
|
("registered_model", "*", "EDIT"),
|
|
# Non-prompt name keeps its resource_type.
|
|
("registered_model", "model-baz", "EDIT"),
|
|
# Prompt-tagged names flip to ``prompt``.
|
|
("prompt", "prompt-bar", "MANAGE"),
|
|
("prompt", "prompt-foo", "READ"),
|
|
]
|
|
|
|
|
|
def test_promote_prompt_resource_type_workspace_collision_isolated(tmp_path: Path) -> None:
|
|
# Same name across workspaces: prompt in team-a, registered_model in team-b.
|
|
# Only the team-a grant should flip.
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "e5f6a7b8c9d0"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
# Same name ``foo`` in two workspaces — prompt in team-a, registered_model
|
|
# in team-b. Only the team-a grant should flip.
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"CREATE TABLE registered_model_tags ("
|
|
"workspace VARCHAR(63) NOT NULL, "
|
|
"name VARCHAR(256) NOT NULL, "
|
|
"key VARCHAR(250) NOT NULL, "
|
|
"value VARCHAR(5000), "
|
|
"PRIMARY KEY (workspace, key, name))"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO registered_model_tags (workspace, name, key, value) "
|
|
"VALUES ('team-a', 'foo', 'mlflow.prompt.is_prompt', 'true')"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'alice', 'h', 0)"
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO roles (id, workspace, name) VALUES (?, ?, ?)",
|
|
[(1, "team-a", "role-a"), (2, "team-b", "role-b")],
|
|
)
|
|
cursor.executemany(
|
|
"INSERT INTO role_permissions "
|
|
"(role_id, resource_type, resource_pattern, permission) VALUES (?, ?, ?, ?)",
|
|
[
|
|
# team-a grant should flip to ``prompt`` (foo IS a prompt in team-a).
|
|
(1, "registered_model", "foo", "MANAGE"),
|
|
# team-b grant must stay ``registered_model`` (foo is a regular RM in team-b).
|
|
(2, "registered_model", "foo", "READ"),
|
|
],
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT r.workspace, rp.resource_type, rp.resource_pattern, rp.permission "
|
|
"FROM role_permissions rp JOIN roles r ON r.id = rp.role_id "
|
|
"ORDER BY r.workspace"
|
|
)
|
|
rows = cursor.fetchall()
|
|
|
|
assert rows == [
|
|
("team-a", "prompt", "foo", "MANAGE"),
|
|
("team-b", "registered_model", "foo", "READ"),
|
|
]
|
|
|
|
|
|
def test_promote_prompt_resource_type_no_tracking_tables_is_noop(tmp_path: Path) -> None:
|
|
# Split-DB: without `registered_model_tags` on this connection the migration
|
|
# is a no-op. Operators run the equivalent UPDATE by hand.
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "e5f6a7b8c9d0"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'alice', 'h', 0)"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO roles (id, workspace, name) VALUES (1, 'default', '__user_1__')"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO role_permissions "
|
|
"(role_id, resource_type, resource_pattern, permission) VALUES "
|
|
"(1, 'registered_model', 'maybe-prompt', 'MANAGE')"
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT resource_type, resource_pattern, permission FROM role_permissions")
|
|
rows = cursor.fetchall()
|
|
assert rows == [("registered_model", "maybe-prompt", "MANAGE")]
|
|
|
|
|
|
def test_promote_prompt_resource_type_downgrade_collapses_to_registered_model(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# `downgrade()` flips every `prompt` row back to `registered_model`. Lossy by
|
|
# design — collapses to the pre-promotion shared namespace.
|
|
runner = CliRunner()
|
|
db = tmp_path / "test.db"
|
|
db_url = f"sqlite:///{db}"
|
|
|
|
res = runner.invoke(
|
|
cli.upgrade,
|
|
["--url", db_url, "--revision", "e5f6a7b8c9d0"],
|
|
catch_exceptions=False,
|
|
)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
_seed_registered_model_tags(db, prompt_names=["prompt-foo"])
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO users (id, username, password_hash, is_admin) VALUES (1, 'alice', 'h', 0)"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO roles (id, workspace, name) VALUES (1, 'default', '__user_1__')"
|
|
)
|
|
cursor.execute(
|
|
"INSERT INTO role_permissions "
|
|
"(role_id, resource_type, resource_pattern, permission) VALUES "
|
|
"(1, 'registered_model', 'prompt-foo', 'READ')"
|
|
)
|
|
conn.commit()
|
|
|
|
res = runner.invoke(cli.upgrade, ["--url", db_url], catch_exceptions=False)
|
|
assert res.exit_code == 0, res.output
|
|
|
|
_alembic_downgrade(db_url, "e5f6a7b8c9d0")
|
|
|
|
with sqlite3.connect(db) as conn:
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT resource_type, resource_pattern FROM role_permissions")
|
|
rows = cursor.fetchall()
|
|
assert rows == [("registered_model", "prompt-foo")]
|