Okta SCIM 프로비저닝 검증 (1) — 자체 SCIM 서버로 본 Okta의 요청
1. 배경
SAML/OIDC는 인증 시점에 IdP가 SP에 사용자 정보를 전달하는 프로토콜이다. SCIM은 인증과 무관하게 IdP가 SP의 사용자 저장소를 REST로 생성·수정·비활성화하는 프로토콜이다.1
ADFS 환경에서는 계정이 이미 AD에 있어 이 층이 필요 없었다. IDaaS에서는 SaaS 앱마다 이 층이 생긴다.
OIN 앱(Slack 등)에 붙이면 Okta가 무엇을 보내는지 보이지 않는다. SCIM 서버를 직접 만들어 Okta를 클라이언트로 붙이면 요청 전부를 로그로 볼 수 있다.
학습 로드맵은 베스핀글로벌 Okta 가이드의 앱 연동·프로비저닝 항목을 참고했고, 본문은 전부 자체 org 실측이다. 이 글은 프로토콜 관찰(요청 형태)이고, 장애·퇴사·복직 체인은 2편에서 다룬다.
2. 구성
| 항목 | 값 |
|---|---|
| Okta org | Integrator Free Plan |
| 앱 | AIW로 만든 커스텀 SAML 앱 (SSO는 사용하지 않고 형식만 채움) → General → Provisioning: SCIM |
| SCIM 서버 | Flask 단일 파일 — /scim/v2 아래 Users·Groups·ServiceProviderConfig, Bearer 인증, 필터·페이지네이션·PATCH·PUT 구현. 모든 요청·응답을 jsonl로 기록 |
| 노출 경로 | cloudflared quick tunnel (https://{tunnel}.trycloudflare.com/scim/v2) |
| 사용자 | AD Agent로 Import한 AD-sourced 계정, AD-sourced 그룹 1개 |
마스킹 표기: org 서브도메인 {org}, 터널 호스트 {tunnel}, 앱 리소스 ID {app_user_id}/{app_group_id}, 테스트 계정명 <username>, 토큰·비밀번호 값.
3. 게이트 확인
들어가기 전에 Free Plan에서 이 실습이 가능한지부터 확인했다.
- Integrator Free Plan에서 커스텀 앱의 Provisioning 필드에 None / On-Premises Provisioning / SCIM 3개가 노출된다
- Supported provisioning actions는 콘솔에 5개(Import New Users and Profile Updates / Push New Users / Push Profile Updates / Push Groups / Import Groups)가 표시된다. Okta Help 문서에는 4개만 기재되어 있다2
- To App 기본값은 Create / Update / Deactivate 전부 미체크다. 매핑 테이블의 Apply on 기본값은 전 속성 “Create”
4. Test Connector Configuration이 보내는 요청
GET /scim/v2/Users?startIndex=1&count=2
이것 하나다. /ServiceProviderConfig는 읽지 않는다. 서버가 patch.supported: true를 선언해도 참고하지 않는다. Save 시 같은 요청이 한 번 더 나간다.
5. To App 저장 시 부수 요청
GET /scim/v2/Users?startIndex=1&count=2
GET /scim/v2/Groups?startIndex=1&count=100
Groups 조회는 앱 그룹 목록 캐시다. 이후 Push Groups의 Create/Link 판정과 “Refresh App Groups” 버튼이 이 캐시를 쓴다.
6. 사용자 할당 — Push New Users
GET /scim/v2/Users?filter=userName eq "<username>"&startIndex=1&count=100 → 0건
POST /scim/v2/Users → 201
POST 본문(발췌):
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"],
"userName": "<username>",
"name": {"givenName": "<givenName>", "familyName": "<familyName>"},
"emails": [{"primary": true, "value": "<username>", "type": "work"}],
"title": "Manager",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": {"department": "Engineering"},
"externalId": "00u{masked}",
"groups": [],
"password": "{masked}",
"active": true
}
externalId= Okta 사용자 ID. 앱 쪽 userName·이메일이 바뀌어도 같은 사람을 추적하는 키다- department는 enterprise 확장 스키마, title은 core에 실린다. 값 없는 매핑 속성은 생략된다
- Sync Password를 끈 상태에서도 password가 온다. 무작위 8자다. 앱 계정에 임의 비밀번호를 박아 로컬 로그인을 막고 SSO로만 들어오게 하는 구조다
부수적인 실수 하나. AD Import 때 동명 Okta-sourced 계정을 NEW로 확정해 둔 탓에 같은 이름이 둘이었고, 처음 할당한 것은 다른 쪽 계정이었다. 할당 팝업에서 username 확인이 필요하다.
7. 할당 해제 — Deactivate Users
GET /scim/v2/Users/{app_user_id} → 200
PUT /scim/v2/Users/{app_user_id} → 200 (active: false)
- DELETE도 PATCH도 아니다. 리소스 전체를 읽어 active만 바꿔 되돌려 쓰는 read-modify-write다
- 앱 계정은 삭제되지 않고 비활성으로 잔존한다
- PUT 본문에 GET 응답의 id·meta가 그대로 포함된다 — Okta는 직전 GET 응답을 복사해 수정한다. 서버가 GET 응답에 password를 내보내면 PUT에도 그대로 왕복한다(12절에서 확정)3
8. 앱 리소스 부재 시 프로필 푸시 — 404에서 멈춘다
서버 저장소를 지운 상태(앱 관리자가 계정을 직접 삭제한 상황)에서 AD title 변경 → Incremental Import → 즉시 다음 요청이 나간다.
GET /scim/v2/Users/{app_user_id} → 404
- 404를 받으면 그 자리에서 중단한다. filter 재검색도, POST 재생성도, 자동 재시도도 없다
- 사용자 상세에 빨간 배너가 뜨고 Tasks에 “profile updates have errors”로 기록된다. Retry는 같은 GET 404를 반복할 뿐이다
- 복구: Unassign(GET 404 → PUT 없이 종료) → 재할당(filter 0건 → POST 201, 새 앱 ID 발급)
9. 프로필 변경 정상 푸시 — Push Profile Updates
AD title 변경 → Incremental Import(1 updated) → 즉시 GET 200 → PUT(title 반영).
7절과 같은 read-modify-write다. 변경 필드만 보내는 PATCH가 아니다.
AD-sourced 사용자는 Okta 콘솔에서 Profile Edit 버튼 자체가 없다. Profile Editor에서 속성 Source priority를 Okta로 바꾸면 편집은 되지만, AD → Okta → 앱 체인 관찰 목적과 어긋나 AD 쪽 변경으로 진행했다.
10. Group Push
POST /scim/v2/Groups → 201 {"displayName": "okta-lab-users", "members": [{"value": "{app_user_id}", "display": "<username>"}]}
GET /scim/v2/Groups/{app_group_id}
PUT /scim/v2/Groups/{app_group_id} (동일 내용)
GET /Groups?filter=displayName eq …같은 존재 확인은 없다. 5절의 캐시로 Create를 판정한다- members에는 앱에 할당된 사용자만 들어간다. AD 그룹의 나머지 멤버는 제외되고, 그들에 대한
POST /Users도 나가지 않는다 - Group Push는 앱 접근 권한을 부여하지 않는다. 할당(계정 생성)과 푸시(그룹 소속)는 별개다. 가이드의 “Assign 그룹과 Push 그룹 분리” 권고의 근거가 이 구조다
11. 멤버십 변경 전파
다른 사용자를 앱에 할당하면 POST /Users → 그룹 GET/PUT(members 2명)이 연달아 나간다. 할당되는 순간 소속 푸시 그룹에 자동 편입된다.
- 그룹 PUT은 항상 전체 members 목록이다. add/remove 연산이 아니다
- 사용자 Unassign 시에는 사용자
PUT active:false만 발생하고 그룹 PUT은 없다 → 비활성 계정이 앱 그룹에 잔존한다. 재할당 시에는 그룹 PUT으로 복귀한다(비대칭)
12. 재할당과 password 왕복 판정
재할당 시퀀스: filter → 기존 계정 1건(active: false) → POST 없이 GET/PUT active: true → 그룹 GET/PUT. 재생성이 아니라 재활성화다.
이 사이클에서 password의 출처를 판정했다. 서버를 고쳐 GET 응답에서 password를 제거한 뒤 나간 PUT에는 password가 없었다 → 7절 PUT의 password는 서버가 내보낸 값을 Okta가 복사한 것이다. Okta 자체 값이 아니다.
13. Import (To Okta) — 앱 사용자를 Okta로 가져오기
실측 재개를 위해 환경부터 되살렸다. SCIM 서버를 토큰 환경변수 주입으로 재기동하고,

