Skip to content

Commit 90c8a79

Browse files
ImTotemclaude
andcommitted
feat(authz): integrate SpiceDB permission checks into resolvers
- New authz/check.py: require_admin, require_fee_edit, require_member_view - GqlContext: add authz client (nullable, skipped if SpiceDB unavailable) - Permission enforcement: - createPeriod, updatePeriod, createForm, updateForm, setSetting → admin - applications list, application detail, confirmPayment, approve → fee_edit - submit, myApplications, cancel → authenticated user only - Approval adds organization relation in SpiceDB (beginner/regular) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0613e85 commit 90c8a79

File tree

7 files changed

+73
-7
lines changed

7 files changed

+73
-7
lines changed

src/bcsd_api/apply/resolvers.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import strawberry
22
from strawberry.types import Info
33

4+
from bcsd_api.authz.check import require_fee_edit
45
from bcsd_api.graphql.context import GqlContext, require_user
5-
from bcsd_api.graphql.convert import from_model
66

77
from . import service
88
from .schema import AnswerRequest
@@ -30,7 +30,8 @@ def resolve_submit(info: Info[GqlContext, None], input: SubmitInput) -> Applicat
3030
def resolve_applications(
3131
info: Info[GqlContext, None], form_id: str,
3232
) -> list[ApplicationType]:
33-
require_user(info.context)
33+
user = require_user(info.context)
34+
require_fee_edit(info.context.authz, user["sub"])
3435
ctx = info.context
3536
apps = service.list_applications(ctx.app_repo, ctx.ans_repo, form_id)
3637
return [_to_app_type(a) for a in apps]
@@ -39,7 +40,8 @@ def resolve_applications(
3940
def resolve_application(
4041
info: Info[GqlContext, None], id: strawberry.ID,
4142
) -> ApplicationType:
42-
require_user(info.context)
43+
user = require_user(info.context)
44+
require_fee_edit(info.context.authz, user["sub"])
4345
ctx = info.context
4446
app = service.get_application(ctx.app_repo, ctx.ans_repo, id)
4547
return _to_app_type(app)
@@ -56,6 +58,7 @@ def resolve_confirm_payment(
5658
info: Info[GqlContext, None], id: strawberry.ID,
5759
) -> ApplicationType:
5860
user = require_user(info.context)
61+
require_fee_edit(info.context.authz, user["sub"])
5962
app = service.confirm_payment(info.context.app_repo, id, user["sub"])
6063
return _to_app_type(app)
6164

@@ -64,10 +67,11 @@ def resolve_approve(
6467
info: Info[GqlContext, None], ids: list[strawberry.ID],
6568
) -> list[ApplicationType]:
6669
user = require_user(info.context)
70+
require_fee_edit(info.context.authz, user["sub"])
6771
ctx = info.context
6872
apps = service.approve(
6973
ctx.app_repo, ctx.member_repo, ctx.form_repo,
70-
ids, user["sub"],
74+
ids, user["sub"], ctx.authz,
7175
)
7276
return [_to_app_type(a) for a in apps]
7377

src/bcsd_api/apply/service.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ def approve(
127127
form_repo: PgFormRepository,
128128
app_ids: Sequence[str],
129129
admin_id: str,
130+
authz=None,
130131
) -> list[ApplicationResponse]:
131132
now = _now()
132133
result = []
@@ -141,11 +142,24 @@ def approve(
141142
"approved_by": admin_id, "updated_at": now,
142143
})
143144
member_repo.update_status(row["member_id"], new_status)
145+
_add_org_relation(authz, row["member_id"], new_status)
144146
row.update({"status": "승인", "approved_at": now, "approved_by": admin_id})
145147
result.append(ApplicationResponse(**row))
146148
return result
147149

148150

151+
_STATUS_RELATION = {"Beginner": "beginner", "Regular": "regular"}
152+
153+
154+
def _add_org_relation(authz, member_id: str, status: str) -> None:
155+
if not authz:
156+
return
157+
relation = _STATUS_RELATION.get(status)
158+
if not relation:
159+
return
160+
authz.add_relation("organization", "bcsdlab", relation, member_id)
161+
162+
149163
def cancel(
150164
app_repo: PgApplicationRepository, app_id: str, member_id: str,
151165
) -> None:

src/bcsd_api/authz/check.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from bcsd_api.exception import Forbidden
2+
3+
ORG_ID = "bcsdlab"
4+
5+
6+
def require_permission(authz, permission: str, user_id: str) -> None:
7+
if not authz:
8+
return
9+
if authz.check("organization", ORG_ID, permission, user_id):
10+
return
11+
raise Forbidden(f"{permission} permission required")
12+
13+
14+
def require_admin(authz, user_id: str) -> None:
15+
require_permission(authz, "admin", user_id)
16+
17+
18+
def require_fee_edit(authz, user_id: str) -> None:
19+
require_permission(authz, "fee_edit", user_id)
20+
21+
22+
def require_member_view(authz, user_id: str) -> None:
23+
require_permission(authz, "member_view", user_id)

src/bcsd_api/form/resolvers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import strawberry
22
from strawberry.types import Info
33

4+
from bcsd_api.authz.check import require_admin
45
from bcsd_api.graphql.context import GqlContext, require_user
56
from bcsd_api.graphql.convert import from_model
67

