CAP for Node

@requires vs @restrict — CAP 보안 3단계 #shorts #SAP #CAPforNode

▶ YouTube에서 보기

📖 개요: 인증 없는 CAP 서비스가 위험한 이유

CAP(Cloud Application Programming Model) 서비스는 어노테이션 한 줄 없이도 배포가 됩니다. 문제는 바로 그 지점입니다. @requires가 없는 서비스는 BTP에 올라간 순간부터 OData 엔드포인트 전체가 열려 있는 상태가 될 수 있고, 실제로 라우트 URL만 알면 누구나 GET /odata/v4/sales/SalesOrders로 주문 데이터 전체를 긁어갈 수 있는 구성이 만들어집니다. 이 글에서는 SalesOrder 서비스를 예시로 인증·인가를 3단계(서비스 → 엔티티 → 필드)로 걸어 잠그는 과정을 다룹니다.

  • 인증 없이 배포된 CAP 서비스에서 실제로 발생하는 위협 시나리오 이해
  • @requires로 서비스 레벨 접근 차단 설정
  • @restrict의 grant / to / where 조합으로 엔티티·인스턴스 레벨 제어
  • 커스텀 핸들러로 민감 필드 마스킹 처리
  • xs-security.json과 XSUAA 바인딩으로 BTP 실전 구성 완성

📚 이 글을 읽기 전에 알아두면 좋은 것

CDS 모델링 기본 문법(entity, service, projection)과 CAP Node.js 프로젝트 구조(db / srv / app 폴더)를 이해하고 있다고 가정합니다. BTP Cloud Foundry 환경에 cf push 또는 MTA로 배포해 본 경험이 있다면 XSUAA 바인딩 부분이 훨씬 빠르게 읽힙니다. JWT 토큰의 기본 개념(발급자, scope 클레임)을 알면 동작 원리 섹션 이해에 도움이 됩니다.

🔧 환경 및 버전 준비

이 글의 예제는 다음 환경을 기준으로 작성했습니다.

항목버전 / 에디션
@sap/cds (CAP Node.js)8.x
Node.js20 LTS
@sap/cds-dk8.x (전역 설치)
SAP BTPCloud Foundry 환경, XSUAA(Authorization and Trust Management) 서비스
플랜xsuaa application 플랜

프로젝트에 인증 모듈을 추가하려면 아래 명령이 필요합니다. cds add xsuaa는 xs-security.json 초안과 package.json의 cds.requires.auth 설정을 함께 생성해 주므로 일반적으로 이 방식이 권장됩니다.

npm i @sap/xssec
cds add xsuaa --for production

💡 핵심 개념: CAP 인증·인가 동작 원리

CAP의 보안 모델은 건물 출입 통제에 비유하면 이해가 쉽습니다. 인증(Authentication)은 건물 정문에서 사원증을 확인하는 절차이고, 인가(Authorization)는 사원증에 붙은 권한으로 어느 층, 어느 방까지 들어갈 수 있는지 결정하는 절차입니다. CAP에서 정문 역할은 인증 미들웨어(XSUAA/IAS 기반 JWT 검증)가, 층·방 통제는 @requires@restrict 어노테이션이 담당합니다.

요청 하나가 처리되는 흐름은 다음과 같습니다.

  1. 클라이언트가 Authorization 헤더에 JWT를 담아 요청 → 인증 미들웨어가 토큰 서명·만료를 검증하고 req.user 객체를 구성
  2. 토큰의 scope 클레임이 xs-security.json의 role-template과 매핑되어 사용자 역할(role)로 변환
  3. CAP 런타임이 대상 서비스의 @requires를 평가 → 역할이 없으면 403, 토큰 자체가 없으면 401
  4. 엔티티의 @restrict를 평가 → 이벤트(READ/WRITE 등)와 역할, where 조건까지 통과해야 데이터 접근 허용

여기서 중요한 설계 포인트 두 가지가 있습니다. 첫째, 어노테이션이 없는 서비스는 "모두 허용"이 기본값입니다. CAP은 배포 자체를 막지 않기 때문에, 개발 중 mocked 인증으로 잘 돌아가던 서비스를 그대로 프로덕션에 올리면 인가 검사 없이 노출될 수 있습니다. 둘째, @restrict의 where 조건은 애플리케이션 레이어에서 필터링하는 것이 아니라 DB 쿼리의 WHERE 절로 내려가서(push-down) 실행됩니다. 즉 인스턴스 레벨 필터가 성능 저하 없이 동작하며, 페이징·카운트 결과에도 일관되게 반영됩니다.

