ES /docs

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#

  1. 2026-04-09 08:22 UTC — 최초 에러 발생
  2. 2026-05-23 01:01-01:04 UTC — 일별 burst 확인
  3. 2026-05-24 01:00-01:03 UTC — 일별 burst 확인 (44건, 11 facilities)
  4. 2026-05-27 — RCA 분석 수행

Error Log#

Datadog Logs

text
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를 통해 UserRecipeCleanerSubscriptionCleaner 구독자를 트리거하고, 이들이 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
config/schedule.rb:175-178ruby
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) 호출
lib/cupix/cron/editing_permission.rb:45-62ruby
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!
app/models/concerns/permissionable.rb:81-96ruby
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 PubSub after_destroySubscriptionCleaner.destroyed / UserRecipeCleaner.destroyed
lib/cupix/pub_sub/subscribers/subscription_cleaner.rb:11-22ruby
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
lib/cupix/pub_sub/subscribers/user_recipe_cleaner.rb:34-41ruby
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 호출
lib/cupix/notification_service.rb:95-113ruby
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
applications/notification-service/src/lambda/authorizer.ts:19-43typescript
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 응답:
lib/cupix/auth/verification.rb:236-256ruby
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 쿼리:

text
service:cupixworks-worker status:error @environment:production "Forbidden" @class:Cupix\:\:NotificationService

두 가지 에러 패턴 확인:

  1. delete_user_recipe function — recipe 삭제 실패:
json
{
  "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"
}
  1. unsubscribe function — 구독 해제 실패:
json
{
  "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:110unsubscribe의 에러 로그를 warn으로 변경. 권한 해제 자체는 정상 진행되며, notification cleanup 실패는 사용자에게 직접적 영향 없음 (orphaned recipe은 발송 대상 facility 접근 불가로 실질적 이메일 발송 안됨).
  • lib/cupix/notification_service.rb:65delete_user_recipe도 동일하게 warn으로 변경.

단기 개선 (1주 이내)#

  • NotificationService에서 개별 사용자의 api_token 대신 시스템 서비스 계정의 토큰을 사용하도록 변경. notification-service API가 admin 권한으로 recipe/subscription을 관리할 수 있는지 확인 필요.
  • 또는 SubscriptionCleanerUserRecipeCleaner에서 사용자의 세션 활성 상태를 사전 확인하고, 인증 불가능한 사용자는 skip하는 guard clause 추가.

장기 개선 (재발 방지)#

  • notification-service의 authorizer에서 Tesla API 호출 대신 내부 서비스 간 인증 (IAM role 기반, shared secret, 또는 service mesh)을 도입하여 사용자 토큰 의존성 제거.
  • PubSub subscriber에서 외부 서비스 호출 실패가 주 작업(permission revocation)을 방해하지 않도록 비동기 큐 (별도 Sidekiq worker)로 분리하여 재시도 및 실패 격리.

Monitoring#

  • 로그 레벨 변경 후 warn 레벨 모니터링:
text
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의 알림 설정 정리 실패).