recipe deletion failed: {"message":"Forbidden"}
RCA: recipe deletion failed: {"message":"Forbidden"}
Overview#
What Happened#
2026-05-08 07:06:37 UTC에 cupixworks-api 서비스(us-west-2)에서 admin 사용자가 팀 1173의 assigned_customer_success_managers 그룹에서 사용자(ID: 751)를 제거하는 과정에서, 해당 사용자의 notification recipe 삭제가 3건 모두 실패했다. notification-service의 API Gateway authorizer가 user 751의 api_token을 거부하여 {"message":"Forbidden"} 응답을 반환했다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::NotificationService |
| exception.message | recipe deletion failed: {"message":"Forbidden"} |
| top_frame | lib/cupix/notification_service.rb:65 |
| env | production, us-west-2 |
| deploy | production-us-west-2-20260508T0527Z0-cc7eab2d-cupixworks |
Timeline#
- 07:06:37Z — Admin user (daniel.kim@cupix.com)가
PUT /api/v1/admin/teams/1173/groups/assigned_customer_success_managers/remove_users호출 - 07:06:37Z — User 751의 cached permissions flush 실행
- 07:06:37Z —
UserRecipeCleaner가 3개 recipe 삭제 시도 (facility_new_project,record_processing_completed,record_preview_ready) — 모두 Forbidden으로 실패 - 07:06:37Z — 전체 HTTP 요청은 200으로 정상 완료 (recipe 삭제 실패는 비차단 부작용)
Error Log#
recipe deletion failed: {"message":"Forbidden"}
Impact#
- Service:
cupixworks-api - 발생 횟수: 3
- 최초 발생: 2026-05-08T07:06:37.639Z
- 최근 발생: 2026-05-08T07:06:37.639Z
- 사용자 영향: 없음. recipe 삭제 실패는 비차단 side-effect로, 사용자 그룹 제거 자체는 정상 완료됨. 다만 user 751의 notification recipe가 orphan 상태로 남아 불필요한 알림을 계속 수신할 수 있음.
Root Cause Summary#
Admin 사용자가 CSM 그룹에서 user 751을 제거할 때, UserRecipeCleaner PubSub subscriber가 Cupix::NotificationService#delete_user_recipe를 호출한다. 이 메서드는 user 751의 api_token을 x-cupix-auth 헤더로 사용하여 notification-service에 인증하는데, notification-service의 Lambda authorizer가 이 토큰으로 Tesla API /api/v1/me를 호출한 결과 비정상 응답(non-200)을 받아 { isAuthorized: false }를 반환했다. AWS API Gateway는 이를 403 {"message":"Forbidden"}으로 변환하여 응답했다.
근본 원인은 user 751의 api_token이 유효하지 않거나, 해당 토큰에 연결된 session이 만료/비활성 상태라 Tesla API /api/v1/me 호출이 실패한 것이다. NotificationService는 항상 대상 사용자의 토큰으로 인증하므로, 토큰이 무효한 사용자에 대해서는 recipe CRUD가 불가능한 구조적 결함이 있다.
Technical Analysis#
Code Path#
- Entry point:
app/controllers/api/v1/admin/groups_controller.rb:38—remove_usersaction app/repositories/admin/group_repository.rb:55-57— 각 사용자에 대해@model.users.destroy(user)호출
users.each do |user|
@model.users.destroy(user)
end
GroupedUserdestroy 시 PubSubdestroyed이벤트 발행lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:9-10—destroyed이벤트 수신
def destroyed(event)
send("_delete_recipes_for_#{self.namespace}", event.payload[:model])
end
lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:15-21— CSM 그룹인 경우 recipe 삭제 실행
def _delete_recipes(opts = {})
%w[
record_preview_ready
record_processing_completed
facility_new_project
].each do |recipe_name|
Cupix::NotificationService.new(user: opts[:user]).delete_user_recipe(recipe_name, team_id: opts[:team_id], facility_key: opts[:facility_key])
end
end
lib/cupix/notification_service.rb:48-68—delete_user_recipe메서드. 먼저find_email_recipeGET 호출 후 DELETE 호출
def delete_user_recipe(recipe_name, team_id: nil, facility_key: nil)
return if @service_url.nil?
recipe = find_email_recipe(recipe_name, facility_key)
return if recipe.nil?
response = RestClient.delete(
"#{@service_url}/api/recipes/v2/#{recipe['id']}",
{
content_type: :json,
'x-cupix-auth': @user.api_token
}
)
Cupix::Logger.info("recipe deleted: #{response.body}", class: self.class.name, function: __method__, recipe_name: recipe_name, facility_key: facility_key)
rescue RestClient::ExceptionWithResponse => e
Cupix::Logger.error("recipe deletion failed: #{e.response.body}", class: self.class.name, function: __method__, recipe_name: recipe_name, facility_key: facility_key)
false
end
- Failure point:
lib/cupix/notification_service.rb:127-133—find_email_recipe의 GET 요청이 user 751의api_token으로 인증 시도
response = RestClient.get(
"#{@service_url}/api/recipes/v2?#{params.to_query}",
{
content_type: :json,
'x-cupix-auth': @user.api_token
}
)
-
find_email_recipe에는 자체 rescue 블록이 없으므로, GET 요청에서 발생한RestClient::ExceptionWithResponse(403)가delete_user_recipe의 rescue로 전파되어 "recipe deletion failed" 에러 로그가 기록됨. -
Downstream:
notification-service/src/lambda/authorizer.ts:11-43— API Gateway Lambda authorizer
const response = await CPUtils.fetchWithRetry(
`https://api.${DOMAIN_NAME}/api/v1/me?fields=id,firstname,lastname,email,team`,
{
headers: {
'x-cupix-auth': token
}
}
);
console.log(`Tesla API responded in ${Date.now() - startTime}ms`);
console.log(`response: ${JSON.stringify(response)}`);
if (response.status !== 200) {
throw new Error('Unauthorized; permission denied');
}
- 기대 동작: user 751의
api_token으로 Tesla/api/v1/me호출 → 200 응답 → 인증 성공 → recipe 조회/삭제 진행 - 실제 동작: user 751의
api_token으로 Tesla/api/v1/me호출 → non-200 응답 → authorizer가{ isAuthorized: false }반환 → API Gateway가403 {"message":"Forbidden"}응답
Log Evidence#
사용한 Datadog 쿼리:
service:cupixworks-api "recipe deletion failed" @environment:production
service:cupixworks-api @request_id:add3120c-c804-4bfe-a1a9-b07abd237f4d
에러 로그 (3건, 동일 request_id):
{
"message": "recipe deletion failed: {\"message\":\"Forbidden\"}",
"class": "Cupix::NotificationService",
"function": "delete_user_recipe",
"recipe_name": "facility_new_project",
"host": "ip-10-1-80-134.us-west-2.compute.internal",
"request_id": "add3120c-c804-4bfe-a1a9-b07abd237f4d",
"timestamp": "2026-05-08T07:06:37.639Z"
}
동일 request 내 context 로그:
[INFO] Flush cached permissions for User 751 (function: flush_cached_permissions, class: Module)
[INFO] Deleting cached_permission on user 751 (function: flush_cached_permission, class: User)
관련 요청 정보:
[200] PUT /api/v1/admin/teams/1173/groups/assigned_customer_success_managers/remove_users
Controller: Api::V1::Admin::GroupsController#remove_users
User: daniel.kim@cupix.com (id: 43867)
User-Agent: python-httpx/0.28.1
Duration: 3680.01ms
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | User 751의 api_token이 무효하거나 session이 만료됨 |
API Gateway authorizer가 Tesla /api/v1/me 호출 시 non-200을 받아 isAuthorized: false 반환 (authorizer.ts:31-32). 응답이 {"message":"Forbidden"} — AWS API Gateway의 표준 unauthorized 응답 형식. |
— | Confirmed |
| H2 | notification-service 자체의 recipe 권한 검사 실패 | — | notification-service의 proxy.ts:100-103은 ItemNotFoundException을 403으로 매핑하지만 그 body는 {"message":"Item not found"}이지 {"message":"Forbidden"}이 아님. {"message":"Forbidden"}은 API Gateway authorizer 거부의 표준 응답. |
Rejected |
| H3 | notification-service URL이 잘못되어 다른 서비스가 403 반환 | — | 다른 recipe 관련 호출(find_email_recipe)이 동일 시간대에 다른 사용자에 대해서는 정상 동작 중 (info 로그에서 "recipe found: null" 확인). URL 자체는 정상. |
Rejected |
| H4 | fetchWithRetry timeout(1초)으로 인한 실패 |
authorizer의 timeout이 1000ms로 매우 짧아 Tesla API 지연 시 실패 가능 | timeout 시 abort error가 발생하며, 이는 catch에서 isAuthorized: false로 처리되지만, 동일 시간대 다른 요청에서는 authorizer가 정상 동작하므로 network 문제는 아님. 3건이 모두 동일하게 실패한 것은 token 자체의 문제를 시사. |
Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
lib/cupix/notification_service.rb:48-68:delete_user_recipe에서find_email_recipe호출을 별도 rescue로 감싸거나,find_email_recipe메서드 자체에 rescue 블록을 추가하여 인증 실패 시 에러 대신 nil을 반환하도록 처리. 이렇게 하면 인증 실패가 "recipe deletion failed"가 아닌 적절한 메시지로 로깅됨.- 에러 레벨을
warn으로 낮출 것을 권장. recipe 삭제 실패는 비차단 side-effect이며 메인 비즈니스 로직(사용자 그룹 제거)에 영향을 주지 않으므로error레벨은 과도함.
단기 개선 (1주 이내)#
NotificationService가 대상 사용자의api_token대신 서비스 계정 토큰 또는 admin token으로 인증하도록 변경. 현재 구조에서는 대상 사용자의 토큰이 무효한 경우 해당 사용자의 recipe를 관리할 수 없는 구조적 한계가 있음.- notification-service authorizer에 service-to-service 인증 방식(예: API key 또는 IAM role 기반)을 추가하여, 서버 간 내부 호출이 개별 사용자 토큰에 의존하지 않도록 개선.
장기 개선 (재발 방지)#
- notification-service의 recipe lifecycle 관리를 이벤트 기반으로 전환. cupixworks-api가 직접 HTTP 호출하는 대신, 이벤트(SNS/SQS)를 발행하고 notification-service가 소비하여 자체 권한으로 recipe를 정리하는 방식.
api_token기반 인증의 취약점(토큰 만료, 사용자 비활성화 등) 대응을 위한 token refresh 또는 fallback 메커니즘 검토.
Monitoring#
- Datadog 쿼리로 recipe deletion 실패 추이 모니터링:
service:cupixworks-api "recipe deletion failed" @environment:production
- notification-service authorizer 실패 모니터링:
service:notification-service "Error authorizing" @environment:production
- user 751의 token 상태 확인 후, 필요 시 token refresh 수동 실행
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard
- 사용자 영향 없음 (메인 작업은 정상 완료). orphan recipe로 인한 불필요 알림 가능성은 있으나 즉각적 장애는 아님.