Skip to content

Commit e01caf4

Browse files
ImTotemclaude
andcommitted
feat(api): add SpiceDB authorization, member filters, and infra
- Add SpiceDB schema with hierarchical role-based permissions (외부인 < 비기너 < 레귤러 < 교육장 < 트랙장 < 부회장/회장 < 멘토) - Add authz client wrapper (check, add/remove relation) - Add docker-compose.yml (SpiceDB + API services) - Add GET /v1/members/filters (tracks, statuses, payment_statuses) - Add Forbidden (403) exception - Auto-load SpiceDB schema on app startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6fbfe3f commit e01caf4

14 files changed

Lines changed: 255 additions & 5 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,7 @@ RESEND_SENDER=onboarding@resend.dev
1616

1717
# CORS (쉼표로 여러 origin 구분)
1818
CORS_ORIGINS=http://localhost:3000
19+
20+
# SpiceDB (권한 관리)
21+
SPICEDB_ENDPOINT=localhost:50051
22+
SPICEDB_TOKEN=bcsd-dev-token

docker-compose.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
services:
2+
spicedb:
3+
image: authzed/spicedb:latest
4+
command: serve
5+
ports:
6+
- "50051:50051" # gRPC
7+
- "8443:8443" # HTTP gateway
8+
- "9090:9090" # metrics
9+
environment:
10+
SPICEDB_GRPC_PRESHARED_KEY: "${SPICEDB_TOKEN:-bcsd-dev-token}"
11+
SPICEDB_DATASTORE_ENGINE: memory
12+
healthcheck:
13+
test: ["CMD", "grpc_health_probe", "-addr=localhost:50051"]
14+
interval: 10s
15+
timeout: 5s
16+
retries: 5
17+
18+
api:
19+
build: .
20+
ports:
21+
- "8000:8000"
22+
env_file:
23+
- .env
24+
depends_on:
25+
spicedb:
26+
condition: service_healthy

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ dependencies = [
1616
"python-jose[cryptography]>=3.3.0",
1717

1818
"resend>=2.0.0",
19+
"authzed>=1.0.0",
1920
]
2021

2122
[project.optional-dependencies]

spicedb/schema.zed

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* BCSDLab 권한 관리 스키마
3+
*
4+
* 계층 구조 (아래에서 위로 권한 누적):
5+
* 외부인 < 비기너 < 레귤러 < 교육장 < 트랙장 < 부회장/회장 < 멘토
6+
*
7+
* 한 사용자가 여러 역할을 가질 수 있음 (예: 레귤러 + 트랙장)
8+
*/
9+
10+
definition user {}
11+
12+
definition track {
13+
relation leader: user
14+
relation member: user
15+
16+
permission manage = leader
17+
permission view = leader + member
18+
}
19+
20+
definition organization {
21+
/** 역할 관계 (낮은 → 높은) */
22+
relation outsider: user
23+
relation beginner: user
24+
relation regular: user
25+
relation educator: user
26+
relation track_leader: user
27+
relation vice_president: user
28+
relation president: user
29+
relation mentor: user
30+
31+
/** 계층 누적 권한 */
32+
permission member_view = beginner + regular + educator + track_leader + vice_president + president + mentor
33+
permission member_edit = regular + educator + track_leader + vice_president + president + mentor
34+
permission fee_view = educator + track_leader + vice_president + president + mentor
35+
permission fee_edit = vice_president + president + mentor
36+
permission group_manage = vice_president + president + mentor
37+
permission admin = president + mentor
38+
permission full_control = mentor
39+
}
40+
41+
definition member {
42+
relation owner: user
43+
relation organization: organization
44+
relation track: track
45+
46+
permission view = owner + organization->member_view
47+
permission edit = owner + organization->member_edit + track->leader
48+
permission delete = organization->admin
49+
}
50+
51+
definition fee {
52+
relation organization: organization
53+
relation payer: user
54+
55+
permission view = payer + organization->fee_view
56+
permission edit = organization->fee_edit
57+
permission delete = organization->admin
58+
}
59+
60+
definition group {
61+
relation organization: organization
62+
relation leader: user
63+
relation member: user
64+
65+
permission view = leader + member + organization->member_view
66+
permission edit = leader + organization->group_manage
67+
permission delete = organization->admin
68+
permission manage = leader + organization->group_manage
69+
}
70+
71+
definition event {
72+
relation organization: organization
73+
relation organizer: user
74+
relation attendee: user
75+
76+
permission view = organizer + attendee + organization->member_view
77+
permission edit = organizer + organization->group_manage
78+
permission delete = organization->admin
79+
}

src/bcsd_api/authz/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .client import AuthzClient
2+
3+
__all__ = ["AuthzClient"]

