AshAuthentication을 이용하여 API 리소스 보호 구현하기 (추가)

전편에서 API Key를 통한 인증과 기본 권한 설정을 완료했다. 하지만 현재는 인증된 사용자라면 누구나 모든 티켓을 수정하고 삭제할 수 있는 상태다. 이번에는 자신이 생성한 리소스만 수정/삭제할 수 있도록 소유권 기반 권한을 구현해보자.

목표

  • 티켓 생성 시 자동으로 생성자(owner) 기록
  • 자신이 생성한 티켓만 삭제 가능
  • 다른 사람의 티켓 삭제 시도는 차단

Owner Relationship 추가

먼저 티켓 리소스에 소유자를 나타내는 relationship을 추가한다.

# lib/helpdesk/support/ticket.ex
defmodule Helpdesk.Support.Ticket do
  use Ash.Resource,
    domain: Helpdesk.Support,
    data_layer: AshPostgres.DataLayer,
    extensions: [AshJsonApi.Resource],
    authorizers: [Ash.Policy.Authorizer]

  # ... 기존 코드 ...

  relationships do
    belongs_to :representative, Helpdesk.Support.Representative  # 기존
    belongs_to :owner, Helpdesk.Accounts.User  # 추가
  end
end

relationship을 추가했으므로 마이그레이션이 필요하다.

mix ash.codegen "add owner relationship to tickets"
mix ash.migrate

생성자 자동 지정

티켓 생성 시 현재 인증된 사용자를 자동으로 owner로 설정하도록 한다.

actions do
  defaults [:read]

  create :open do
    accept [:subject]
    change relate_actor(:owner)  # 이 줄 추가
  end

  # ... 기존 액션들
end

relate_actor(:owner)는 현재 actor(인증된 사용자)를 owner relationship에 자동으로 연결한다.

삭제 액션 추가

actions do
  # ... 기존 액션들

  destroy :destroy do
    primary? true
  end
end

정책 설정

이제 권한 정책을 설정할 차례다. 읽기와 생성은 모든 인증된 사용자에게 허용하고, 수정과 삭제는 소유자만 가능하도록 한다.

policies do
  # 읽기와 생성은 모든 인증된 사용자
  policy action_type([:read, :create]) do
    authorize_if actor_present()
  end
  
  # 수정과 삭제는 소유자만
  policy action_type([:update, :destroy]) do
    authorize_if relates_to_actor_via(:owner)
  end
end

relates_to_actor_via(:owner)는 현재 사용자와 리소스의 owner가 일치하는지 확인한다.

API 라우트 추가

Domain 설정에 delete 엔드포인트를 추가한다.

# lib/helpdesk/support.ex
defmodule Helpdesk.Support do
  use Ash.Domain, extensions: [AshJsonApi.Domain]

  json_api do
    routes do
      base_route "/tickets", Helpdesk.Support.Ticket do
        get :read
        index :read
        post :open
        delete :destroy  # 이 줄 추가
      end
    end
  end

  resources do
    resource Helpdesk.Support.Ticket
  end
end

테스트

자신의 티켓 삭제는 성공한다.

# 티켓 생성
% curl -X POST http://localhost:4000/api/json/tickets \
  -H "Authorization: Bearer helpdesk_FlhUEreQrABNZ2XvyDIC5bUeJljjHiOGL9jeiwHPpjeMSmYosY4TupfzhIOF339E_ckbhkf" \
  -H "Content-Type: application/vnd.api+json" \
  -d '{
    "data": {
      "type": "ticket",
      "attributes": {
        "subject": "My test ticket"
      }
    }
  }'

# 응답에서 ID 확인 (예: abc-123-def)

# 자신의 티켓 삭제 (성공)
% curl -X DELETE http://localhost:4000/api/json/tickets/abc-123-def \
  -H "Authorization: Bearer helpdesk_FlhUEreQrABNZ2XvyDIC5bUeJljjHiOGL9jeiwHPpjeMSmYosY4TupfzhIOF339E_ckbhkf"

# 결과: 200 OK

다른 사용자의 티켓을 삭제하려고 하면 차단된다. 테스트를 위해 새 사용자와 API Key를 생성한다(방법은 전편 참조).

# 다른 사용자의 API Key로 삭제 시도
% curl -X DELETE http://localhost:4000/api/json/tickets/abc-123-def \
  -H "Authorization: Bearer helpdesk_NewUserApiKeyHere..."

# 결과: 403 Forbidden
{"errors":[{"code":"forbidden",...}]}

서버 로그를 보면 정책 평가 과정을 확인할 수 있다.

Policy Breakdown
  user: %{id: "62ff21cf-d1c4-4fd5-8af1-b4cbc160a4c1"}

  Policy | 🔎:

    condition: action.type in [:update, :destroy]

    authorize if: record.owner == actor | owner.id == "62ff21cf-d1c4-4fd5-8af1-b4cbc160a4c1" | ? | 🔎

SAT Solver statement: 

 "action.type in [:update, :destroy]" and
  (("action.type in [:update, :destroy]" and "record.owner == actor") or
     not "action.type in [:update, :destroy]")

동작 원리

티켓 생성 시 relate_actor(:owner)가 현재 인증된 사용자의 ID를 owner_id 컬럼에 저장한다. 삭제 요청이 들어오면 relates_to_actor_via(:owner) 정책이 ticket.owner_id == current_user.id를 확인하고, 일치하면 허용하고 다르면 차단한다.

주의사항

이미 존재하는 티켓들은 owner_id가 NULL이다. 필요하다면 relationship을 nullable로 설정할 수 있다.

belongs_to :owner, Helpdesk.Accounts.User do
  allow_nil? true
end

또는 기존 데이터에 대한 마이그레이션 스크립트를 작성해야 할 수도 있다.

마무리

Ash의 relationship과 정책 시스템을 활용하면 소유권 기반 권한 제어를 선언적으로 구현할 수 있다. relate_actor로 자동으로 소유자를 설정하고, relates_to_actor_via로 권한을 확인하는 것만으로 충분하다. 이 패턴은 삭제뿐 아니라 수정, 조회 등 모든 액션에 적용 가능하다.

댓글 남기기