fastapi-permissions
fastapi-permissions는 Pyramid 프레임워크의 Pyraid Securiy 기능에서 영감을 받아 만들어진 라이브러리이다. 2024년 12월 기준으로 마지막 업데이트가 4년 전이어서 최신 FastAPI와의 호환성에 의문이 들지만, 접근 방식이 흥미로워 살펴보게 되었다.
fastapi-permissions 가 갖는 주요 철학과 개념, 내구 동작 방식을 살펴본다.
1. 주요 철학
fastapi-permissions 가 FastAPI security 와는 다른 점은 scope 로 접근을 제어하는게 아니라 더 저수준에서 세밀하게 접근을 제어한다는 것이다. 그에따라 접근자의 권한뿐만 아니라 접근 하려하는 리소스의 상태에 따라서도 접근을 제어할 수 있다.
2. 주요 개념
FastAPI에는 기본적으로 제공되지 않는 4가지 새로운 개념이 도입되었다.
- Resource (리소스): 관리 대상 엔티티, Access Control List 를 제공한다.
- ACL (Access Control List, 접근 제어 리스트): 리소스별 권한 규칙 집합, (액션, 주체, 권한) 들의 집합이다.
- Principals (주체): 사용자 및 그룹, 정의에 제한을 두지 않는다.
- Permissions (권한): 리소스에서 수행할 수 있는 작업, 정의에 제한을 두지 않는다.
주요개념들의 이름과 역할만 들어서 어떤식으로 접근제어를 하는지 감이 잡힌다. "특정 리소스의 권한 규칙이 있을때, 접근 주체가 권한을 가지고 있는지 검증한다."
2.1. Resource & ACL
이 예시에서 관리대상 엔티티(Resource)는 User 이다. User 클래스에 __acl__을 구현하였다.
from typing import List
from fastapi_permissions import Allow, Authenticated
# 사용자 데이터 모델 (ACL 포함)
class UserInDB:
def __init__(self, id: int, email: str, full_name: str, roles: List[str]):
self.id = id
self.email = email
self.full_name = full_name
self.roles = roles
def __acl__(self):
return [
(Allow, Authenticated, "view"),
(Allow, f"user:{self.id}", ("edit", "delete")),
(Allow, "role:admin", ("view", "edit", "delete")),
]
user_db: List[UserInDB] = [
UserInDB(id=i, email=f"mail{i}@gmail.com", full_name=f"name_{i}", roles=["user"]) for i in range(10)
]
2.2. Principl & Permissions
구현된 접근 제어 리스트(acl) 이 반환하는 tuple 중 하나를 접근 제어 항목(Access Control Item) 이라고 부르겠다. 하나의 접근제어 항목은 다음과 같이 구성되어 있다.
(액션, 주체(Principal), 권한(Permission)), 따라서 (Allow, Authenticated, "view") 라는 접근 제어 항목은 어떤 방식으로든 Authenticated 되었다고 판단된 주체는 "view" 권한이 허가(Allow) 된다는 것을 의미한다.
각각의 항목을 조금 자세히 살펴보면,
- 액션: Allow 혹은 Deny 가 가능하다.
- 주체: 라이브러리에서 이미 정해놓은 EveryOne, Authenticated 를 제외한 나머지는 개발자가 정의하여 넣을 수 있다.
- 권한: 라이브러리에서 정해진게 없다. 개발자가 원하는대로 설정할 수 있다.
2.3 에제 코드
순서상 주요객체와 함수를 설명해야겠지만 개인적으로 새로운 내용을 학습하고나서 그게 구현된 코드를 보는게 도움이 되었다. 그래서 이상의 개념들을 활용해서 간단한 API 와 접근제어를 구현하였다. 유저 정보를 DB 에서 읽어와서 pydantic model 로 변환하여 반환하는 것처럼 동작하는 GET 엔드포인트 하나다.
아래 코드를 직접 python 파일에 넣고 필요한 의존성을 설치한 뒤 http://localhost:8000/users/1 등을 해보면 유저의 정보가 조회되는 것을 확인할 수 있다.
from typing import List, Optional
from fastapi import FastAPI, HTTPException, status
from fastapi_permissions import Allow, Authenticated, configure_permissions, Everyone
from pydantic import BaseModel
# 사용자 데이터 모델 (ACL 포함)
class UserInDB:
def __init__(self, id: int, email: str, full_name: str, roles: List[str]):
self.id = id
self.email = email
self.full_name = full_name
self.roles = roles
def __acl__(self):
return [
(Allow, Authenticated, "view"),
(Allow, f"user:{self.id}", ("edit", "delete")),
(Allow, "role:admin", ("view", "edit", "delete")),
]
# 사용자 읽기 모델 (Pydantic)
class UserRead(BaseModel):
id: int
email: str
full_name: str
roles: List[str]
# 데이터베이스 시뮬레이션
user_db: List[UserInDB] = [
UserInDB(id=i, email=f"mail{i}@gmail.com", full_name=f"name_{i}", roles=["user"]) for i in range(10)
]
# 사용자 인증 및 주체(principals) 생성
def get_active_principals(user_id: int) -> List[str]:
# 데이터베이스에서 사용자 조회
user = next((u for u in user_db if u.id == user_id), None)
if user:
principals = [Everyone, Authenticated, f"user:{user.id}"] + [f"role:{role}" for role in user.roles]
principals.extend(getattr(user, "principals", []))
else:
principals = [Everyone]
return principals
# 특정 사용자 리소스 반환
def get_user_resource(user_id: int) -> Optional[UserInDB]:
user = next((u for u in user_db if u.id == user_id), None)
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found",
)
return user
# 권한 검사 시스템 설정
Permission = configure_permissions(get_active_principals)
# FastAPI 앱 초기화
app = FastAPI()
# 사용자 정보 조회 엔드포인트
@app.get("/users/{user_id}", response_model=UserRead)
async def get_user(
user: UserInDB = Permission("view", get_user_resource),
):
return UserRead(**user.__dict__)
get_active_principals(user_id: int)는 user_id 가 user_db 리스트에 있으면 user 정보를 활용해 principal 을 생성해 반환한다.
get_user_resource(user_id: int) 는 user_id 가 user_db 리스트에 있으면 user 정보를 반환한다.
그리고 아직 뭔지 모르는 Permission 이라는 객체는 configure_permissions(get_active_principals) 를 통해 생성되더니, 엔드포인트 파라미터에서는 객체 자체를 주입하고 "view" (요구되는 permission 일 것이다) 와 get_user_resource 를 이용해 구현체를 반환한다.
적절한 유저 정보를 반환한다는 사실과, get_active_principals 가 EveyOne 만 반환하게 바꾸면 엔드포인트가 403을 반환하다는 사실에 기반하여 어떤식으로든 Permission 객체는 user_id 를 읽어와서 get_user_resource 와 get_active_principals 를 호출하여 접근 제어와 유저정보 반환을 모두 수행한다는 것을 추측할 수 있다.
이제 직관적인 흐름이 그려졌다. 이제 세부적으로 파고들어가서 어떻게 이런 일이 가능한건지 알아보자.
3. fastapi-permission 내부동작 파헤치기
내부 동작을 세부적으로 파고들기에 앞서 결론부터 요약하자면 configure_permissions 함수를 통해 만들어낸 Permissions 이라는 객체는 내부적으로 has_permission 을 호출하여 접근주체(principal) 자원(resource)에 접근권한(permission) 이 있는지 체크하는 것으로 동작한다. 이때 fastapi 에서 제공하는 Depends 를 활용하여 동작을 추상화한다.
이제 fastapi-permissions 라이브러리 README.md 에서 언급한 함수와 객체 위주로 코드를 까보자
3.1. 주요 객체와 함수
has_permissions(principals, permission, resource):
주어진 사용자(principals)가 특정 자원(resource) 에 대한 권한(permission)이 있는지 검사하여 True/False 를 반환한다.
list_permissions(princiapals, resource):
주어진 사용자(principals)가 특정 자원(resource) 에 대해 가질 수 있는 모든 권한(permission)을 반환다. dict를 반환한다.
Permission:
FastAPI 경로(Path Operation)에서 Depends 를 사용하여 권한 검사를 간단히 처리할 수 있도록 도와주는 의존성 객체이다.
내부적으로 이미 Depends 로 감싸져있기 때문에 별도로 Depneds 를 설정할 필요는 없다.
명시적으로는 confiture_permissions() 를 이용하여 생성하고, 내부적으로는 permission_dependency_factory() 가 생성한 함수를 사용하여 동작한다.
permission_dependency_factory(permission, resource, active_principals_func, permission_exception):
권한 검사를 위한 FastAPI 의존성(dependency)을 생성한다.
내부적으로 principals 와 resource 를 가져와 has_permissions() 를 호출하여 권한을 검사한다.
사용자(principals) 이 접근 권한이 없다고 판단되면 permission_exception 을 일으킨다. (default 값 변경가능)
configure_permissions(active_principals_func, permission_execption):
permission_dependency_factory 를 단순화한 버전이다.
대략적으로 살펴봤을때 중요한 역할을 하는 함수는 permission_dependency_factory() 와 configure_permissions() 으로 보인다. 이들을 중점적으로 살펴보자
3.2. permission_dependency_factory():
소스코드를 까보자
def permission_dependency_factory(
permission: str,
resource: Any,
active_principals_func: Any,
permission_exception: HTTPException,
):
if callable(resource):
dependable_resource = Depends(resource)
else:
dependable_resource = Depends(lambda: resource)
# to get the caller signature right, we need to add only the resource and
# user dependable in the definition
# the permission itself is available through the outer function scope
def permission_dependency(
resource=dependable_resource, principals=active_principals_func
):
if has_permission(principals, permission, resource):
return resource
raise permission_exception
return Depends(permission_dependency)
검사할 권한(permission) 과 대상 자원(resource)를 전달받아서 의존성 주입을 받도록 만들고, 전달받은 접근자 정보를 얻는 방법(active_principals_func)를 활용하여 has_permission 으로 권한을 검사하는 permission_dependency 라는 함수를 정의하여 반환한다.
위의 예제코드에서 get_user_resource 가 의도한대로 동작할 수 있었던 이유는 FastAPI 엔드포인트에 의존성 (Depends) 으로 설정되어 주입됐기 때문이다.
그렇다면 active_principals_func 는 어떻게 그게 가능했을까? 이 함수는 FastAPI 의존성(Depends) 으로 선언되어 있지 않다.
그 비밀은 다음에 자세히 살펴볼 configure_permissions() 함수에 있다.
3.3. configure_permissions():
confiture_permissions() 는 permission_dedendency_factory 를 쉽게 사용하는 편의 함수정도로 얘기했지만 사실 중요한 차이가 있다. 바로 configure_permissions() 를 이용해야 active_principals_func 에 의존성(Depends)이 설정된다는 것이다.
def configure_permissions(
active_principals_func: Any,
permission_exception: HTTPException = permission_exception,
):
active_principals_func = Depends(active_principals_func)
return functools.partial(
permission_dependency_factory,
active_principals_func=active_principals_func,
permission_exception=permission_exception,
)
이제 위의 예제코드에서 필요한 부분만 짤라서 살펴보자, 각 주석에 자세히 설명되어 있다.
# 권한 검사 시스템 설정, 이때 넘긴 get_active_principals 함수는 FastAPI 의존성으로 설정된다.
# Permission 객체는 permission_dependency_factory() 를 functuools.partial 에 의해 호출하는 callable 객체이다.
# functuools.partial 덕분에 외부 클로저를 활용할 수 있어서 Permission 객체에는 permission: str, resource: Any 만 전달하면 된다.
Permission = configure_permissions(get_active_principals)
app = FastAPI()
# 사용자 정보 조회 엔드포인트
@app.get("/users/{user_id}", response_model=UserRead)
async def get_user(
user: UserInDB = Permission("view", get_user_resource),
):
# Permission 을 호출함으로써 get_user_resource 는 FastAPI 의존성으로 설정되었다.
# 내부적으로 get_active_principals 함수와 get_user_resource 함수가 돌아가고
# has_permission 을 통해 "view" 권한이 있는지 검사한다.
return UserRead(**user.__dict__)
'탐구 생활 > FastAPI' 카테고리의 다른 글
FastAPI 의존성 주입, Depends 를 알아보자 (0) | 2024.12.04 |
---|---|
오픈소스 초보자의 FastAPI 기여하기 (2) | 2024.12.03 |