Spaces:
Running
Running
test: cover inactive Studio tenant identities
Browse files- 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(
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 43 |
-
("
|
| 44 |
-
|
| 45 |
-
|
| 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(
|
|
|
|
|
|
|
| 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",
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
)
|
| 73 |
principal = TenantPrincipal(
|
| 74 |
-
workspace_id="workspace-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",
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
)
|
| 92 |
principal = TenantPrincipal(
|
| 93 |
-
workspace_id="workspace-a",
|
|
|
|
|
|
|
| 94 |
membership_role="superuser",
|
| 95 |
)
|
| 96 |
with pytest.raises(ValueError, match="Unsupported"):
|
| 97 |
constrain_context_to_membership(context, principal)
|
| 98 |
|
| 99 |
|
| 100 |
-
|
| 101 |
-
|
|
|
|
| 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
|
| 130 |
database = SecurityDatabase(
|
| 131 |
-
f"sqlite+aiosqlite:///{tmp_path /
|
| 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 =
|
| 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 =
|
| 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()
|