cloudflared quick tunnel을 다시 띄우자 새 무작위 호스트명이 발급됐다. 16절의 교훈 그대로, 커넥터의 base URL 갱신이 첫 작업이 된다.


앱 저장소에는 Okta가 만들지 않은 계정(SaaS 관리자가 콘솔에서 직접 만든 상황)을 넣어 두었다. store JSON에 core 스키마 형태의 사용자를 직접 추가하고,

서버 재기동 후 API로 조회해 신규 계정이 active: True로 들어간 것을 확인했다. 이 시점 store에는 4건 — 기존 링크 2건(True), 비활성 1건(False), 앱 전용 신규 1건(True) — 이 있다.

이 상태에서 Import 탭의 Import Now를 실행했다.

GET /scim/v2/Users?startIndex=1&count=100
GET /scim/v2/Groups?startIndex=1&count=100
각 1회, 페이지 크기 100이다. 요약 결과는 4 users scanned / 1 new / 2 updated(기존 링크) / 1 removed / 0 groups scanned.

- “removed” = 앱에서
active: false인 계정. Okta 상태에는 변화 없다 - “0 groups scanned” —
GET /Groups는 나갔으나 Okta가 Push로 만든 그룹은 Import 대상에서 제외된다 - 확인 화면에서 앱 전용 계정이 기존 Okta 사용자와 EXACT match(username·email 일치)로 잡힌다. 드롭다운은 EXACT / NEW / EXISTING I specify / IGNORE — AD Import와 같은 UI다
- Imported User 열의 Name이 공란이었다 — 서버 레코드에
name.givenName/familyName은 있고displayName은 없었다. Import 방향 매핑이 core name 구조체를 그대로 읽지 않는 것으로 보인다(원인 미확인)


