basyx commited on
Commit
049beed
·
verified ·
1 Parent(s): 27f84dc

test: cover inactive Studio tenant identities

Browse files
Files changed (1) hide show
  1. tests/test_studio_identity.py +94 -33
tests/test_studio_identity.py CHANGED
@@ -17,8 +17,13 @@ from app.security.tenancy import TenantPrincipal, TenantService
17
  SECRET = "a" * 64
18
 
19
 
20
- def signed_headers(*, timestamp: int = 1_800_000_000, subject: str = "supabase-user-1",
21
- email: str = "user@example.com", name: str = "Studio User") -> dict[str, str]:
 
 
 
 
 
22
  signature = hmac.new(
23
  SECRET.encode(),
24
  "\n".join((str(timestamp), subject, email, name)).encode(),
@@ -39,11 +44,14 @@ def test_valid_studio_identity_signature_is_accepted() -> None:
39
  assert identity.email == "user@example.com"
40
 
41
 
42
- @pytest.mark.parametrize("changed_header,changed_value", [
43
- ("x-mr-studio-subject", "another-user"),
44
- ("x-mr-studio-email", "attacker@example.com"),
45
- ("x-mr-studio-name", "Administrator"),
46
- ])
 
 
 
47
  def test_tampered_identity_fields_are_rejected(changed_header: str, changed_value: str) -> None:
48
  headers = signed_headers()
49
  headers[changed_header] = changed_value
@@ -53,7 +61,9 @@ def test_tampered_identity_fields_are_rejected(changed_header: str, changed_valu
53
 
54
  def test_expired_identity_is_rejected() -> None:
55
  with pytest.raises(ValueError, match="expired"):
56
- verify_studio_identity(signed_headers(timestamp=1_800_000_000), SECRET, now=1_800_000_301)
 
 
57
 
58
 
59
  def test_incomplete_envelope_and_short_secret_are_rejected() -> None:
@@ -65,18 +75,28 @@ def test_incomplete_envelope_and_short_secret_are_rejected() -> None:
65
 
66
  def test_shared_admin_key_cannot_escalate_viewer_membership() -> None:
67
  context = AuthContext(
68
- api_key_id="key", key_name="Studio proxy", key_prefix="mp_live_12345678",
69
- environment="live", role="admin", scopes=frozenset({"admin", "projects:delete", "projects:read"}),
70
- requests_per_minute=100, concurrent_jobs=10, uploads_per_hour=20,
71
- processing_bytes_per_day=1000, expires_at=None,
 
 
 
 
 
 
 
72
  )
73
  principal = TenantPrincipal(
74
- workspace_id="workspace-a", user_id="user-a", membership_id="membership-a",
 
 
75
  membership_role="member",
76
  )
77
  scoped = constrain_context_to_membership(context, principal)
78
  assert scoped.workspace_id == "workspace-a"
79
  assert scoped.user_id == "user-a"
 
80
  assert "admin" not in scoped.scopes
81
  assert "projects:delete" not in scoped.scopes
82
  assert "projects:read" in scoped.scopes
@@ -84,27 +104,39 @@ def test_shared_admin_key_cannot_escalate_viewer_membership() -> None:
84
 
85
  def test_unknown_membership_role_fails_closed() -> None:
86
  context = AuthContext(
87
- api_key_id="key", key_name="Studio proxy", key_prefix="mp_live_12345678",
88
- environment="live", role="admin", scopes=frozenset({"admin"}),
89
- requests_per_minute=100, concurrent_jobs=10, uploads_per_hour=20,
90
- processing_bytes_per_day=1000, expires_at=None,
 
 
 
 
 
 
 
91
  )
92
  principal = TenantPrincipal(
93
- workspace_id="workspace-a", user_id="user-a", membership_id="membership-a",
 
 
94
  membership_role="superuser",
95
  )
96
  with pytest.raises(ValueError, match="Unsupported"):
97
  constrain_context_to_membership(context, principal)
98
 
99
 
100
-
101
- async def _seed_subject(database: SecurityDatabase, subject: str, workspace_count: int) -> str:
 
102
  user_id = str(uuid4())
 
103
  async with database.session() as session:
104
  user = User(id=user_id, subject=subject, display_name="Test user")
105
  rows = [user]
106
  for index in range(workspace_count):
107
  workspace_id = str(uuid4())
 
108
  rows.append(
109
  Workspace(
110
  id=workspace_id,
@@ -123,19 +155,24 @@ async def _seed_subject(database: SecurityDatabase, subject: str, workspace_coun
123
  )
124
  session.add_all(rows)
125
  await session.commit()
126
- return user_id
127
 
128
 
129
- async def test_studio_subject_resolves_one_active_membership(tmp_path) -> None:
130
  database = SecurityDatabase(
131
- f"sqlite+aiosqlite:///{tmp_path / 'studio-tenant.db'}", auto_migrate=True
132
  )
133
  await database.initialize()
 
 
 
 
 
134
  try:
135
- user_id = await _seed_subject(database, "supabase-subject-one", 1)
136
  principal = await TenantService(database).resolve_studio_subject("supabase-subject-one")
137
  assert principal.user_id == user_id
138
- assert principal.workspace_id
139
  assert principal.membership_id
140
  assert principal.membership_role == "member"
141
  finally:
@@ -143,10 +180,7 @@ async def test_studio_subject_resolves_one_active_membership(tmp_path) -> None:
143
 
144
 
145
  async def test_studio_subject_fails_closed_for_ambiguous_memberships(tmp_path) -> None:
146
- database = SecurityDatabase(
147
- f"sqlite+aiosqlite:///{tmp_path / 'studio-ambiguous.db'}", auto_migrate=True
148
- )
149
- await database.initialize()
150
  try:
151
  await _seed_subject(database, "supabase-subject-many", 2)
152
  with pytest.raises(ForbiddenError):
@@ -156,12 +190,39 @@ async def test_studio_subject_fails_closed_for_ambiguous_memberships(tmp_path) -
156
 
157
 
158
  async def test_unknown_studio_subject_is_not_auto_provisioned(tmp_path) -> None:
159
- database = SecurityDatabase(
160
- f"sqlite+aiosqlite:///{tmp_path / 'studio-unknown.db'}", auto_migrate=True
161
- )
162
- await database.initialize()
163
  try:
164
  with pytest.raises(ForbiddenError):
165
  await TenantService(database).resolve_studio_subject("not-provisioned")
166
  finally:
167
  await database.close()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  SECRET = "a" * 64
18
 
19
 
20
+ def signed_headers(
21
+ *,
22
+ timestamp: int = 1_800_000_000,
23
+ subject: str = "supabase-user-1",
24
+ email: str = "user@example.com",
25
+ name: str = "Studio User",
26
+ ) -> dict[str, str]:
27
  signature = hmac.new(
28
  SECRET.encode(),
29
  "\n".join((str(timestamp), subject, email, name)).encode(),
 
44
  assert identity.email == "user@example.com"
45
 
46
 
47
+ @pytest.mark.parametrize(
48
+ ("changed_header", "changed_value"),
49
+ [
50
+ ("x-mr-studio-subject", "another-user"),
51
+ ("x-mr-studio-email", "attacker@example.com"),
52
+ ("x-mr-studio-name", "Administrator"),
53
+ ],
54
+ )
55
  def test_tampered_identity_fields_are_rejected(changed_header: str, changed_value: str) -> None:
56
  headers = signed_headers()
57
  headers[changed_header] = changed_value
 
61
 
62
  def test_expired_identity_is_rejected() -> None:
63
  with pytest.raises(ValueError, match="expired"):
64
+ verify_studio_identity(
65
+ signed_headers(timestamp=1_800_000_000), SECRET, now=1_800_000_301
66
+ )
67
 
68
 
69
  def test_incomplete_envelope_and_short_secret_are_rejected() -> None:
 
75
 
76
  def test_shared_admin_key_cannot_escalate_viewer_membership() -> None:
77
  context = AuthContext(
78
+ api_key_id="key",
79
+ key_name="Studio proxy",
80
+ key_prefix="mp_live_12345678",
81
+ environment="live",
82
+ role="admin",
83
+ scopes=frozenset({"admin", "projects:delete", "projects:read"}),
84
+ requests_per_minute=100,
85
+ concurrent_jobs=10,
86
+ uploads_per_hour=20,
87
+ processing_bytes_per_day=1000,
88
+ expires_at=None,
89
  )
90
  principal = TenantPrincipal(
91
+ workspace_id="workspace-a",
92
+ user_id="user-a",
93
+ membership_id="membership-a",
94
  membership_role="member",
95
  )
96
  scoped = constrain_context_to_membership(context, principal)
97
  assert scoped.workspace_id == "workspace-a"
98
  assert scoped.user_id == "user-a"
99
+ assert scoped.membership_role == "member"
100
  assert "admin" not in scoped.scopes
101
  assert "projects:delete" not in scoped.scopes
102
  assert "projects:read" in scoped.scopes
 
104
 
105
  def test_unknown_membership_role_fails_closed() -> None:
106
  context = AuthContext(
107
+ api_key_id="key",
108
+ key_name="Studio proxy",
109
+ key_prefix="mp_live_12345678",
110
+ environment="live",
111
+ role="admin",
112
+ scopes=frozenset({"admin"}),
113
+ requests_per_minute=100,
114
+ concurrent_jobs=10,
115
+ uploads_per_hour=20,
116
+ processing_bytes_per_day=1000,
117
+ expires_at=None,
118
  )
119
  principal = TenantPrincipal(
120
+ workspace_id="workspace-a",
121
+ user_id="user-a",
122
+ membership_id="membership-a",
123
  membership_role="superuser",
124
  )
125
  with pytest.raises(ValueError, match="Unsupported"):
126
  constrain_context_to_membership(context, principal)
127
 
128
 
129
+ async def _seed_subject(
130
+ database: SecurityDatabase, subject: str, workspace_count: int
131
+ ) -> tuple[str, list[str]]:
132
  user_id = str(uuid4())
133
+ workspace_ids: list[str] = []
134
  async with database.session() as session:
135
  user = User(id=user_id, subject=subject, display_name="Test user")
136
  rows = [user]
137
  for index in range(workspace_count):
138
  workspace_id = str(uuid4())
139
+ workspace_ids.append(workspace_id)
140
  rows.append(
141
  Workspace(
142
  id=workspace_id,
 
155
  )
156
  session.add_all(rows)
157
  await session.commit()
158
+ return user_id, workspace_ids
159
 
160
 
161
+ async def _database(tmp_path, filename: str) -> SecurityDatabase:
162
  database = SecurityDatabase(
163
+ f"sqlite+aiosqlite:///{tmp_path / filename}", auto_migrate=True
164
  )
165
  await database.initialize()
166
+ return database
167
+
168
+
169
+ async def test_studio_subject_resolves_one_active_membership(tmp_path) -> None:
170
+ database = await _database(tmp_path, "studio-tenant.db")
171
  try:
172
+ user_id, workspace_ids = await _seed_subject(database, "supabase-subject-one", 1)
173
  principal = await TenantService(database).resolve_studio_subject("supabase-subject-one")
174
  assert principal.user_id == user_id
175
+ assert principal.workspace_id == workspace_ids[0]
176
  assert principal.membership_id
177
  assert principal.membership_role == "member"
178
  finally:
 
180
 
181
 
182
  async def test_studio_subject_fails_closed_for_ambiguous_memberships(tmp_path) -> None:
183
+ database = await _database(tmp_path, "studio-ambiguous.db")
 
 
 
184
  try:
185
  await _seed_subject(database, "supabase-subject-many", 2)
186
  with pytest.raises(ForbiddenError):
 
190
 
191
 
192
  async def test_unknown_studio_subject_is_not_auto_provisioned(tmp_path) -> None:
193
+ database = await _database(tmp_path, "studio-unknown.db")
 
 
 
194
  try:
195
  with pytest.raises(ForbiddenError):
196
  await TenantService(database).resolve_studio_subject("not-provisioned")
197
  finally:
198
  await database.close()
199
+
200
+
201
+ @pytest.mark.parametrize("inactive_entity", ["user", "workspace", "membership"])
202
+ async def test_inactive_identity_or_membership_is_denied(tmp_path, inactive_entity: str) -> None:
203
+ database = await _database(tmp_path, f"inactive-{inactive_entity}.db")
204
+ try:
205
+ user_id, workspace_ids = await _seed_subject(
206
+ database, f"supabase-inactive-{inactive_entity}", 1
207
+ )
208
+ async with database.session() as session:
209
+ if inactive_entity == "user":
210
+ entity = await session.get(User, user_id)
211
+ elif inactive_entity == "workspace":
212
+ entity = await session.get(Workspace, workspace_ids[0])
213
+ else:
214
+ entity = await session.scalar(
215
+ __import__("sqlalchemy").select(WorkspaceMembership).where(
216
+ WorkspaceMembership.user_id == user_id,
217
+ WorkspaceMembership.workspace_id == workspace_ids[0],
218
+ )
219
+ )
220
+ assert entity is not None
221
+ entity.status = "disabled"
222
+ await session.commit()
223
+ with pytest.raises(ForbiddenError):
224
+ await TenantService(database).resolve_studio_subject(
225
+ f"supabase-inactive-{inactive_entity}"
226
+ )
227
+ finally:
228
+ await database.close()