Skip to content

Commit ce87346

Browse files
fix: upgraded instances with no superadmin (OpenHands#15349)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 2a37082 commit ce87346

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Ensure upgraded instances have an initial superadmin.
2+
3+
Revision ID: 138
4+
Revises: 137
5+
Create Date: 2026-07-21 00:00:00.000000
6+
"""
7+
8+
from typing import Sequence, Union
9+
10+
import sqlalchemy as sa
11+
from alembic import op
12+
13+
revision: str = '138'
14+
down_revision: Union[str, None] = '137'
15+
branch_labels: Union[str, Sequence[str], None] = None
16+
depends_on: Union[str, Sequence[str], None] = None
17+
18+
19+
def upgrade() -> None:
20+
connection = op.get_bind()
21+
metadata = sa.MetaData()
22+
role = sa.Table('role', metadata, autoload_with=connection)
23+
user = sa.Table('user', metadata, autoload_with=connection)
24+
25+
admin_role_id = sa.select(role.c.id).where(role.c.name == 'admin').scalar_subquery()
26+
has_superadmin = sa.exists(
27+
sa.select(user.c.id).where(user.c.role_id == admin_role_id)
28+
)
29+
oldest_user_id = (
30+
sa.select(user.c.id)
31+
.order_by(
32+
sa.func.coalesce(user.c.first_login_at, user.c.accepted_tos)
33+
.asc()
34+
.nulls_last(),
35+
user.c.id.asc(),
36+
)
37+
.limit(1)
38+
.scalar_subquery()
39+
)
40+
41+
connection.execute(
42+
sa.update(user)
43+
.where(user.c.id == oldest_user_id, ~has_superadmin)
44+
.values(role_id=admin_role_id)
45+
)
46+
47+
48+
def downgrade() -> None:
49+
pass
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from datetime import datetime
2+
from importlib.util import module_from_spec, spec_from_file_location
3+
from pathlib import Path
4+
5+
import sqlalchemy as sa
6+
from alembic.migration import MigrationContext
7+
from alembic.operations import Operations
8+
9+
MIGRATION_PATH = (
10+
Path(__file__).resolve().parents[2]
11+
/ 'migrations'
12+
/ 'versions'
13+
/ '138_backfill_initial_superadmin.py'
14+
)
15+
spec = spec_from_file_location('migration_138', MIGRATION_PATH)
16+
assert spec is not None and spec.loader is not None
17+
migration_138 = module_from_spec(spec)
18+
spec.loader.exec_module(migration_138)
19+
20+
21+
def _run_upgrade(connection: sa.Connection) -> None:
22+
context = MigrationContext.configure(connection)
23+
with Operations.context(context):
24+
migration_138.upgrade()
25+
26+
27+
def _database() -> tuple[sa.Engine, sa.Table, sa.Table]:
28+
engine = sa.create_engine('sqlite://')
29+
metadata = sa.MetaData()
30+
role = sa.Table(
31+
'role',
32+
metadata,
33+
sa.Column('id', sa.Integer(), primary_key=True),
34+
sa.Column('name', sa.String(), nullable=False, unique=True),
35+
)
36+
user = sa.Table(
37+
'user',
38+
metadata,
39+
sa.Column('id', sa.String(), primary_key=True),
40+
sa.Column('role_id', sa.Integer(), sa.ForeignKey('role.id')),
41+
sa.Column('accepted_tos', sa.DateTime()),
42+
sa.Column('first_login_at', sa.DateTime()),
43+
)
44+
metadata.create_all(engine)
45+
return engine, role, user
46+
47+
48+
def test_upgrade_promotes_oldest_user_when_no_superadmin_exists():
49+
engine, role, user = _database()
50+
51+
with engine.begin() as connection:
52+
connection.execute(
53+
role.insert(),
54+
[{'id': 1, 'name': 'admin'}, {'id': 2, 'name': 'user'}],
55+
)
56+
connection.execute(
57+
user.insert(),
58+
[
59+
{
60+
'id': 'newer',
61+
'role_id': 2,
62+
'accepted_tos': datetime(2025, 2, 1),
63+
},
64+
{
65+
'id': 'oldest',
66+
'role_id': None,
67+
'accepted_tos': datetime(2025, 1, 1),
68+
},
69+
],
70+
)
71+
72+
_run_upgrade(connection)
73+
roles = dict(
74+
connection.execute(sa.select(user.c.id, user.c.role_id)).tuples().all()
75+
)
76+
77+
assert roles == {'newer': 2, 'oldest': 1}
78+
79+
80+
def test_upgrade_is_idempotent_when_superadmin_exists():
81+
engine, role, user = _database()
82+
83+
with engine.begin() as connection:
84+
connection.execute(
85+
role.insert(),
86+
[{'id': 1, 'name': 'admin'}, {'id': 2, 'name': 'user'}],
87+
)
88+
connection.execute(
89+
user.insert(),
90+
[
91+
{'id': 'admin', 'role_id': 1},
92+
{
93+
'id': 'older-non-admin',
94+
'role_id': 2,
95+
'accepted_tos': datetime(2024, 1, 1),
96+
},
97+
],
98+
)
99+
100+
_run_upgrade(connection)
101+
_run_upgrade(connection)
102+
roles = dict(
103+
connection.execute(sa.select(user.c.id, user.c.role_id)).tuples().all()
104+
)
105+
106+
assert roles == {'admin': 1, 'older-non-admin': 2}
107+
108+
109+
def test_upgrade_noops_when_there_are_no_users():
110+
engine, role, user = _database()
111+
112+
with engine.begin() as connection:
113+
connection.execute(role.insert(), [{'id': 1, 'name': 'admin'}])
114+
_run_upgrade(connection)
115+
users = connection.execute(sa.select(user)).all()
116+
117+
assert users == []

0 commit comments

Comments
 (0)