@@ -46,6 +47,7 @@ def resolve_create_form(
4647
info: Info[GqlContext, None], input: CreateFormInput,
4748
) -> FormType:
4849
user = require_user(info.context)
50+
require_admin(info.context.authz, user["sub"])
4951
ctx = info.context
5052
req = CreateFormRequest(
5153
title=input.title, description=input.description,
@@ -59,7 +61,8 @@ def resolve_create_form(
5961
def resolve_update_form(
6062
info: Info[GqlContext, None], id: strawberry.ID, input: UpdateFormInput,
6163
) -> FormType:
62-
require_user(info.context)
64+
user = require_user(info.context)
65+
require_admin(info.context.authz, user["sub"])
6366
ctx = info.context
6467
questions = None
6568
if input.questions is not None:

src/bcsd_api/graphql/context.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
import logging
2+
13
from fastapi import Depends, Request
24
from sqlalchemy import Connection
35
from strawberry.fastapi import BaseContext
46

57
from bcsd_api.auth import token as jwt_token
8+
from bcsd_api.authz.client import AuthzClient
69
from bcsd_api.config import Settings
710
from bcsd_api.dependencies import (
811
get_ans_repo,
912
get_app_repo,
13+
get_authz,
1014
get_conn,
1115
get_form_repo,
1216
get_link_repo,
@@ -24,12 +28,14 @@
2428
from bcsd_api.setting.pg_repository import PgSettingRepository
2529
from bcsd_api.shorten.pg_repository import PgLinkRepository
2630

31+
logger = logging.getLogger(__name__)
32+
2733

2834
class GqlContext(BaseContext):
2935
def __init__(
3036
self, conn, member_repo, link_repo,
3137
setting_repo, recruit_repo, form_repo, question_repo,
32-
app_repo, ans_repo, user,
38+
app_repo, ans_repo, authz, user,
3339
):
3440
self.conn = conn
3541
self.member_repo = member_repo
@@ -40,6 +46,7 @@ def __init__(
4046
self.question_repo = question_repo
4147
self.app_repo = app_repo
4248
self.ans_repo = ans_repo
49+
self.authz = authz
4350
self.user = user
4451

4552

@@ -50,6 +57,14 @@ def _try_auth(request: Request, settings: Settings) -> dict | None:
5057
return jwt_token.decode_or_none(raw, settings.jwt_secret, settings.jwt_algorithm)
5158

5259

60+
def _try_authz(settings: Settings) -> AuthzClient | None:
61+
try:
62+
return get_authz(settings)
63+
except Exception:
64+
logger.warning("SpiceDB unavailable, skipping authz")
65+
return None
66+
67+
5368
def require_user(ctx: GqlContext) -> dict:
5469
if not ctx.user:
5570
raise Unauthorized("authentication required")
@@ -70,6 +85,7 @@ async def context_getter(
7085
ans_repo: PgAnswerRepository = Depends(get_ans_repo),
7186
) -> GqlContext:
7287
user = _try_auth(request, settings)
88+
authz = _try_authz(settings)
7389
return GqlContext(
7490
conn=conn,
7591
member_repo=member_repo,
@@ -80,5 +96,6 @@ async def context_getter(
8096
question_repo=question_repo,
8197
app_repo=app_repo,
8298
ans_repo=ans_repo,
99+
authz=authz,
83100
user=user,
84101
)

src/bcsd_api/recruit/resolvers.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import strawberry
22
from strawberry.types import Info
33

4+
from bcsd_api.authz.check import require_admin
45
from bcsd_api.graphql.context import GqlContext, require_user
56
from bcsd_api.graphql.convert import from_model
67

@@ -35,6 +36,7 @@ def resolve_create_period(
3536
info: Info[GqlContext, None], input: CreatePeriodInput,
3637
) -> PeriodType:
3738
user = require_user(info.context)
39+
require_admin(info.context.authz, user["sub"])
3840
req = CreatePeriodRequest(
3941
title=input.title, type=input.type,
4042
start_date=input.start_date, end_date=input.end_date,
@@ -46,7 +48,8 @@ def resolve_create_period(
4648
def resolve_update_period(
4749
info: Info[GqlContext, None], id: strawberry.ID, input: UpdatePeriodInput,
4850
) -> PeriodType:
49-
require_user(info.context)
51+
user = require_user(info.context)
52+
require_admin(info.context.authz, user["sub"])
5053
req = UpdatePeriodRequest(
5154
title=input.title, start_date=input.start_date,
5255
end_date=input.end_date, is_active=input.is_active,

src/bcsd_api/setting/resolvers.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from strawberry.types import Info
22

3+
from bcsd_api.authz.check import require_admin
34
from bcsd_api.graphql.context import GqlContext, require_user
45

56
from . import service
@@ -12,5 +13,6 @@ def resolve_setting(info: Info[GqlContext, None], key: str) -> str | None:
1213

1314
def resolve_set_setting(info: Info[GqlContext, None], key: str, value: str) -> bool:
1415
user = require_user(info.context)
16+
require_admin(info.context.authz, user["sub"])
1517
service.set_setting(info.context.setting_repo, key, value, user["sub"])
1618
return True

0 commit comments

Comments
 (0)