Skip to content

Commit 5f8f0e9

Browse files
committed
fix(invitations): create user role assignment on invite
1 parent 4fce9c2 commit 5f8f0e9

File tree

7 files changed

+247
-79
lines changed

7 files changed

+247
-79
lines changed

alembic/versions/4bb7e59026f3_drop_role_from_membership_and_.py

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -111,18 +111,31 @@ def upgrade() -> None:
111111

112112
def downgrade() -> None:
113113
# ### commands auto generated by Alembic - please adjust! ###
114+
#
115+
# This downgrade restores the original enum-based role columns by:
116+
# 1. Creating the enum types
117+
# 2. Adding role columns as nullable
118+
# 3. Populating role values from role_id via the role table
119+
# 4. Making the columns NOT NULL
120+
# 5. Dropping the role_id columns and constraints
121+
#
122+
# Note: The original schema did NOT have server_default on invitation/org_invitation
123+
# role columns, but membership/org_membership did have defaults.
124+
125+
# Step 1: Create enum types
114126
sa.Enum("VIEWER", "EDITOR", "ADMIN", name="workspacerole").create(op.get_bind())
115127
sa.Enum("MEMBER", "ADMIN", "OWNER", name="orgrole").create(op.get_bind())
128+
129+
# Step 2: Add role columns as nullable
116130
op.add_column(
117131
"organization_membership",
118132
sa.Column(
119133
"role",
120134
postgresql.ENUM(
121135
"MEMBER", "ADMIN", "OWNER", name="orgrole", create_type=False
122136
),
123-
server_default=sa.text("'MEMBER'::orgrole"),
124137
autoincrement=False,
125-
nullable=False,
138+
nullable=True,
126139
),
127140
)
128141
op.add_column(
@@ -133,28 +146,18 @@ def downgrade() -> None:
133146
"MEMBER", "ADMIN", "OWNER", name="orgrole", create_type=False
134147
),
135148
autoincrement=False,
136-
nullable=False,
149+
nullable=True,
137150
),
138151
)
139-
op.drop_constraint(
140-
op.f("fk_organization_invitation_role_id_role"),
141-
"organization_invitation",
142-
type_="foreignkey",
143-
)
144-
op.drop_index(
145-
op.f("ix_organization_invitation_role_id"), table_name="organization_invitation"
146-
)
147-
op.drop_column("organization_invitation", "role_id")
148152
op.add_column(
149153
"membership",
150154
sa.Column(
151155
"role",
152156
postgresql.ENUM(
153157
"VIEWER", "EDITOR", "ADMIN", name="workspacerole", create_type=False
154158
),
155-
server_default=sa.text("'EDITOR'::workspacerole"),
156159
autoincrement=False,
157-
nullable=False,
160+
nullable=True,
158161
),
159162
)
160163
op.add_column(
@@ -165,9 +168,77 @@ def downgrade() -> None:
165168
"VIEWER", "EDITOR", "ADMIN", name="workspacerole", create_type=False
166169
),
167170
autoincrement=False,
168-
nullable=False,
171+
nullable=True,
169172
),
170173
)
174+
175+
# Step 3: Populate role values from role_id via the role table
176+
# Organization membership: role_id -> role.slug -> uppercase enum
177+
op.execute(
178+
"""
179+
UPDATE organization_membership om
180+
SET role = UPPER(r.slug)::orgrole
181+
FROM role r
182+
WHERE om.role_id = r.id
183+
"""
184+
)
185+
186+
# Organization invitation: role_id -> role.slug -> uppercase enum
187+
op.execute(
188+
"""
189+
UPDATE organization_invitation oi
190+
SET role = UPPER(r.slug)::orgrole
191+
FROM role r
192+
WHERE oi.role_id = r.id
193+
"""
194+
)
195+
196+
# Workspace membership: role_id -> role.slug -> uppercase enum
197+
op.execute(
198+
"""
199+
UPDATE membership m
200+
SET role = UPPER(r.slug)::workspacerole
201+
FROM role r
202+
WHERE m.role_id = r.id
203+
"""
204+
)
205+
206+
# Workspace invitation: role_id -> role.slug -> uppercase enum
207+
op.execute(
208+
"""
209+
UPDATE invitation i
210+
SET role = UPPER(r.slug)::workspacerole
211+
FROM role r
212+
WHERE i.role_id = r.id
213+
"""
214+
)
215+
216+
# Step 4: Make columns NOT NULL and add server_default where original schema had it
217+
op.alter_column("organization_membership", "role", nullable=False)
218+
op.alter_column(
219+
"organization_membership",
220+
"role",
221+
server_default=sa.text("'MEMBER'::orgrole"),
222+
)
223+
op.alter_column("organization_invitation", "role", nullable=False)
224+
op.alter_column("membership", "role", nullable=False)
225+
op.alter_column(
226+
"membership",
227+
"role",
228+
server_default=sa.text("'EDITOR'::workspacerole"),
229+
)
230+
op.alter_column("invitation", "role", nullable=False)
231+
232+
# Step 5: Drop role_id columns and constraints
233+
op.drop_constraint(
234+
op.f("fk_organization_invitation_role_id_role"),
235+
"organization_invitation",
236+
type_="foreignkey",
237+
)
238+
op.drop_index(
239+
op.f("ix_organization_invitation_role_id"), table_name="organization_invitation"
240+
)
241+
op.drop_column("organization_invitation", "role_id")
171242
op.drop_constraint(
172243
op.f("fk_invitation_role_id_role"), "invitation", type_="foreignkey"
173244
)

