CAP for Node

CAP 인증 미들웨어 안 걸면 큰일 #shorts #SAP #CAPforNode

▶ YouTube에서 보기

왜 CAP Node.js 인증 미들웨어를 다시 봐야 하는가

CAP(Cloud Application Programming Model) Node.js 서비스는 기본적으로 @(requires:'authenticated-user') 같은 CDS 어노테이션만으로도 인증이 걸린 것처럼 보인다. 문제는 커스텀 미들웨어, 커스텀 라우터, 배치 잡용 기술 사용자(technical user) 우회 로직이 섞이는 순간이다. 실무에서는 OData 표준 엔드포인트는 XSUAA로 잘 막혀 있는데, 나중에 붙인 REST 커스텀 핸들러나 헬스체크용 라우터가 인증 체크 없이 노출되는 사고가 반복된다. 이 글에서는 가상의 지출 결의 승인 서비스 ExpenseApprovalService를 예로 들어, CAP Node.js에서 인증 미들웨어가 실제로 어떻게 동작하는지, 그리고 실무에서 자주 놓치는 지점을 코드로 짚는다.

미리 알아둘 개념

CAP Node.js는 내부적으로 Express 위에서 동작하며, cds.middlewares 파이프라인이 인증/컨텍스트 바인딩을 담당한다. XSUAA를 인증 전략으로 쓸 경우 passport 기반 strategy가 JWT를 검증해 req.user(CAP에서는 cds.context.user)를 채운다. 서비스 정의의 @requires, 엔티티/액션 단위의 @restrict 어노테이션은 이 인증 결과를 바탕으로 권한을 판단한다. Express 미들웨어 개념(순서, next() 호출, 라우터 마운트 지점)을 알고 있으면 이해가 빠르다.

환경과 준비물

예제는 @sap/cds 7.x, @sap/xssec 3.x, Node.js 18 이상 기준이다. package.jsoncds.requires.auth.kindxsuaa로 지정하고, xs-security.json에 스코프와 역할 템플릿을 정의한 뒤 BTP Cloud Foundry에 xsuaa 서비스 인스턴스를 바인딩한다. 로컬 개발 시에는 mocked auth kind로 대체할 수 있지만, 이 글의 핵심은 mocked 환경에서 통과한 로직이 실제 XSUAA 환경에서 그대로 안전하다고 착각하는 데서 사고가 난다는 점이다.

핵심 개념 — CAP 인증 파이프라인이 실제로 도는 순서

CAP 서버가 부팅되면 cds.middlewares.before 단계에서 인증 미들웨어가 등록되고, 그 다음 CDS가 생성한 서비스 라우터가 마운트된다. 여기서 자주 생기는 착각이 두 가지다. 첫째, @requires는 서비스 전체가 아니라 서비스 정의 시점의 진입점에만 적용되며, 서비스 안에서 별도로 만든 커스텀 Express 라우터(srv.use('/custom', router))는 이 어노테이션의 보호를 받지 않는다. 둘째, 기술 사용자(technical user) 우회는 배치 잡을 위해 만든 예외 경로인데, 이 경로가 조건 없이 살아있으면 일반 사용자도 같은 헤더 조작으로 우회할 수 있다.

// srv/expense-approval-service.cds
service ExpenseApprovalService @(requires: 'authenticated-user') {

  entity ExpenseApprovalRequest {
    key ID       : UUID;
    amount       : Decimal(15,2);
    tenantCode   : String(10);
    status       : String(20);
    requesterId  : String(40);
  }

  entity ApprovalAuditLog {
    key ID        : UUID;
    requestID     : UUID;
    actorId       : String(40);
    action        : String(20);
    createdAt     : Timestamp;
  }

  action approve(requestID: UUID) returns String
    @(requires: ['ApprovalManager']);

  action escalate(requestID: UUID) returns String
    @(requires: ['ApprovalManager', 'ApprovalDelegate']);
}

실전 코드 1단계 — 기본 인증 미들웨어 등록

가장 단순한 형태는 CDS가 자동 생성하는 인증을 그대로 신뢰하는 것이다. @requires: 'authenticated-user'가 붙어 있으면 XSUAA 토큰이 없는 요청은 401로 막힌다. 여기까지는 대부분 문제가 없다.

// srv/expense-approval-service.js
const cds = require('@sap/cds')

module.exports = cds.service.impl(async function () {
  const { ExpenseApprovalRequest } = this.entities

  this.on('approve', async (req) => {
    const { requestID } = req.data
    const user = req.user
    // 여기서부터는 authenticated-user 를 통과했다는 가정 하에 로직이 진행된다
    await UPDATE(ExpenseApprovalRequest, requestID).with({ status: 'APPROVED' })
    return `approved by ${user.id}`
  })
})

실전 코드 2단계 — 실무 시나리오: 커스텀 라우터가 인증을 우회하는 경우

문제는 여기서 시작된다. 알림 발송용 웹훅이나 헬스체크 엔드포인트를 CAP 서비스 안에 추가하면서, 개발 편의를 위해 인증 체크를 빼먹는 경우다.

// srv/custom-hooks.js — 위험한 예시
const cds = require('@sap/cds')

cds.on('bootstrap', (app) => {
  // 주의: 이 라우터는 @requires 보호를 받지 않는다
  app.post('/expense/notify-webhook', (req, res) => {
    console.log('external notify:', req.body)
    res.send('ok')
  })
})