흔히 발생하는 위협 시나리오를 정리하면 이렇습니다. 인증 없이 배포된 서비스는 (1) 주문·단가·마진 같은 영업 데이터가 무제한 조회되고, (2) OData의 $batch로 대량 변경 요청이 가능하며, (3) $metadata가 공개되어 데이터 모델 구조 자체가 공격자에게 지도처럼 제공됩니다. @requires 한 줄이 이 세 가지를 동시에 차단합니다.

💻 실전 예제: 3단계로 SalesOrder 서비스 잠그기

1단계 — 서비스 레벨 보호: 정문부터 닫기

가장 먼저 서비스 전체에 인증 요구를 선언합니다. 도메인 모델과 서비스 정의는 다음과 같습니다.

// db/schema.cds
namespace btpstacks.sales;

entity SalesOrders {
  key ID          : UUID;
  orderNo         : String(20);
  customerName    : String(100);
  totalAmount     : Decimal(15,2);
  marginRate      : Decimal(5,2);   // 민감 필드
  status          : String(10) default 'OPEN';
  createdBy       : String(255);
}
// srv/sales-service.cds
using { btpstacks.sales as db } from '../db/schema';

@requires: 'authenticated-user'
service SalesService {
  entity SalesOrders as projection on db.SalesOrders;
}

authenticated-user는 CAP이 제공하는 의사 역할(pseudo role)로, "유효한 토큰만 있으면 통과"를 의미합니다. 이 한 줄만으로 익명 접근이 401로 차단됩니다. 로컬 개발에서는 mock 사용자를 정의해 테스트합니다.

// package.json 일부 (development 프로파일)
{
  "cds": {
    "requires": {
      "[development]": {
        "auth": {
          "kind": "mocked",
          "users": {
            "viewer@btpstacks.com":  { "roles": ["SalesViewer"] },
            "manager@btpstacks.com": { "roles": ["SalesManager"] }
          }
        }
      },
      "[production]": { "auth": { "kind": "xsuaa" } }
    }
  }
}

2단계 — 엔티티 레벨 보호: 역할별 권한 분리와 로깅

실무에서는 "조회는 영업팀 전원, 수정은 매니저만, 일반 사원은 본인 주문만"처럼 세분화가 필요합니다. @restrict로 표현합니다.

@requires: 'authenticated-user'
service SalesService {
  entity SalesOrders as projection on db.SalesOrders
    actions { action approve() }
  ;

  annotate SalesOrders with @(restrict: [
    { grant: 'READ',  to: 'SalesViewer' },
    { grant: 'READ',  to: 'SalesRep',
      where: 'createdBy = $user' },              // 본인 주문만
    { grant: ['WRITE','approve'], to: 'SalesManager' }
  ]);
}

where: 'createdBy = $user'는 앞서 설명한 대로 DB WHERE 절로 내려가므로, SalesRep 역할 사용자는 목록 조회 시 자기 주문 외에는 존재조차 확인할 수 없습니다. 여기에 인가 실패를 추적할 수 있도록 커스텀 핸들러에서 로깅과 명시적 거부를 추가합니다.

// srv/sales-service.js
const cds = require('@sap/cds');
const LOG = cds.log('sales-auth');

module.exports = class SalesService extends cds.ApplicationService {
  init() {
    const { SalesOrders } = this.entities;

    // 승인 액션: 역할 이중 확인 + 감사 로그
    this.on('approve', SalesOrders, async (req) => {
      if (!req.user.is('SalesManager')) {
        LOG.warn(`승인 거부: user=${req.user.id}, order=${req.params[0]?.ID}`);
        return req.reject(403, '승인 권한이 없습니다.');
      }
      const { ID } = req.params[0];
      await UPDATE(SalesOrders, ID).with({ status: 'APPROVED' });
      LOG.info(`주문 승인: order=${ID}, by=${req.user.id}`);
    });

    // 수정 시도 감사 로그
    this.before('UPDATE', SalesOrders, (req) => {
      LOG.info(`수정 요청: user=${req.user.id}, data=${JSON.stringify(req.data.ID)}`);
    });

    return super.init();
  }
};

req.reject(403, ...)은 OData 표준 오류 포맷으로 응답하므로 Fiori Elements UI에서도 메시지가 그대로 표시됩니다.

3단계 — 프로덕션: 필드 마스킹, XSUAA 구성, 테스트

@restrict는 엔티티·인스턴스 단위까지만 제어하므로, marginRate 같은 민감 필드는 핸들러에서 역할 기반으로 마스킹하는 패턴이 일반적으로 사용됩니다.

// 매니저가 아니면 마진율 필드 제거
this.after('READ', SalesOrders, (rows, req) => {
  if (req.user.is('SalesManager')) return;
  for (const row of Array.isArray(rows) ? rows : [rows]) {
    if (row) delete row.marginRate;
  }
});

