M0 — 인가 as-code: Cedar 정책을 빈 파일에서 재구현¶
준비(한 번만): 클론엔
.venv가 없다 →python -m venv .venv후.venv\Scripts\python.exe -m pip install -r requirements-dev.txt— macOS/Linux는make setup한 줄. 자세히는 SETUP.
미션: 핀테크 데모의 인가 정책을 스펙만 보고 직접 작성해 채점기 11/11을 달성한다. 클러스터 불필요 — 노트북에서 python만으로 한다.
학습 성과 (면접에서 말할 수 있는 것): Cedar로 RBAC(역할)+ABAC(소유자·한도·동결) 하이브리드 인가를 코드로 작성하고, "통과≠증명"(default-deny로 Deny가 공짜로 맞는 함정·뮤테이션 테스트)을 설명할 수 있다. → 캡스톤 M0
편집하는 파일은 단 하나: labs/m0/policies.cedar.
cedar/policies.cedar(정답지)는 졸업 전 열람 금지.
빈 파일이 막막하면 → 배우기 모드: LEARN.md. 완성 예시 1개(주석)를 읽고 → 빈칸 1개를 채우고 → 나머지는 직접 작성. 정답지 통째 베끼기가 아니라 문법을 떠먹여주는 중간 계단이다(주입식이 편하면 여기부터).
Step 0 — 베이스라인 확인 (5분)¶
# repo 루트에서 (.venv의 python 사용)
.venv\Scripts\python.exe cedar\authz.py # 정답 정책의 8/8 — 목표 상태를 눈으로 확인
.venv\Scripts\python.exe labs\m0\grade.py # 내 (빈) 정책의 채점 — 5/8부터 시작한다
첫 교훈이 바로 여기 있다: 정책이 하나도 없는데 5/8이 통과한다. Cedar는 default-deny — permit이 없으면 전부 Deny이므로, "Deny 기대" 시나리오 5개는 공짜로 맞는다. 즉 Deny 통과는 정책이 옳다는 증거가 아니다. 이 함정은 Step 3에서 다시 만난다.
더 날카로운 관찰: 통과하는 5개 중 하나가
transfer from FROZEN account (forbid overrides)다 — R4(forbid)를 검증하려고 만든 시나리오인데 R4를 한 줄도 안 썼는데 PASS다. alice는 동결 계좌의 소유자이고 금액(100)도 한도(1000) 이내라 R2 permit이 있었다면 Allow였겠지만, permit이 없어 default-deny로 떨어진 것이다. forbid가 실제로 일하는지는 그것이 덮을 R2 permit을 켠 뒤에야 드러난다(Step 3-A). guardrail은 그것이 막을 permit이 켜져 있어야 비로소 관측된다.
Step 1 — 읽기 (30–60분)¶
docs/01-authz-no-cluster.md— 이 데모의 인가 레이어 개요docs/authorization-model.md§1–§3 — RBAC/ABAC/지속평가 지형- Cedar 공식 튜토리얼 (https://www.cedarpolicy.com/) — 문법 감 잡기
cedar/schema.json은 열람 OK — 스키마는 스펙의 일부다(엔티티 타입·속성·액션 정의).cedar/entities.json도 열람 OK(테스트 데이터).cedar/policies.cedar만 보지 마라.
문법 치트시트 (언어를 가르치는 것이지 답이 아니다)¶
// 기본 골격 — permit(허용) / forbid(금지), 조건은 when 절에:
permit ( principal, action == Action::"X", resource )
when { /* 조건식 */ };
// 자주 쓰는 조건식:
// principal == resource.owner 엔티티 참조 비교
// principal in Role::"name" 역할(부모 그룹) 멤버십
// context.amount > 0 컨텍스트 값
// resource.frozen == true 불리언 속성
// && / || 결합
// forbid는 어떤 permit보다 우선한다 (explicit deny wins).
채점 전에 먼저 도는 관문: schema validation¶
grade.py는 시나리오를 평가하기 전에 정책을 schema.json에 대해 정적 검증한다(cedarpy.validate_policies).
스키마에 없는 속성·타입오류·없는 액션을 참조하면 평가에 가기도 전에 ERRORS로 멈춘다 — 이게
policy-as-code의 핵심 이득이다(런타임 요청 없이 배포 전 결함 차단). 흔한 실수와 실제 에러 문구:
resource.ownerr처럼 오타 →attribute `ownerr` on entity type `Account` not foundresource.transferLimit(엉뚱한 타입에서 속성 읽기 —transferLimit은User속성이지Account가 아니다) → 똑같이attribute `transferLimit` on entity type `Account` not found
두 번째가 특히 중요하다: owner는 Account 속성, transferLimit은 User 속성이다(schema.json을 보면
명시돼 있다). principal/resource를 헷갈려 principal.owner나 resource.transferLimit를 쓰면 validation이
잡아준다 — 평가까지 가서 엉뚱한 Deny로 디버깅하는 대신, 검증 단계에서 정확한 줄을 짚어준다.
파싱 에러(주로 ; 누락)일 땐 채점기가 세미콜론 힌트까지 출력한다(authz.py의 hint 분기).
Step 2 — 재구현: 스펙 → 정책 (핵심, 1–3시간)¶
은행의 인가 요구사항(스펙). 이걸 labs/m0/policies.cedar에 Cedar로 옮겨라:
| # | 요구사항 |
|---|---|
| R1 | 계좌 조회(ViewAccount)는 그 계좌의 소유자만 할 수 있다 |
| R2 | 이체(Transfer)는 소유자이면서, 금액이 양수이고, 본인의 transferLimit 이하일 때만 |
| R3 | 감사로그 조회(ViewAuditLog)는 auditor 역할 멤버만 |
| R4 | 동결(frozen) 계좌의 이체는 무조건 금지 — 다른 어떤 허용보다 우선 |
.venv\Scripts\python.exe labs\m0\grade.py # 반복: 작성 → 채점 → 8/8까지
.venv\Scripts\python.exe labs\m0\grade.py --hint # 막히면: FAIL 행마다 어느 요건인지 넛지(정답 아님)
채점기는 시나리오별 expect/actual을 보여준다. 틀리면 왜 그 시나리오가 그 결과를 냈는지
정책을 다시 읽어라 — 그 인과 추적이 학습의 본체다. (--hint는 어느 요건인지만 알려주지 답은 안 준다.)
첫 한 줄이 막히면: R1 완성 예시 (R2~R4·E1은 직접)
R1을 그대로 옮기면 이렇게 된다 — scope 3칸(principal, action ==, resource) + `when` 블록 + 끝에 `;`: 이걸 `policies.cedar`에 넣고 채점하면 `owner views own account`가 PASS로 바뀐다. **문법 함정 3가지:** ① 모든 문장은 `;`로 끝난다 ② action 앞에 반드시 `==` ③ 조건은 scope가 아니라 `when {}` 안에. (R2의 양수/한도, R3의 역할, R4의 forbid는 이 골격을 따라 직접.)Step 3 — break-and-fix: 예측 → 파괴 → 확인 (30분)¶
8/8이 된 내 정책에 아래 뮤테이션을 하나씩 적용한다. 규칙: 실행 전에 "어느 시나리오가 깨질지" 먼저 종이에 적는다. 그 다음 채점해서 예측과 비교.
| 뮤테이션 | 예측해 볼 것 |
|---|---|
| A | R4(forbid) 정책 전체 삭제 |
| B | R1의 소유자 조건 제거(무조건 permit) |
| C | R2의 한도 비교를 <=에서 <로 |
A를 돌려본 후에 열 것 — forbid가 load-bearing인 이유
**A는 정확히 1개를 깬다: `transfer from FROZEN account`가 Deny→Allow로 뒤집힌다.** 왜? alice는 동결 계좌의 소유자이고 금액(100)도 한도(1000) 이내라 **R2 permit이 매치한다** — forbid가 없으면 그 permit이 곧장 Allow다. 즉 forbid는 "permit이 이미 Allow를 내주는 요청"을 덮어쓰려고 존재한다. 이게 Step 0에서 본 빈-정책 함정의 거울상이다: 빈 정책에선 그 시나리오가 *permit이 없어서* Deny였고 (forbid 무관), 여기선 *permit이 켜져 forbid가 진짜 일을 해야* Deny다. **`forbid > permit`은 이렇게 permit과 forbid가 동시에 매치할 때만 관측 가능하다** — 둘 중 하나라도 빠지면 보이지 않는다.B를 돌려본 후에 열 것
**B는 `non-owner views another's account`를 Deny→Allow로 깬다.** `when` 조건을 지워 무조건 permit이 되면 bob이 alice 계좌를 조회해도 Allow다 — 전형적인 **BOLA / IDOR**(객체 수준 인가 누락, OWASP API Security #1)다. 1개만 깨지는 게 핵심: owner-views-own은 여전히 Allow(소유자도 무조건 permit에 포함), ext를 켜면 EXT1(auditor가 남 계좌 조회)도 *우연히* Allow가 되지만 그건 다른 이유다. 조건 제거가 "적게 깨진다"고 안전한 게 아니다 — 깨진 1개가 바로 인가의 존재 이유다.뮤테이션 C를 돌려본 후에 열 것
**C는 core 8/8을 그대로 통과한다.** 시나리오에 "정확히 한도만큼(1000) 이체"하는 **경계 케이스가 없기** 때문이다 — 뮤턴트가 살아남으면 테스트 스위트에 구멍이 있다는 뜻이다(*mutation testing*). 이 repo의 적대적 검증이 `cedar/agent/`에서 실제로 같은 클래스의 결함("시나리오가 잘못된 이유로 통과")을 찾아 falsifiable 테스트를 추가했다 — Step 4의 ext 시나리오 3번이 바로 이 뮤턴트를 잡는 경계 테스트다. 뮤테이션을 되돌리고 Step 4로.Step 4 — 졸업 과제: 스펙에 없던 확장 (30–60분)¶
새 요구가 내려왔다:
E1. 감사역(auditor)은 모든 계좌를 조회할 수 있어야 한다. 단, 이체 권한은 절대 아니다.
설계해서 labs/m0/policies.cedar에 추가하라. 졸업 채점:
ext 시나리오 3개: ① auditor가 남의 계좌 조회 → Allow ② auditor가 남의 계좌 이체 → Deny (과잉 허용을 잡는다 — "auditor에게 다 열어주기"로 풀면 여기서 걸린다) ③ 정확히 한도만큼(1000) 이체 → Allow (Step 3-C의 경계 테스트).
Step 5 — 구두 문답 (면접 방어)¶
답안을 보기 전에 소리 내어 답하라. 모듈 졸업 후와 면접 전에 다시 돌아올 것.
Cedar의 기본 판정은? permit이 하나도 없으면 무슨 일이 일어나는가?
default-deny. 매치되는 permit이 없으면 무조건 Deny. 그래서 빈 정책으로도 Deny-기대 시나리오는 통과한다 — Deny 통과는 정책의 증거가 아니다.permit과 forbid가 동시에 매치되면?
forbid가 이긴다(explicit deny overrides). R4가 R2의 허용을 덮는 구조가 그 예다.PARC 모델의 각 요소는 이 데모에서 구체적으로 무엇인가?
Principal=User::"alice"(X-User에서 파생), Action=ViewAccount/Transfer/ViewAuditLog, Resource=Account/AuditLog 엔티티, Context={amount} 같은 요청 시점 값.R3은 RBAC적이고 R2는 ABAC적이다 — 왜?
R3은 역할 멤버십(principal in Role)만 보는 거친 판정=RBAC. R2는 소유자 관계+금액+개인 한도라는 속성/컨텍스트 조건=ABAC. 한 엔진에서 둘을 계층으로 섞는 게 실무 하이브리드다.schema validation은 무엇을 잡고, 평가(evaluate)와 어떻게 다른가?
validation은 정책이 스키마에 없는 속성/타입오류/없는 액션을 참조하는 걸 배포 전에 정적으로 잡는다. 평가는 런타임에 요청별 판정. authz.py가 둘 다 수행한다 — "배포 전 검증 가능"이 policy-as-code의 핵심 가치.왜 인가를 코드로 두는가? 한 문장으로.
코드면 단위테스트·리뷰·CI 게이트·뮤테이션 검증이 가능해서 "시행된다"를 증명할 수 있다 — 이 repo의 명제(검증가능성) 그 자체.context.amount는 누가 주는 값인가? 신뢰할 수 있나?
호출자(PEP, 여기선 api)가 요청 본문에서 추출해 넣는다. 신뢰 불가 입력이므로 PEP가 정규화해야 한다 — app/api/main.py는 파싱 실패 시 10^9로 강제해 한도 초과 Deny로 fail-closed한다.principal id가 공격자 제어 입력(X-User 헤더)일 때 무엇을 막아야 하나?
charset 검증. User::"..." 문자열에 따옴표 등을 인젝션해 UID를 탈출하는 것 — main.py가 정규식 ^[A-Za-z0-9_-]{1,64}$로 막고 400을 돌려준다.이 정책을 AWS 관리형으로 옮기려면?
Amazon Verified Permissions — 같은 Cedar 정책/스키마가 그대로 올라간다. isAuthorized API로 평가. ($5/100만 요청)빈 정책이 5/8을 통과하고, 뮤테이션 C가 core 8/8을 통과했다 — 두 사건의 공통 교훈은?
둘 다 "통과 = 증명"이 아님을 보여준다. Deny는 default-deny 때문에 공짜로 맞을 수 있고(옳은 이유로 Deny인지 별도 확인 필요), Allow도 경계를 안 찌르면 뮤턴트가 살아남는다. 통과하는 스위트 ≠ 좋은 스위트 — 뮤턴트를 죽이는 스위트가 좋은 스위트다(mutation testing).R2의 세 조건(
owner 조건 → 남의 계좌에서 이체(BOLA).owner&&amount > 0&&amount <= limit)은 각각 어떤 잘못된 요청을 막나? 하나씩 빼면?amount > 0→ 음수 이체로 역방향 가치 추출(피해자→공격자).amount <= limit→ 한도 초과 대량 인출. 셋은 AND라 하나라도 빠지면 그 클래스의 공격이 열린다. 핵심은amount > 0: 한도 검사(<= 1000)만 있으면 -100은 한도를 만족하므로 통과한다 — 음수 가드는 한도 가드가 못 잡는 별개의 결함을 막는다(docs/01-authz-no-cluster.md의 break-and-fix가 정확히 이 줄을 지운다).E1을
안 된다.permit(principal in Role::"auditor", action, resource)로 풀면 11/11인가? 안 되면 무엇이 잡나?action을 묶지 않은 와일드카드는 auditor에게 Transfer까지 열어준다 — EXT2(auditor 이체 시도)가 Deny 기대인데 Allow로 뒤집혀 FAIL. 올바른 E1은action == Action::"ViewAccount"로 액션을 고정한다. 이게 ext 시나리오가 존재하는 이유: 과잉 허용(over-grant)은 core 8개로는 안 잡히고, "auditor가 하면 안 되는 일"을 명시적으로 Deny-기대로 박아야 잡힌다. 최소권한은 "허용을 추가"가 아니라 "허용의 범위를 좁히는" 문제다.carol의 transferLimit은 0이다. 그럼 R2가 carol의 이체를 어차피 막지 않나? 왜 forbid나 액션 고정이 따로 필요한가?
아니다 — limit 0은 우연한 방어일 뿐이다. R2 permit은resource.owner == principal을 먼저 요구하므로 carol은 남의 계좌(acct-alice)에선 owner 조건에서 이미 막힌다(limit과 무관). 만약 carol이 자기 계좌를 가졌다면 limit 0이 0원 이체만 막고, 운영이 limit을 올리는 순간 열린다. 보안을 데이터값(limit=0)에 의존시키면 안 되고 정책 구조(auditor에게 Transfer permit을 아예 안 주기)로 보장해야 한다 — EXT2가 검증하는 건 후자다.이 4개 정책에 평가 순서가 있나? Cedar는 어떻게 최종 결정을 합치나?
없다 — Cedar 정책은 순서 무관이다. 엔진은 매치하는 모든 forbid와 permit을 모은 뒤 합친다: forbid가 하나라도 매치하면 Deny, 아니면 permit이 하나라도 매치할 때 Allow, 그 외 default-deny. 그래서 정책을 파일에서 재배치해도 결정이 안 바뀐다(IAM의 명시적 Deny 우선과 같은 모델). 이게 "정책을 독립적으로 추가/리뷰 가능"의 토대 — 한 줄을 읽을 때 위쪽 정책의 실행 순서를 머릿속에 둘 필요가 없다.
현실 연결 (이 통제가 막는 실제 사고 클래스)¶
- 인가를 PDP 한 곳에 모으는 이유. R1 같은 객체 수준 인가를 컨트롤러 if문에 흩뿌리면 엔드포인트마다 빠뜨려 BOLA(OWASP API #1)가 난다(뮤테이션 B가 재현하는 결함). Cedar처럼 PDP로 모으면 모든 요청이 같은 정책을 거치고, 그 정책을 단위테스트로 회귀까지 막는다.
- R4 forbid = 차단의 덧씌우기(overlay), 권한 회수가 아니다. 동결·제재 대상은 "permit을 다 회수"하는 게 아니라 "그 위에 forbid 한 장"으로 막는다 — permit 로직을 안 건드리고 차단을 덧씌우고 즉시 해제할 수 있다. AWS IAM의 명시적 Deny, SCP가 같은 패턴이다(Step 3-A의 forbid>permit이 정확히 이 구조다).
Go deeper (1차 출처)¶
- Cedar 언어 레퍼런스 / 평가·검증 시맨틱 — https://docs.cedarpolicy.com/ (default-deny·
forbid>permit·validation 정의) - Amazon Verified Permissions (Cedar 관리형 PDP,
IsAuthorized) — https://aws.amazon.com/verified-permissions/ - OWASP API Security Top 10 — API1:2023 Broken Object Level Authorization(R1/뮤테이션 B가 막는 것) — https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/
- NIST SP 800-207 Zero Trust Architecture — 매 요청 동적 인가(이 PDP가 세션 grant를 캐시하지 않는 이유) — https://csrc.nist.gov/pubs/sp/800/207/final
- 이 repo의 인가 모델 포지셔닝(RBAC+ABAC 하이브리드, ReBAC·에이전트 위임 데모) —
docs/authorization-model.md
졸업 기준 (셀프 체크)¶
- [ ]
grade.py --ext11/11 - [ ] 뮤테이션 A·B의 깨질 시나리오를 사전에 정확히 예측했다
- [ ] 뮤테이션 C가 core를 통과하는 이유와, ext ③이 그것을 잡는 이유를 설명할 수 있다
- [ ] 구두 문답 14개를 답안 안 보고 말로 답했다
- [ ] 이제 정답지를 열어라:
git diff --no-index labs/m0/policies.cedar cedar/policies.cedar— 내 답과 원본의 차이를 읽고, 각 차이가 스타일인지 의미인지 판별할 수 있다
다음: M1 — 쉬프트레프트 결함 사냥 (클러스터 불필요).