refactor(db): consolidate authz migrations into a single revision

Merge system-role seeding into 7c8d9e0f1a2b and drop 8d3a1f9c2e0b.
This commit is contained in:
himavarshagoutham
2026-05-26 11:35:21 -04:00
parent 2ac7568f01
commit 8ec32f6c69
3 changed files with 124 additions and 186 deletions

View File

@ -1,11 +1,15 @@
"""Authz foundations: policy rules, authz_* tables, workspace_id columns.
"""Authz foundations: tables, workspace_id columns, and built-in system roles.
Revision ID: 7c8d9e0f1a2b
Revises: mb01b2c3d4e5
Create Date: 2026-05-20
"""
from __future__ import annotations
from collections.abc import Sequence
from datetime import datetime, timezone
from uuid import uuid4
import sqlalchemy as sa
import sqlmodel
@ -20,6 +24,116 @@ depends_on: str | Sequence[str] | None = None
_WORKSPACE_TABLES = ("flow", "folder", "deployment")
_VIEWER_PERMISSIONS: tuple[str, ...] = (
"flow:read",
"flow:execute",
"deployment:read",
"project:read",
"knowledge_base:read",
"variable:read",
"file:read",
)
_DEVELOPER_EXTRA: tuple[str, ...] = (
"flow:write",
"flow:create",
"deployment:write",
"deployment:create",
"deployment:execute",
"project:write",
"project:create",
"knowledge_base:write",
"knowledge_base:create",
"knowledge_base:ingest",
"variable:write",
"variable:create",
"file:write",
"file:create",
)
_ADMIN_EXTRA: tuple[str, ...] = (
"flow:delete",
"flow:deploy",
"deployment:delete",
"deployment:deploy",
"project:delete",
"knowledge_base:delete",
"variable:delete",
"file:delete",
"share:read",
"share:create",
"share:update",
"share:delete",
)
_SYSTEM_ROLES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
(
"viewer",
"Read-only access to flows, deployments, projects, and supporting resources.",
_VIEWER_PERMISSIONS,
),
(
"developer",
"Author and execute flows; manage variables, knowledge bases, and files.",
_VIEWER_PERMISSIONS + _DEVELOPER_EXTRA,
),
(
"admin",
"Full management of resources and shares within the workspace.",
_VIEWER_PERMISSIONS + _DEVELOPER_EXTRA + _ADMIN_EXTRA,
),
)
def _seed_system_roles(conn: sa.Connection) -> None:
"""Insert viewer / developer / admin roles (idempotent)."""
authz_role = sa.table(
"authz_role",
sa.column("id", sa.Uuid()),
sa.column("name", sa.String()),
sa.column("description", sa.String()),
sa.column("is_system", sa.Boolean()),
sa.column("permissions", sa.JSON()),
sa.column("parent_role_id", sa.Uuid()),
sa.column("workspace_id", sa.Uuid()),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
sa.column("created_by", sa.Uuid()),
)
timestamp = datetime.now(timezone.utc)
dialect = conn.dialect.name
for name, description, permissions in _SYSTEM_ROLES:
values = {
"id": uuid4(),
"name": name,
"description": description,
"is_system": True,
"permissions": list(permissions),
"parent_role_id": None,
"workspace_id": None,
"created_at": timestamp,
"updated_at": timestamp,
"created_by": None,
}
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(authz_role).values(**values).on_conflict_do_nothing(index_elements=["name"])
conn.execute(stmt)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(authz_role).values(**values).on_conflict_do_nothing(index_elements=["name"])
conn.execute(stmt)
else:
already_present = conn.execute(
sa.select(sa.literal(1)).select_from(authz_role).where(authz_role.c.name == name)
).scalar()
if already_present:
continue
conn.execute(authz_role.insert().values(**values))
def upgrade() -> None:
conn = op.get_bind()
@ -292,6 +406,9 @@ def upgrade() -> None:
batch_op.add_column(sa.Column("workspace_id", sa.Uuid(), nullable=True))
batch_op.create_index(f"ix_{table}_workspace_id", ["workspace_id"], unique=False)
if migration.table_exists("authz_role", conn):
_seed_system_roles(conn)
def downgrade() -> None:
conn = op.get_bind()

View File

@ -1,173 +0,0 @@
"""Seed built-in authz roles (viewer, developer, admin).
Revision ID: 8d3a1f9c2e0b
Revises: 7c8d9e0f1a2b
Create Date: 2026-05-21
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from uuid import uuid4
import sqlalchemy as sa
from alembic import op
if TYPE_CHECKING:
from collections.abc import Sequence
revision: str = "8d3a1f9c2e0b" # pragma: allowlist secret
down_revision: str | None = "7c8d9e0f1a2b" # pragma: allowlist secret
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
# Permission templates use ``"{resource}:{action}"`` slugs that map directly
# to policy object/action pairs. Authorization plugins read these to seed
# matching ``p`` rules during ``PolicySync``.
_VIEWER_PERMISSIONS: tuple[str, ...] = (
"flow:read",
"flow:execute",
"deployment:read",
"project:read",
"knowledge_base:read",
"variable:read",
"file:read",
)
_DEVELOPER_EXTRA: tuple[str, ...] = (
"flow:write",
"flow:create",
"deployment:write",
"deployment:create",
"deployment:execute",
"project:write",
"project:create",
"knowledge_base:write",
"knowledge_base:create",
"knowledge_base:ingest",
"variable:write",
"variable:create",
"file:write",
"file:create",
)
_ADMIN_EXTRA: tuple[str, ...] = (
"flow:delete",
"flow:deploy",
"deployment:delete",
"deployment:deploy",
"project:delete",
"knowledge_base:delete",
"variable:delete",
"file:delete",
"share:read",
"share:create",
"share:update",
"share:delete",
)
_SYSTEM_ROLES: tuple[tuple[str, str, tuple[str, ...]], ...] = (
(
"viewer",
"Read-only access to flows, deployments, projects, and supporting resources.",
_VIEWER_PERMISSIONS,
),
(
"developer",
"Author and execute flows; manage variables, knowledge bases, and files.",
_VIEWER_PERMISSIONS + _DEVELOPER_EXTRA,
),
(
"admin",
"Full management of resources and shares within the workspace.",
_VIEWER_PERMISSIONS + _DEVELOPER_EXTRA + _ADMIN_EXTRA,
),
)
def upgrade() -> None:
conn = op.get_bind()
authz_role = sa.table(
"authz_role",
sa.column("id", sa.Uuid()),
sa.column("name", sa.String()),
sa.column("description", sa.String()),
sa.column("is_system", sa.Boolean()),
sa.column("permissions", sa.JSON()),
sa.column("parent_role_id", sa.Uuid()),
sa.column("workspace_id", sa.Uuid()),
sa.column("created_at", sa.DateTime(timezone=True)),
sa.column("updated_at", sa.DateTime(timezone=True)),
sa.column("created_by", sa.Uuid()),
)
timestamp = datetime.now(timezone.utc)
# ``sa.Uuid`` adapts ``UUID`` objects per-dialect (CHAR(32) on SQLite,
# native UUID on Postgres). Passing a bare string skips that path and
# fails at bind time on SQLite (``'str' object has no attribute 'hex'``),
# so we pass real UUID objects here.
# ``sa.JSON`` serializes Python lists natively on both SQLite and Postgres.
# ``json.dumps`` here would write the JSON-encoded string *inside* the
# JSON column, which downstream readers (PolicySync expects a
# list) would have to peel a second time.
for name, description, permissions in _SYSTEM_ROLES:
# Use an atomic check-then-insert to harden against concurrent
# rollouts (two pods running migrations against the same DB). The
# previous implementation did a SELECT then an INSERT in two
# statements: under concurrency both could pass the SELECT, both
# could attempt the INSERT, and one would crash on the unique
# constraint. ``ON CONFLICT DO NOTHING`` / ``OR IGNORE`` makes this
# idempotent under any number of concurrent writers without a lock.
dialect = conn.dialect.name
values = {
"id": uuid4(),
"name": name,
"description": description,
"is_system": True,
"permissions": list(permissions),
"parent_role_id": None,
"workspace_id": None,
"created_at": timestamp,
"updated_at": timestamp,
"created_by": None,
}
if dialect == "postgresql":
from sqlalchemy.dialects.postgresql import insert as pg_insert
stmt = pg_insert(authz_role).values(**values).on_conflict_do_nothing(index_elements=["name"])
conn.execute(stmt)
elif dialect == "sqlite":
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(authz_role).values(**values).on_conflict_do_nothing(index_elements=["name"])
conn.execute(stmt)
else:
# Fallback for other dialects (e.g. unit test stand-ins): use the
# original check-then-insert. Not fully race-safe but matches the
# previous behavior and never raises on a clean fresh DB.
already_present = conn.execute(
sa.select(sa.literal(1)).select_from(authz_role).where(authz_role.c.name == name)
).scalar()
if already_present:
continue
conn.execute(authz_role.insert().values(**values))
def downgrade() -> None:
conn = op.get_bind()
authz_role = sa.table(
"authz_role",
sa.column("name", sa.String()),
sa.column("is_system", sa.Boolean()),
)
conn.execute(
authz_role.delete().where(
sa.and_(
authz_role.c.name.in_([name for name, _, _ in _SYSTEM_ROLES]),
authz_role.c.is_system.is_(True),
)
)
)