BTP에서 역할이 실제로 동작하려면 xs-security.json에 scope와 role-template을 선언해야 합니다. CDS의 역할 이름과 role-template 이름이 정확히 일치해야 합니다.

{
  "xsappname": "btpstacks-sales",
  "tenant-mode": "dedicated",
  "scopes": [
    { "name": "$XSAPPNAME.SalesViewer",  "description": "주문 조회" },
    { "name": "$XSAPPNAME.SalesRep",     "description": "본인 주문 관리" },
    { "name": "$XSAPPNAME.SalesManager", "description": "주문 승인/수정" }
  ],
  "role-templates": [
    { "name": "SalesViewer",  "scope-references": ["$XSAPPNAME.SalesViewer"] },
    { "name": "SalesRep",     "scope-references": ["$XSAPPNAME.SalesRep"] },
    { "name": "SalesManager", "scope-references": ["$XSAPPNAME.SalesManager"] }
  ]
}

mta.yaml에서 서비스 모듈에 XSUAA 인스턴스를 바인딩합니다.

modules:
  - name: btpstacks-sales-srv
    type: nodejs
    path: gen/srv
    requires:
      - name: btpstacks-sales-auth
resources:
  - name: btpstacks-sales-auth
    type: org.cloudfoundry.managed-service
    parameters:
      service: xsuaa
      service-plan: application
      path: ./xs-security.json

배포 후에는 BTP 콕핏에서 Role Collection을 만들어 role-template을 담고 사용자에게 할당합니다. 마지막으로 인가 규칙이 회귀하지 않도록 cds.test 기반 자동화 테스트를 두는 것이 권장됩니다.

// test/auth.test.js
const cds = require('@sap/cds');
const { GET, axios } = cds.test(__dirname + '/..');

it('익명 요청은 401', async () => {
  await expect(GET('/odata/v4/sales/SalesOrders')).rejects.toThrow(/401/);
});

it('Viewer는 marginRate를 볼 수 없다', async () => {
  axios.defaults.auth = { username: 'viewer@btpstacks.com', password: '' };
  const { data } = await GET('/odata/v4/sales/SalesOrders');
  expect(data.value[0]?.marginRate).toBeUndefined();
});

⚠️ 흔한 실수와 트러블슈팅

Q1. 로컬에서는 잘 되는데 BTP 배포 후 모든 요청이 401입니다.

대부분 Approuter 없이 서비스 URL을 직접 호출했거나, 토큰 발급 대상 XSUAA 인스턴스가 서비스에 바인딩된 인스턴스와 다른 경우입니다. Approuter를 경유하거나, 하이브리드 테스트라면 cds bind -2 btpstacks-sales-authcds watch --profile hybrid로 실제 토큰을 사용해 확인하세요.

Q2. Role Collection을 할당했는데 계속 403이 발생합니다.

CDS 역할 이름과 xs-security.json의 role-template 이름 불일치가 가장 흔한 원인입니다. scope는 $XSAPPNAME.SalesManager처럼 접두어가 붙지만 CDS에서는 접두어 없이 SalesManager만 씁니다. 또한 Role Collection 할당 후에는 재로그인으로 토큰을 새로 발급받아야 반영됩니다. 기존 토큰에는 새 scope가 없습니다.

Q3. 401과 403은 어떻게 구분해서 해석하나요?

401은 "누군지 모른다"(토큰 없음/만료/서명 오류), 403은 "누군지는 알지만 권한이 없다"(역할·restrict 조건 불충족)입니다. 401이면 인증 구성(xssec, 바인딩)을, 403이면 어노테이션과 Role Collection을 점검하는 순서가 효율적입니다.

Q4. draft 활성화 엔티티에서 @restrict가 이상하게 동작합니다.

draft 편집 중 데이터는 별도 draft 테이블에 저장되며, where 조건 평가 시점이 활성 엔티티와 다릅니다. 일반적으로 draft 시나리오에서는 WRITE 권한을 역할 단위로 단순하게 유지하고, 세밀한 인스턴스 조건은 활성화(activation) 시점 검증 핸들러로 보완하는 방식이 안전합니다.

🚀 더 나아가기

서비스·엔티티·필드 3단계 잠금이 끝났다면 다음 주제로 확장할 수 있습니다. IAS(Identity Authentication Service)와 AMS 기반 정책형 인가로 전환하면 Role Collection보다 세밀한 속성 기반 제어가 가능합니다. 멀티테넌트 SaaS라면 tenant-mode: shared와 테넌트 격리 검증이 필수 주제가 됩니다. 또한 @PersonalData 어노테이션 기반 감사 로깅(Audit Log 서비스 연동)까지 붙이면 개인정보 접근 추적 요건도 함께 충족할 수 있습니다.

📚 함께 보면 좋은 문서

댓글 0

아직 댓글이 없습니다.