Skip to content

Commit 7294f66

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 7bbb09b commit 7294f66

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
@@ -144,6 +144,8 @@ async def _always_allow(
144144
)
145145
),
146146
]
147+
148+
147149
# ---------------------------------------------------------------------------
148150
# Account-based policy guards
149151
# ---------------------------------------------------------------------------

server/polar/organization/endpoints.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,16 @@ async def delete(
263263
If deletion cannot proceed immediately (has orders, subscriptions, or
264264
Stripe deletion fails), a support ticket will be created for manual handling.
265265
"""
266+
# Re-fetch on write session — the guard loaded on a read session
267+
from polar.organization.repository import OrganizationRepository
268+
269+
org_repo = OrganizationRepository.from_session(session)
270+
organization = await org_repo.get_by_id(authz.organization.id)
271+
if organization is None:
272+
raise ResourceNotFound()
273+
266274
result = await organization_service.request_deletion(
267-
session, authz.auth_subject, authz.organization
275+
session, authz.auth_subject, organization
268276
)
269277

270278
return OrganizationDeletionResponse(
@@ -354,7 +362,13 @@ async def invite_member(
354362
session: AsyncSession = Depends(get_db_session),
355363
) -> OrganizationMember:
356364
"""Invite a user to join an organization."""
357-
organization = authz.organization
365+
# Re-fetch on write session — the guard loaded on a read session
366+
from polar.organization.repository import OrganizationRepository
367+
368+
org_repo = OrganizationRepository.from_session(session)
369+
organization = await org_repo.get_by_id(authz.organization.id)
370+
if organization is None:
371+
raise ResourceNotFound()
358372

359373
# Get or create user by email
360374
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)