위 코드는 외부 알림 콜백을 받기 위한 목적이었지만, 인증이 전혀 걸려 있지 않아 누구나 ExpenseApprovalRequest 상태를 조작하는 임의 요청을 보낼 수 있는 통로가 됐다. 실제 사고 사례에서는 이런 웹훅 경로가 6개월 이상 방치되다가 보안 점검에서 발견됐다. 올바른 접근은 다음과 같이 명시적으로 미들웨어를 삽입하는 것이다.

// srv/custom-hooks.js — 개선 버전
const cds = require('@sap/cds')

function verifyWebhookSecret(req, res, next) {
  const secret = req.headers['x-webhook-secret']
  if (secret !== process.env.WEBHOOK_SECRET) {
    return res.status(403).send('forbidden')
  }
  next()
}

cds.on('bootstrap', (app) => {
  app.post('/expense/notify-webhook', verifyWebhookSecret, (req, res) => {
    console.log('external notify:', req.body)
    res.send('ok')
  })
})

실전 코드 3단계 — 프로덕션: 테넌트 격리와 감사 로그까지

멀티테넌트 환경에서는 인증만으로 부족하다. 같은 XSUAA 서브스크라이버라도 tenantCode 검증이 빠지면 A사 사용자가 B사 지출 결의 데이터를 조회하는 사고로 이어질 수 있다. 프로덕션 단계에서는 인증 미들웨어 다음에 테넌트 바인딩 검증과 감사 로그 기록까지 넣어야 한다.

// srv/expense-approval-service.js — 프로덕션 강화
const cds = require('@sap/cds')

module.exports = cds.service.impl(async function () {
  const { ExpenseApprovalRequest, ApprovalAuditLog } = this.entities

  this.before('approve', async (req) => {
    const request = await SELECT.one.from(ExpenseApprovalRequest)
      .where({ ID: req.data.requestID })

    // 테넌트 격리 검증: 인증만 통과했다고 안심하면 안 된다
    if (request.tenantCode !== req.user.tenant) {
      req.reject(403, 'cross-tenant access denied')
    }
  })

  this.on('approve', async (req) => {
    const { requestID } = req.data
    await UPDATE(ExpenseApprovalRequest, requestID).with({ status: 'APPROVED' })

    await INSERT.into(ApprovalAuditLog).entries({
      ID: cds.utils.uuid(),
      requestID,
      actorId: req.user.id,
      action: 'APPROVE',
      createdAt: new Date()
    })

    return `approved by ${req.user.id}`
  })
})

흔한 실수와 트러블슈팅

Q1. mocked auth로는 통과했는데 XSUAA 붙이니 401이 난다. xs-security.json의 스코프 이름과 CDS @requires에 쓴 역할 이름이 정확히 매핑됐는지 확인한다. role template과 role collection이 BTP 서브계정에 실제로 할당됐는지도 함께 봐야 한다.

Q2. 커스텀 라우터에 인증을 걸었는데도 뚫린다. cds.on('bootstrap')에서 라우터를 등록하는 시점이 CAP 기본 인증 미들웨어보다 먼저인지 확인한다. 순서가 꼬이면 미들웨어가 무효화된 것처럼 동작한다.

Q3. 기술 사용자 우회 로직을 개발 중에만 켜뒀는데 프로덕션에 남아있었다. process.env.NODE_ENV 분기로 우회를 허용하는 코드는 배포 파이프라인에서 환경변수 값이 예상과 다르게 설정되는 순간 그대로 살아난다. 우회 로직 자체를 코드에서 완전히 제거하고, 로컬 테스트는 별도의 mock 인증 프로파일로 분리하는 편이 안전하다.

Q4. 감사 로그를 남겼는데 actorId가 비어 있다. 배치 잡이나 이벤트 메시징 경로로 들어온 요청은 req.user가 시스템 사용자로 채워지는 경우가 있다. 어떤 사용자 컨텍스트로 실행됐는지 로그에 원본 트리거 정보까지 남기는 것이 트러블슈팅에 도움이 된다.

확장해볼 만한 아이디어

여기서 다룬 ExpenseApprovalService 패턴은 승인/위임(ApprovalDelegate) 워크플로가 있는 다른 도메인(구매, 휴가, 자산 반출)에도 그대로 적용할 수 있다. 인증 미들웨어 점검 체크리스트를 CI 파이프라인에 넣어, 커스텀 라우터가 추가될 때마다 @requires 누락 여부를 자동으로 탐지하는 lint 규칙을 만들어보는 것도 실무에서 효과적인 다음 시도다. 또한 감사 로그를 별도 서비스로 분리해 SIEM 연동까지 확장하면 보안 사고 대응 속도를 크게 줄일 수 있다.

정리

CAP Node.js의 @requires 어노테이션은 강력하지만 서비스 정의 범위 밖의 커스텀 라우터, 웹훅, 기술 사용자 우회 로직까지 자동으로 지켜주지 않는다. 배포 전 체크포인트로 (1) 모든 커스텀 app.use/라우터에 명시적 인증 미들웨어가 걸려 있는지, (2) 테넌트 격리 검증이 인증과 별도로 존재하는지, (3) 개발용 우회 로직이 프로덕션 빌드에서 완전히 제거됐는지 세 가지만 확인해도 대부분의 사고를 막을 수 있다.

댓글 0

아직 댓글이 없습니다.