- Confirm(EXACT) → 앱으로 나가는 요청 없이 Okta 내부 링크만 생성된다. Assignments에 Individual로 표시되어 Push로 만든 할당과 구분되지 않는다


14. Import 링크와 Group Push
- Import 링크 직후 그룹 PUT은 없다. 11절(할당 → 즉시 그룹 PUT)과 비대칭이다
- Push Groups → Push now →
GET /Groups/{app_group_id}×2 → PUT×2, members 3명(Import 링크 사용자 포함)


- Push now는 Okta 쪽 멤버 목록 전체를 다시 PUT하는 강제 동기화다
- 앱에 따로 만든 계정을 Import로 붙인 뒤에는 Push now 또는 다음 멤버십 변경 이벤트가 있어야 앱 그룹이 맞춰진다
15. 정리
| 조작 | 앱으로 나가는 요청 | 비고 |
|---|---|---|
| 연결 테스트 | GET /Users count=2 | ServiceProviderConfig 안 읽음 |
| 할당 | filter GET → POST | externalId = Okta ID, password 포함 |
| 프로필 변경 | GET → 전체 PUT | 404면 중단, 재시도 없음 |
| 할당 해제 | GET → PUT active:false | 그룹 PUT 없음 |
| 재할당 | filter → GET → PUT active:true → 그룹 PUT | 재생성 아님 |
| 그룹 푸시 | POST /Groups → GET/PUT | 할당된 사용자만 members |
| 멤버 변경 | 그룹 GET → 전체 PUT | add/remove 아님 |
| Import | GET /Users + GET /Groups (100건 페이지) | 링크만, 앱 호출 없음 |
| Push now | 그룹 GET/PUT×2 | 전체 재동기화 |
- Okta는 서버 capability를 읽지 않고, 수정은 전부 read-modify-write PUT이다
- 할당·해제·Import·Push는 서로 다른 경로이고 그룹 동기화가 따라오는 조건이 제각각이다. “앱 쪽 상태가 왜 이런가”는 어느 경로로 만들어졌는지를 먼저 봐야 한다
- SCIM 서버 구현 쪽 교훈: writeOnly 속성을 응답에 내보내면 IdP의 read-modify-write 경로로 그 값이 왕복한다
16. 실습 중 실수
- 서버 코드를 고친 뒤 재기동 확인 없이 결론을 두 번 뒤집었다. 판정 전에 실행 중 프로세스의 동작을 직접 확인해야 한다
- PowerShell 5의
Set-Content -Encoding UTF8은 BOM을 붙이고,-Encoding없는Get-Content는 CP949로 읽는다. 서버 파일 1회 손상, JSON 저장소 1회 기동 실패 - 저장소 삭제로 8절의 404 시나리오가 우연히 생겼다 — 결과적으로 가장 실무적인 관찰이 됐다
- cloudflared quick tunnel은 재시작마다 주소가 바뀐다. 재개 시 Base URL 갱신이 첫 작업이다
다음 편
2편에서 장애·퇴사·복직 체인을 다룬다.
- 연결 장애 중 할당과 Retry, AD Disable → Okta → 앱 비활성화 푸시 실패, AD Enable → 재링크 절차
- AD 그룹 멤버 변경의 앱 그룹 전파, 비활성 멤버의 Push now 처리
-
RFC 7643 (SCIM Core Schema), RFC 7644 (SCIM Protocol) ↩
-
Okta Help, “Add SCIM provisioning to app integrations” — https://help.okta.com/oie/en-us/content/topics/apps/apps_app_integration_wizard_scim.htm ↩
-
RFC 7643 §7 — password 속성은 mutability
writeOnly, returnednever↩
Comments