Forbidden
RCA: Forbidden
Overview#
What Happened#
매일 01:00 UTC에 실행되는 Cupix::Cron::EditingPermission.revoke_expired_editor_permissions 크론잡이 만료된 편집 권한을 해제하면서, PubSub 이벤트를 통해 notification-service에 recipe 삭제 및 구독 해제 요청을 보내는 과정에서 인증 실패가 발생하고 있다. notification-service의 Lambda authorizer가 사용자의 api_token으로 Tesla API에 인증을 요청하지만 non-200 응답을 받아 API Gateway가 {"message":"Forbidden"}을 반환한다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Cupix::NotificationService |
| exception.message | recipe deletion failed: {"message":"Forbidden"} / Forbidden |
| top_frame | lib/cupix/notification_service.rb:65 |
| env | production, us-west-2 |
| deploy | production-us-west-2-2026052*T0100Z0-3e770a15-cupixworks |
Timeline#
- 2026-04-09 08:22 UTC — 최초 에러 발생
- 2026-05-23 01:01-01:04 UTC — 일별 burst 확인
- 2026-05-24 01:00-01:03 UTC — 일별 burst 확인 (44건, 11 facilities)
- 2026-05-27 — RCA 분석 수행
Error Log#
Forbidden
Impact#
- Service:
cupixworks-worker - 발생 횟수: 122 (collector 집계 기준; 실제 일별 ~200건)
- 최초 발생: 2026-04-09T08:22:14.767Z
- 최근 발생: 2026-05-24T01:03:11.042Z
notification subscription 및 email recipe 삭제 실패로 인해 권한이 제거된 사용자의 notification 리소스가 orphaned 상태로 남는다. 그러나 해당 사용자는 이미 facility 접근 권한이 없으므로 실질적 알림 발송은 이루어지지 않아 사용자 영향 없음.
Root Cause Summary#
매일 01:00 UTC에 실행되는 revoke_expired_editor_permissions 크론잡이 만료된 편집 권한을 가진 사용자들의 FacilityPermission을 삭제한다. 이 삭제 이벤트가 PubSub를 통해 UserRecipeCleaner와 SubscriptionCleaner 구독자를 트리거하고, 이들이 Cupix::NotificationService를 통해 notification-service API에 요청을 보낸다. 이때 각 사용자의 api_token을 인증 토큰으로 사용하는데, notification-service의 Lambda authorizer가 Tesla API /api/v1/me에 해당 토큰으로 인증을 시도할 때 실패하여 API Gateway가 {"message":"Forbidden"}을 반환한다. 영향받는 사용자들은 1개월 이상 비활성 상태인 편집자로, 이들의 api_token 기반 세션이 유효하지 않거나 인증이 거부되는 것이 원인이다. 동일 facility에서 한 사용자는 실패하고 다른 사용자는 성공하는 패턴이 user-level 문제임을 확증한다.
Technical Analysis#
Code Path#
- Entry point:
config/schedule.rb:175-178— 01:00 UTC daily cron
every '0 1 * * *' do # 01:00 UTC everyday
runner 'Cupix::Cron::EditingPermission.revoke_expired_editor_permissions'
runner 'Cupix::Cron::EditingPermission.revoke_expired_manager_permissions'
end
- Step 1: 크론잡이 만료된 편집 권한을 가진 사용자를 찾아
facility.unshare(user)호출
completed_pairs.pluck(:editor_id, :facility_id).each do |user_id, facility_id|
next if active_set.include?([user_id, facility_id])
last_updated = last_completed_map[[user_id, facility_id]]
next if last_updated.present? && last_updated > 1.month.ago
next unless permission_set.include?([user_id, facility_id])
facility = ::Facility.find_by(id: facility_id)
next if facility.nil?
user = ::User.find_by(id: user_id)
next if user.nil?
Cupix::Logger.info("Revoking expired editor permission: user=#{user_id}, facility=#{facility_id}",
class: name, function: __method__, module: 'Cupix::Cron')
facility.unshare(user)
count += 1
- Step 2:
facility.unshare(user)→remove_permission!(user)→FacilityPermission.destroy!
def remove_permission!(user_or_group)
_permission = permissions.find_by(
accessor: user_or_group
)
if _permission.present?
_permission.destroy!
Permissionable.flush_cached_permissions(user_or_group)
Permissionable.flush_cached_permissions_by_user(user_or_group, self)
_permission
else
raise Cupix::Errors::Entity.new(code: 'ENT10012', reason: 'Permission not found')
end
end
- Step 3:
FacilityPermission.destroy!triggers PubSubafter_destroy→SubscriptionCleaner.destroyed/UserRecipeCleaner.destroyed
def _delete_subscriptions_by_facility_permission(model)
return false unless model.accessor_type == ::User.name
user = model.accessor
facility_key = model.facility.key
_delete_subscription(user: user, facility_key: facility_key)
end
def _delete_subscription(opts = {})
Cupix::NotificationService.new(user: opts[:user]).unsubscribe(facility_key: opts[:facility_key])
end
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
- Step 4 (Failure point):
NotificationService가 사용자의api_token으로 notification-service API 호출
def unsubscribe(facility_key: nil)
return if @service_url.nil?
begin
response = Cupix::HttpClient.delete(
"#{@service_url}/api/v1/subscriptions?model_type=Facility&model_id=#{facility_key}",
{
content_type: :json,
'x-cupix-auth': @user.api_token
}
)
Cupix::Logger.info("subscription of #{@user.email} on Facility #{facility_key} deleted", class: self.class.name, function: __method__, facility_key: facility_key)
true
rescue RestClient::ExceptionWithResponse => e
Cupix::Logger.error(e.response, class: self.class.name, function: __method__, facility_key: facility_key)
false
end
end
- Step 5: notification-service Lambda authorizer에서 토큰 검증 실패 → API Gateway 403
const response = await CPUtils.fetchWithRetry(
`https://api.${DOMAIN_NAME}/api/v1/me?fields=id,firstname,lastname,email,team`,
{
headers: {
'x-cupix-auth': token
}
}
);
if (response.status !== 200) {
throw new Error('Unauthorized; permission denied');
}
// ...
} catch (error: any) {
console.log('Error authorizing: ', error.message);
return { isAuthorized: false };
}
- Tesla API 측 인증 검증 코드 — 세션 만료/비활성 시 non-200 응답:
def verify_api_token!(api_token)
user = ::User.find_by_api_token(api_token)
session =
if user.blank?
get_session_from_session_info(api_token)
else
user.default_session
end
raise Cupix::Errors::Unauthorized.new(code: 'AUTH10008', reason: "Invalid API token") if session.blank?
if session.present?
raise Cupix::Errors::Session.new(code: 'AUTH10031', reason: 'Session has expired') if session.state_expired?
raise Cupix::Errors::Session.new(code: 'AUTH10032', reason: 'Session has expired') if session.state_inactive?
raise Cupix::Errors::Session.new(code: 'AUTH10033', reason: 'Session has expired') if session.expires_at < DateTime.now
end
Log Evidence#
Datadog 쿼리:
service:cupixworks-worker status:error @environment:production "Forbidden" @class:Cupix\:\:NotificationService
두 가지 에러 패턴 확인:
delete_user_recipefunction — recipe 삭제 실패:
{
"message": "recipe deletion failed: {\"message\":\"Forbidden\"}",
"class": "Cupix::NotificationService",
"function": "delete_user_recipe",
"tenant": "cupix",
"facility_key": "6gagd6",
"recipe_name": "record_processing_completed",
"service_role": "worker",
"pid": "3347182",
"host": "ip-10-1-18-233.us-west-2.compute.internal"
}
unsubscribefunction — 구독 해제 실패:
{
"message": "Forbidden",
"class": "Cupix::NotificationService",
"function": "unsubscribe",
"tenant": "cupix",
"facility_key": "6gagd6",
"service_role": "worker",
"pid": "3347182",
"host": "ip-10-1-18-233.us-west-2.compute.internal"
}
시간/패턴 분석:
- 매일 01:00-01:04 UTC에 3-4분간 burst 발생 (크론잡 실행 시간과 일치)
- 2026-05-24 burst: 44건, 11개 고유 facility_key (
2kd1ox,4xgkn5,6gagd6,c4miml,co5wtb,hi3c2t,jgox4c,r1v2lj,r8iknt,u4tjy4,xz64od) - 각 facility당 4건 (3x delete_user_recipe + 1x unsubscribe)
recipe_name:record_processing_completed,facility_new_project,record_preview_ready- 동일 facility(
6gagd6)에서 한 사용자는 Forbidden 실패, 다른 사용자(digitexx+vu@cupix.io, user_id 44947)는 성공 — user-specific 문제 확증
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | 비활성 사용자의 api_token 세션이 expired/inactive 상태라 Tesla API 인증 실패 | 동일 facility에서 특정 사용자만 실패; verify_api_token!에서 세션 상태 검증 (AUTH10031/32/33); 크론 대상은 1개월+ 비활성 편집자 |
api_token 세션은 expires_at = 100.years.from_now로 생성됨 (직접 만료 가능성 낮음) |
Confirmed |
| H2 | notification-service 일시적 장애 또는 API Gateway 재배포 | 배포 직후 burst 패턴 | 동일 시간대에 다른 사용자는 성공; 여러 주에 걸쳐 반복 발생 (일시적이지 않음) | Rejected |
| H3 | facility-level 권한 문제 (notification-service 측에서 facility 접근 차단) | — | 동일 facility에서 다른 사용자는 성공; authorizer는 user 인증만 수행하며 facility-level 체크 없음 | Rejected |
| H4 | 사용자의 api_token이 null이거나 DB에서 삭제됨 | User.find_by_api_token nil 시 AUTH10008 발생 → non-200 |
before_create에서 api_token 자동 생성; 명시적 API 없이는 null 불가 |
Rejected |
최종 판단: 핵심 문제는 NotificationService가 사용자 본인의 api_token으로 notification-service에 인증하는 구조적 결함이다. 크론잡이 대상으로 하는 사용자는 1개월 이상 편집 활동이 없는 비활성 편집자이며, 이들의 세션이 inactive 또는 expired 상태로 전환되어 Tesla API 인증에 실패한다. 시스템 계정 대신 개별 사용자 토큰을 사용하는 설계가 이런 일괄 실패를 야기한다.
Fix Recommendation#
즉시 조치 (Critical)#
lib/cupix/notification_service.rb:110—unsubscribe의 에러 로그를warn으로 변경. 권한 해제 자체는 정상 진행되며, notification cleanup 실패는 사용자에게 직접적 영향 없음 (orphaned recipe은 발송 대상 facility 접근 불가로 실질적 이메일 발송 안됨).lib/cupix/notification_service.rb:65—delete_user_recipe도 동일하게warn으로 변경.
단기 개선 (1주 이내)#
NotificationService에서 개별 사용자의api_token대신 시스템 서비스 계정의 토큰을 사용하도록 변경. notification-service API가 admin 권한으로 recipe/subscription을 관리할 수 있는지 확인 필요.- 또는
SubscriptionCleaner와UserRecipeCleaner에서 사용자의 세션 활성 상태를 사전 확인하고, 인증 불가능한 사용자는 skip하는 guard clause 추가.
장기 개선 (재발 방지)#
- notification-service의 authorizer에서 Tesla API 호출 대신 내부 서비스 간 인증 (IAM role 기반, shared secret, 또는 service mesh)을 도입하여 사용자 토큰 의존성 제거.
- PubSub subscriber에서 외부 서비스 호출 실패가 주 작업(permission revocation)을 방해하지 않도록 비동기 큐 (별도 Sidekiq worker)로 분리하여 재시도 및 실패 격리.
Monitoring#
- 로그 레벨 변경 후 warn 레벨 모니터링:
service:cupixworks-worker status:warn @class:Cupix\:\:NotificationService "Forbidden"
- 일별 01:00 UTC 이후 5분간 위 쿼리 결과가 10건 이상이면 알림 설정 권장.
- notification-service 403 응답 비율 메트릭 추가 검토.
Risk Assessment#
- Risk level: low
- 예상 복잡도: standard
- 사유: 에러 자체는 권한 해제 프로세스의 부수 효과(notification recipe 정리)에서만 발생하며, 핵심 기능(권한 해제)은 정상 동작. 사용자 영향 없음 (이미 접근 권한이 해제된 facility의 알림 설정 정리 실패).