tests/unit/test_authz_seeding.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ async def test_seed_system_scopes(session):
2525
# Verify scopes exist in database
2626
result = await session.execute(
2727
select(Scope).where(
28-
Scope.source == ScopeSource.SYSTEM,
28+
Scope.source == ScopeSource.PLATFORM,
2929
Scope.organization_id.is_(None),
3030
)
3131
)
@@ -52,7 +52,7 @@ async def test_seed_system_scopes_idempotent(session):
5252
# Verify count is still the same
5353
result = await session.execute(
5454
select(Scope).where(
55-
Scope.source == ScopeSource.SYSTEM,
55+
Scope.source == ScopeSource.PLATFORM,
5656
Scope.organization_id.is_(None),
5757
)
5858
)
@@ -72,7 +72,7 @@ async def test_seed_registry_scope(session):
7272
assert scope.name == f"action:{action_key}:execute"
7373
assert scope.resource == "action"
7474
assert scope.action == "execute"
75-
assert scope.source == ScopeSource.REGISTRY
75+
assert scope.source == ScopeSource.PLATFORM
7676
assert scope.source_ref == action_key
7777
assert scope.organization_id is None
7878

@@ -112,7 +112,7 @@ async def test_seed_registry_scopes_bulk(session):
112112
# Verify scopes exist
113113
result = await session.execute(
114114
select(Scope).where(
115-
Scope.source == ScopeSource.REGISTRY,
115+
Scope.source == ScopeSource.PLATFORM,
116116
Scope.organization_id.is_(None),
117117
)
118118
)

tracecat/authz/controls.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import asyncio
22
import functools
33
import re
4+
import warnings
45
from collections.abc import Callable, Coroutine
56
from fnmatch import fnmatch
67
from typing import Any, Protocol, TypeVar, cast, runtime_checkable
78

8-
from tracecat.auth.types import Role
9+
from tracecat.auth.types import AccessLevel, Role
910
from tracecat.authz.enums import OrgRole, WorkspaceRole
1011
from tracecat.contexts import ctx_role
1112
from tracecat.exceptions import ScopeDeniedError, TracecatAuthorizationError
@@ -122,6 +123,54 @@ class HasRole(Protocol):
122123
role: Role
123124

124125

126+
def require_access_level(level: AccessLevel) -> Callable[[T], T]:
127+
"""Decorator that protects a `Service` method with a minimum access level requirement.
128+
129+
If the caller does not have at least the required access level, a TracecatAuthorizationError is raised.
130+
131+
.. deprecated::
132+
Use `@require_scope` instead. This decorator will be removed in a future version.
133+
"""
134+
warnings.warn(
135+
"require_access_level is deprecated, use require_scope instead",
136+
DeprecationWarning,
137+
stacklevel=2,
138+
)
139+
140+
def check(self: HasRole):
141+
if not hasattr(self, "role"):
142+
raise AttributeError("Service must have a 'role' attribute")
143+
144+
if not isinstance(self.role, Role):
145+
raise ValueError("Invalid role type")
146+
147+
user_role = self.role
148+
if user_role.access_level < level:
149+
raise TracecatAuthorizationError(
150+
f"User does not have required access level: {level.name}"
151+
)
152+
153+
def decorator(fn: T) -> T:
154+
if asyncio.iscoroutinefunction(fn):
155+
156+
@functools.wraps(fn)
157+
async def async_wrapper(self: HasRole, *args, **kwargs):
158+
check(self)
159+
return await fn(self, *args, **kwargs)
160+
161+
return cast(T, async_wrapper)
162+
else:
163+
164+
@functools.wraps(fn)
165+
def sync_wrapper(self: HasRole, *args, **kwargs):
166+
check(self)
167+
return fn(self, *args, **kwargs)
168+
169+
return cast(T, sync_wrapper)
170+
171+
return decorator
172+
173+
125174
def require_org_role(*roles: OrgRole) -> Callable[[T], T]:
126175
"""Decorator that protects a Service method with an org role requirement.
127176

0 commit comments

Comments
 (0)