View File

@ -1,16 +1,10 @@
"""Tests for the seed-system-roles migration (8d3a1f9c2e0b).
The migration's job is to insert exactly three system roles — viewer,
developer, admin — with stable permission templates and idempotent semantics.
These tests pin the permission shape and the idempotency contract without
executing the alembic harness.
"""
"""Tests for built-in authz system roles seeded by the foundations migration."""
from __future__ import annotations
import importlib
_MIGRATION = importlib.import_module("langflow.alembic.versions.8d3a1f9c2e0b_seed_authz_system_roles")
_MIGRATION = importlib.import_module("langflow.alembic.versions.7c8d9e0f1a2b_authz_foundations")
def test_three_system_roles_are_seeded():
@ -58,7 +52,7 @@ def test_permission_slugs_use_resource_action_format():
assert verb, slug
def test_revision_chain_pins_authz_foundations():
"""Down-revision must be the authz foundations migration so the chain stays linear."""
assert _MIGRATION.revision == "8d3a1f9c2e0b"
assert _MIGRATION.down_revision == "7c8d9e0f1a2b"
def test_revision_chain_is_linear_after_mb01():
"""Foundations migration follows memory-base migration mb01b2c3d4e5."""
assert _MIGRATION.revision == "7c8d9e0f1a2b"
assert _MIGRATION.down_revision == "mb01b2c3d4e5"