src/bcsd_api/authz/client.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from authzed.api.v1 import (
2+
CheckPermissionRequest,
3+
CheckPermissionResponse,
4+
Client,
5+
ObjectReference,
6+
Relationship,
7+
RelationshipUpdate,
8+
SubjectReference,
9+
WriteRelationshipsRequest,
10+
WriteSchemaRequest,
11+
)
12+
from grpcutil import insecure_bearer_token_credentials
13+
14+
_TOUCH = RelationshipUpdate.Operation.OPERATION_TOUCH
15+
_DELETE = RelationshipUpdate.Operation.OPERATION_DELETE
16+
_HAS = CheckPermissionResponse.PERMISSIONSHIP_HAS_PERMISSION
17+
18+
19+
def _object_ref(obj_type: str, obj_id: str) -> ObjectReference:
20+
return ObjectReference(object_type=obj_type, object_id=obj_id)
21+
22+
23+
def _subject_ref(user_id: str) -> SubjectReference:
24+
return SubjectReference(object=_object_ref("user", user_id))
25+
26+
27+
def _relationship(res_type: str, res_id: str, relation: str, user_id: str) -> Relationship:
28+
return Relationship(
29+
resource=_object_ref(res_type, res_id),
30+
relation=relation,
31+
subject=_subject_ref(user_id),
32+
)
33+
34+
35+
def _update(operation, res_type: str, res_id: str, relation: str, user_id: str) -> RelationshipUpdate:
36+
return RelationshipUpdate(
37+
operation=operation,
38+
relationship=_relationship(res_type, res_id, relation, user_id),
39+
)
40+
41+
42+
class AuthzClient:
43+
def __init__(self, endpoint: str, token: str):
44+
self._client = Client(
45+
endpoint,
46+
insecure_bearer_token_credentials(token),
47+
)
48+
49+
def check(self, res_type: str, res_id: str, permission: str, user_id: str) -> bool:
50+
resp = self._client.CheckPermission(
51+
CheckPermissionRequest(
52+
resource=_object_ref(res_type, res_id),
53+
permission=permission,
54+
subject=_subject_ref(user_id),
55+
)
56+
)
57+
return resp.permissionship == _HAS
58+
59+
def add_relation(self, res_type: str, res_id: str, relation: str, user_id: str) -> None:
60+
self._write(_TOUCH, res_type, res_id, relation, user_id)
61+
62+
def remove_relation(self, res_type: str, res_id: str, relation: str, user_id: str) -> None:
63+
self._write(_DELETE, res_type, res_id, relation, user_id)
64+
65+
def write_schema(self, schema: str) -> None:
66+
self._client.WriteSchema(WriteSchemaRequest(schema=schema))
67+
68+
def _write(self, operation: int, res_type: str, res_id: str, relation: str, user_id: str) -> None:
69+
self._client.WriteRelationships(
70+
WriteRelationshipsRequest(updates=[_update(operation, res_type, res_id, relation, user_id)])
71+
)

src/bcsd_api/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,6 @@ class Settings(BaseSettings):
1313
cors_origins: str = "http://localhost:3000"
1414
cookie_name: str = "access_token"
1515
cookie_secure: bool = False
16+
spicedb_endpoint: str = "localhost:50051"
17+
spicedb_token: str = "bcsd-dev-token"
1618
model_config = {"env_file": ".env"}

src/bcsd_api/dependencies.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from fastapi import Depends, Request
44

55
from .auth import token as jwt_token
6+
from .authz.client import AuthzClient
67
from .config import Settings
78
from .email import ResendSender
89
from .email.sender import EmailSender
@@ -40,6 +41,17 @@ def get_email_sender(settings: Settings = Depends(get_settings)) -> EmailSender:
4041
return _sender_cache
4142

4243

44+
_authz_cache: AuthzClient | None = None
45+
46+
47+
def get_authz(settings: Settings = Depends(get_settings)) -> AuthzClient:
48+
global _authz_cache
49+
if _authz_cache:
50+
return _authz_cache
51+
_authz_cache = AuthzClient(settings.spicedb_endpoint, settings.spicedb_token)
52+
return _authz_cache
53+
54+
4355
def get_member_repo(sheets: SheetsClient = Depends(get_sheets)) -> MemberRepository:
4456
return MemberRepository(sheets)
4557

src/bcsd_api/exception/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from .base import AppException
2-
from .errors import BadRequest, Conflict, NotFound, Unauthorized
2+
from .errors import BadRequest, Conflict, Forbidden, NotFound, Unauthorized
33
from .handlers import register_handlers
44

55
__all__ = [
66
"AppException",
77
"BadRequest",
88
"Conflict",
9+
"Forbidden",
910
"NotFound",
1011
"Unauthorized",
1112
"register_handlers",

src/bcsd_api/exception/errors.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,9 @@ class BadRequest(AppException):
2323
status_code = 400
2424
error_code = "BAD_REQUEST"
2525
message = "bad request"
26+
27+
28+
class Forbidden(AppException):
29+
status_code = 403
30+
error_code = "FORBIDDEN"
31+
message = "permission denied"

0 commit comments

Comments
 (0)