https://noose.tistory.com/38 이전 포스팅을 읽으면 도움이 됩니다.
SpiceDB는 HTTP API 및 gRPC를 제공하고 있다.
실무에서는 빠른 통신 속도를 위해 gRPC를 사용하는 것을 권장한다.
gRPC 설정이 복잡해 보일 수 있지만, 공식 라이브러리를 활용하면 쉽게 적용할 수 있다.
https://github.com/authzed/authzed-java
GitHub - authzed/authzed-java: Official SpiceDB client library for JVM languages
Official SpiceDB client library for JVM languages. Contribute to authzed/authzed-java development by creating an account on GitHub.
github.com
이번 글에서는 Kotlin Spring 애플리케이션에서 SpiceDB와 통신하는 방법을 정리한다.
Gradle 의존성 추가
implementation("cohttp://m.authzed.api:authzed:v1.0.0")
implementation("io.grpc:grpc-protobuf:1.66.0") // gRPC Protobuf 지원
implementation("io.grpc:grpc-stub:1.66.0") // gRPC Stub
✅ authzed 라이브러리는 SpiceDB gRPC 클라이언트 API를 제공
✅ grpc-protobuf 및 grpc-stub 을 추가하여 gRPC 메시지 및 Stub을 사용할 수 있게함
클라이언트 Bean 등록
@Component
internal class GrpcSpiceDBConfig {
@Bean
fun permissionGrpcClient(): PermissionsServiceGrpc.PermissionsServiceBlockingStub {
val channel = io.grpc.ManagedChannelBuilder
.forTarget("localhost:50051") // SpiceDB 엔드포인트
.usePlaintext()
.build()
return PermissionsServiceGrpc
.newBlockingStub(channel)
.withCallCredentials(BearerToken("wolfdesk")) // GRPC_PRESHARED_KEY 값과 동일한 값 입력
}
}
인터페이스 정의
라이브러리에서 제공하는 gRPC 클라이언트를 직접 사용할 수도 있지만,
- proto 모델을 그대로 사용하면 코드의 가독성이 저하될 수 있음
- gRPC 메시지 구조가 변경될 경우, 애플리케이션에서 직접적인 영향을 받을 수 있음
이를 해결하기 위해 애플리케이션 환경에 맞춘 인터페이스를 별도로 설계하였다.
/**
* 권한 등록 및 검증을 수행하는 RBAC 클라이언트
*/
interface RbacClient {
/**
* 특정 대상이 지정된 리소스에 대해 특정 권한을 가지고 있는지 확인
*
* @param resource 권한을 확인할 리소스 객체
* @param subject 권한을 확인할 대상 객체
* @param permission 확인할 권한 유형 객체
*
* @return 대상이 해당 리소스에 대해 지정된 권한을 가지고 있는지 여부를 반환
*/
fun hasPermission(
resource: RbacResource,
subject: RbacSubject,
permission: Permission
): Boolean
/**
* 리소스에 접근 가능한 대상을 조회
*
* @param resource 리소스 객체
* @param subjectType 대상 유형
* @param permission 확인할 권한 유형
*
* @return 특정 리소스에 접근 가능한 대상 목록 반환
*/
fun getAccessibleSubjects(
resource: RbacResource,
subjectType: SubjectType,
permission: Permission,
): List<SubjectResponse>
/**
* 대상이 접근 가능한 리소스를 조회
*
* @param subject 대상 객체
* @param resourceType 리소스 유형
* @param permission 확인할 권한 유형
*
* @return 특정 리소스에 접근 가능한 대상 목록 반환
*/
fun getAccessibleResources(
subject: RbacSubject,
resourceType: ResourceType,
permission: Permission,
): List<ResourceResponse>
/**
* 리소스와 대상 간 관계를 설정
*
* @param resource 리소스 객체
* @param relation 관계
* @param subject 대상 객체
*/
fun setRelationShip(
resource: RbacResource,
relation: RelationType,
subject: RbacSubject,
)
/**
* 관계 설정 벌크 요청
*
* @param requests 관계 요청 객체
*/
fun setRelationShips(requests: List<RelationShipRequest>)
}
- hasPermission: 특정 대상이 특정 리소스에 대해 권한을 가졌는지 확인
- eg. admin 사용자(대상)는 1000번 티켓(리소스)을 읽을 수 있는가?
- getAccessibleSubjects: 특정 리소스에 접근 가능한 대상 목록 조회
- eg. 1000번 티켓(리소스)을 사용자(대상) 중 누가 수정할 수 있는가?
- getAccessibleResources: 특정 대상이 접근 가능한 리소스 목록 조회
- eg. admin 사용자(대상)가 어떤 티켓(리소스)들을 읽을 수 있는가?
- setRelationShip: 특정 리소스와 대상 간의 관계 설정
- eg. 1000번 티켓(리소스)의 주인(관계)은 admin(대상)이다.
- setRelationShips: 여러 관계를 한 번에 설정하는 벌크 요청 지원
- eg. 1000번 티켓의 주인은 admin이고, 1000번 티켓의 그룹은 super_team이다.
예시를 보면 쉽게 이해할 수 있을거라고 생각한다.
인터페이스에 사용되는 모델
권한 관리 전용 데이터베이스 SpiceDB 소개
애플리케이션에서 권한 관리는 보안의 핵심 요소 중 하나다.예를 들어, 새로운 리소스를 생성할 때 누가 이 리소스를 소유하는지, 누가 읽거나 수정할 수 있는지를 명확하게 정의하고 검증해야
noose.tistory.com
앞선 소개 게시글에 소개한 스키마를 위한 데이터들이다.
스키마에 맞게 타입을 정의해 놓는다면 다른 모듈에서 요청하기 쉬운 구조가 된다.
enum class ResourceType(
val value: String
) {
TICKET("ticket"),
;
companion object {
fun from(value: String): ResourceType {
return entries.first { it.value == value }
}
}
}
enum class SubjectType(
val value: String
) {
MEMBER("member"),
GROUP("group"),
;
companion object {
fun from(value: String): SubjectType {
return entries.first { it.value == value }
}
}
}
enum class Permission(
val value: String
) {
WRITE("write"),
READ("read"),
;
companion object {
fun from(value: String): Permission {
return entries.first { it.value == value }
}
}
}
enum class RelationType(
val value: String,
) {
GROUP_HOST("host"),
GROUP_PARENT("parent"),
GROUP_MEMBER("member"),
TICKET_OWNER("owner"),
TICKET_GROUP("group"),
}
data class RbacResource(
val id: String,
val type: ResourceType
)
data class RbacSubject(
val id: String,
val type: SubjectType
)
data class SubjectResponse(
val id: String,
val type: SubjectType,
)
data class ResourceResponse(
val id: String,
val type: ResourceType,
)
구현
위 인터페이스에 맞게 구현한 컴포넌트이다.
@Component
class SpiceDbClient(
private val client: PermissionsServiceGrpc.PermissionsServiceBlockingStub, // Bean 주입
) : RbacClient {
override fun hasPermission(
resource: RbacResource,
subject: RbacSubject,
permission: Permission,
): Boolean {
val request = CheckPermissionRequest.newBuilder()
.setResource(createResource(resource.type, resource.id))
.setSubject(createSubject(subject.type, subject.id))
.setPermission(permission.value)
.build()
val response = client.checkPermission(request)
return response.permissionship == PERMISSIONSHIP_HAS_PERMISSION
}
override fun getAccessibleSubjects(
resource: RbacResource,
subjectType: SubjectType,
permission: Permission
): List<SubjectResponse> {
val request = LookupSubjectsRequest.newBuilder()
.setResource(createResource(resource.type, resource.id))
.setSubjectObjectType(subjectType.value)
.setPermission(permission.value)
.build()
return client.lookupSubjects(request).asSequence()
.map {
SubjectResponse(
id = it.subject.subjectObjectId,
type = subjectType,
)
}.toList()
}
override fun getAccessibleResources(
subject: RbacSubject,
resourceType: ResourceType,
permission: Permission
): List<ResourceResponse> {
val request = LookupResourcesRequest.newBuilder()
.setSubject(createSubject(subject.type, subject.id))
.setResourceObjectType(resourceType.value)
.setPermission(permission.value)
.build()
return client.lookupResources(request).asSequence()
.map {
ResourceResponse(
id = it.resourceObjectId,
type = resourceType,
)
}.toList()
}
override fun setRelationShip(
resource: RbacResource,
relation: RelationType,
subject: RbacSubject,
) {
val updateRelation = toUpdateRelationShip(resource, relation, subject)
val relationshipRequest= WriteRelationshipsRequest.newBuilder()
.addUpdates(updateRelation)
.build()
client.writeRelationships(relationshipRequest)
}
override fun setRelationShips(
requests: List<RelationShipRequest>,
) {
val updates = requests.map { toUpdateRelationShip(it.resource, it.relation, it.subject) }
val relationshipRequest = WriteRelationshipsRequest.newBuilder()
.addAllUpdates(updates)
.build()
client.writeRelationships(relationshipRequest)
}
private fun toUpdateRelationShip(
resource: RbacResource,
relation: RelationType,
subject: RbacSubject,
): RelationshipUpdate {
val requestResource = createResource(resource.type, resource.id)
val requestSubject = createSubject(subject.type, subject.id)
return RelationshipUpdate.newBuilder()
.setRelationship(createRelationShip(requestResource, relation, requestSubject))
.setOperation(RelationshipUpdate.Operation.OPERATION_CREATE)
.build()
}
// 리소스 생성 편의 함수
private fun createResource(
resource: ResourceType,
id: String,
): ObjectReference {
return ObjectReference.newBuilder()
.setObjectType(resource.value)
.setObjectId(id)
.build()
}
// 서브젝트 생성 편의 함수
private fun createSubject(
subject: SubjectType,
id: String,
): SubjectReference {
return SubjectReference.newBuilder()
.setObject(
ObjectReference.newBuilder()
.setObjectType(subject.value)
.setObjectId(id)
.build()
).build()
}
// 관계 생성 편의 함수
private fun createRelationShip(
resource: ObjectReference,
relation: RelationType,
subject: SubjectReference,
): Relationship {
return Relationship.newBuilder()
.setResource(resource)
.setRelation(relation.value)
.setSubject(subject)
.build()
}
}
실 사용 예제
조회
조회하기 전 권한을 체크하고 권한이 없다면 예외를 던진다.
@Transactional(readOnly = true)
fun getTicket(ticketId: Long, principalId: Long): TicketResponse {
val resource = RbacResource(ticketId, TICKET)
val subject = RbacSubject(principalId, MEMBER)
val hasPermission = rbacClient.hasPermission(resource, subject, READ)
check(hasPermission) { "읽기 권한이 없습니다." }
return TicketResponse(..)
}
관계 맺기
@Transactional
fun issueTicket(command: TicketCreateCommand) {
// 티켓 Entity 생성
ticketRepository.save(ticket)
val resource = RbacResource(tieckt.id, TICKET)
val relation = TICKET_OWNER
val subject = RbacSubject(principalId, MEMBER)
rbacClient.setRelationShip(resource, TICKET_OWNER, subject) // 커밋 직전 호출
}
SpiceDB는 별도의 데이터 저장소를 사용하므로 애플리케이션의 DB 트랜잭션과 묶여있지 않다.
따라서 DB 커밋 직전에 권한 관계 저장을 요청해야 일관성이 보장된다.
그 이유는 다음과 같다.

