Skip to content

Commit fdf26d2

Browse files
committed
fix(authz): finance security hardening
- AuthorizeFinanceRead/Write: use finance-specific scopes - AccountPolicyGuard/PayoutAccountPolicyGuard: explicit scope requirements - Blocked org filtering in get_by_account/get_by_payout_account - Write endpoints re-fetch entities on write session - Blocked org test
1 parent 082351b commit fdf26d2

6 files changed

Lines changed: 57 additions & 6 deletions

File tree

server/polar/account/endpoints.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from polar.account_credit.repository import AccountCreditRepository
44
from polar.account_credit.schemas import AccountCredit as AccountCreditSchema
55
from polar.authz.dependencies import AuthorizeAccountRead, AuthorizeAccountWrite
6+
from polar.exceptions import ResourceNotFound
67
from polar.openapi import APITag
78
from polar.postgres import (
89
AsyncReadSession,
@@ -32,7 +33,14 @@ async def patch(
3233
account_update: AccountUpdate,
3334
session: AsyncSession = Depends(get_db_session),
3435
) -> AccountSchema:
35-
updated = await account_service.update(session, authorized.account, account_update)
36+
# Re-fetch on write session — the guard loaded on a read session
37+
from polar.account.repository import AccountRepository
38+
39+
repository = AccountRepository.from_session(session)
40+
account = await repository.get_by_id(authorized.account.id)
41+
if account is None:
42+
raise ResourceNotFound()
43+
updated = await account_service.update(session, account, account_update)
3644
return AccountSchema.model_validate(updated)
3745

3846

server/polar/authz/dependencies.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ async def policy(
207207
)
208208
),
209209
]
210+
211+
210212
# ---------------------------------------------------------------------------
211213
# Account-based policy guards
212214
# ---------------------------------------------------------------------------

server/polar/organization/endpoints.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,9 +266,17 @@ async def delete(
266266
If deletion cannot proceed immediately (has orders, subscriptions, or
267267
Stripe deletion fails), a support ticket will be created for manual handling.
268268
"""
269+
# Re-fetch on write session — the guard loaded on a read session
270+
from polar.organization.repository import OrganizationRepository
271+
272+
org_repo = OrganizationRepository.from_session(session)
273+
organization = await org_repo.get_by_id(authorized.organization.id)
274+
if organization is None:
275+
raise ResourceNotFound()
276+
269277
assert is_user(authorized.auth_subject)
270278
result = await organization_service.request_deletion(
271-
session, authorized.auth_subject, authorized.organization
279+
session, authorized.auth_subject, organization
272280
)
273281

274282
return OrganizationDeletionResponse(
@@ -361,7 +369,13 @@ async def invite_member(
361369
session: AsyncSession = Depends(get_db_session),
362370
) -> OrganizationMember:
363371
"""Invite a user to join an organization."""
364-
organization = authorized.organization
372+
# Re-fetch on write session — the guard loaded on a read session
373+
from polar.organization.repository import OrganizationRepository
374+
375+
org_repo = OrganizationRepository.from_session(session)
376+
organization = await org_repo.get_by_id(authorized.organization.id)
377+
if organization is None:
378+
raise ResourceNotFound()
365379

366380
# Get or create user by email
367381
user, _ = await user_service.get_by_email_or_create(session, invite_body.email)

server/polar/organization/repository.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,8 @@ async def get_by_id(
6666
async def get_by_account(self, account_id: UUID) -> Organization | None:
6767
"""Get the organization that owns the given account."""
6868
statement = self.get_base_statement().where(
69-
Organization.account_id == account_id
69+
Organization.account_id == account_id,
70+
Organization.status != OrganizationStatus.BLOCKED,
7071
)
7172
return await self.get_one_or_none(statement)
7273

@@ -75,7 +76,8 @@ async def get_by_payout_account(
7576
) -> Organization | None:
7677
"""Get the organization that uses the given payout account."""
7778
statement = self.get_base_statement().where(
78-
Organization.payout_account_id == payout_account_id
79+
Organization.payout_account_id == payout_account_id,
80+
Organization.status != OrganizationStatus.BLOCKED,
7981
)
8082
return await self.get_one_or_none(statement)
8183

server/polar/payout_account/endpoints.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
AuthorizePayoutAccountRead,
88
AuthorizePayoutAccountWrite,
99
)
10+
from polar.exceptions import ResourceNotFound
1011
from polar.kit.pagination import ListResource, Pagination
1112
from polar.models import PayoutAccount
1213
from polar.openapi import APITag
@@ -64,7 +65,14 @@ async def delete(
6465
authorized: AuthorizePayoutAccountWrite,
6566
session: AsyncSession = Depends(get_db_session),
6667
) -> None:
67-
await payout_account_service.delete(session, authorized.payout_account)
68+
# Re-fetch on write session — the guard loaded on a read session
69+
from polar.payout_account.repository import PayoutAccountRepository
70+
71+
repository = PayoutAccountRepository.from_session(session)
72+
payout_account = await repository.get_by_id(authorized.payout_account.id)
73+
if payout_account is None:
74+
raise ResourceNotFound()
75+
await payout_account_service.delete(session, payout_account)
6876

6977

7078
@router.post("/{id}/onboarding-link", response_model=PayoutAccountLink)

server/tests/authz/test_endpoints.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from httpx import AsyncClient
55

66
from polar.models import Organization, User
7+
from polar.models.organization import OrganizationStatus
78
from polar.models.user_organization import UserOrganization
89
from tests.fixtures.database import SaveFixture
910
from tests.fixtures.random_objects import create_account
@@ -66,6 +67,22 @@ async def test_admin_returns_200(
6667
response = await client.get(f"/v1/organizations/{organization.id}/account")
6768
assert response.status_code == 200
6869

70+
@pytest.mark.auth
71+
async def test_blocked_org_returns_404(
72+
self,
73+
client: AsyncClient,
74+
save_fixture: SaveFixture,
75+
user: User,
76+
organization: Organization,
77+
user_organization: UserOrganization,
78+
) -> None:
79+
organization.account = await create_account(save_fixture, user=user)
80+
organization.status = OrganizationStatus.BLOCKED
81+
await save_fixture(organization)
82+
83+
response = await client.get(f"/v1/organizations/{organization.id}/account")
84+
assert response.status_code == 404
85+
6986

7087
@pytest.mark.asyncio
7188
class TestPolicyGuardDeleteOrganization:

0 commit comments

Comments
 (0)