트랜잭션이 묶여있지 않으므로 위 문제가 발생하면 DB 에러 시 SpiceDB 데이터를 재수정해야한다.

위 그림과 같이 배치하면 데이터 저장이 실패되면 예외 처리로 로직이 중단되어 관계가 적용되지 않고,
관계 저장이 실패된다하여도 예외를 던져 스프링 롤백 정책에 의해 저장된 데이터를 롤백시킨다.
물론 이 방식이 아닌 보상 트랜잭션 같은 방식으로 처리해도 좋지만, 현재 상황에서는 이 방식이 적합하다고 판단하여 사용하고 있다.
✅ 마무리
이번 구조를 기반으로 애플리케이션에서 RBAC 시스템을 효율적으로 활용할 수 있게 되었다.
예제에서는 데이터 접근 시나리오를 다루었지만, 이를 확장하면 API 인가 처리 같은 부분도 충분히 해결할 수 있을 것이다.
생각보다 SpiceDB의 공식 문서가 잘 정리되어 있어 적용 과정에서 큰 어려움은 없을거라고 생각했지만..
국내 레퍼런스가 부족해 초기 학습 과정에서 다소 어려움이 있었다.
문제를 해결할 때 기존 RDB 모델에서만 답을 찾기보다는, 적절한 솔루션을 도입하고 적용해 보는 도전도 좋은 경험이라고 생각한다.