ML Plugin Registry — M2
Theme G (#1154) → G-3 GAP #1215 의 부분 M2 구현 가이드. 가스공사 RFP SFR-009 ② "다양한 학습 알고리즘 (자사 모델 포함 plug-in) 제공" 의 backend 골격.
기존 GenD 는 MLflow Registry 로 학습된 모델은 다뤘지만, 학습 알고리즘 자체를 동적으로 추가/교체 하는 layer 가 없었다. MLPlugin Registry 는 sklearn / H2O AutoML / 자사 모델 같은 provider 를 DB-driven 으로 등록 하고, 등록된 provider 를 통일된 ABC (train / predict / get_serving_runtime / supports_automl) 로 호출하는 layer 다.
본 PR (M2) 의 정확한 scope
| 영역 | M1 PoC (PR #1222) | M2 (본 PR) | M3 / 후속 |
|---|---|---|---|
| DB 테이블 | ml_plugin ✅ | (변경 없음 — Alembic baseline 정책) | (동일) |
| Provider ABC | MLPluginProvider ✅ | (변경 없음) | (변경 없음) |
| Reference provider | sklearn-baseline ✅ | (변경 없음) | H2O-AutoML / 자사 |
| Registry loader | load_plugin + allowlist prefix 보안 가드 ✅ | (변경 없음) | (변경 없음) |
| API (read) | GET list / GET detail / GET capabilities ✅ | (변경 없음) | (변경 없음) |
| API (mutation) | 없음 | POST / PUT / DELETE ✅ | (변경 없음) |
| 설치-시점 allowlist 재검증 | runtime 만 | service-layer create_plugin 가 INSERT 전에 재검증 ✅ | (강화 없음) |
| Audit emit | 없음 | gend.audit ml_plugin.install / update / uninstall ✅ | HMAC chain 통합 |
| 학습 Job 트리거 | 없음 | 없음 | Dagster asset 이 provider.train() 호출 |
| KServe deploy proxy | 없음 | 없음 | serving_runtime 으로 InferenceService 템플릿 |
| UI | 없음 | 없음 | components/admin/MLPlugins/ |
본 M2 에 포함되지 않은 것 (M3 작업 항목)
- ❌ Dagster
ml_trainasset 이load_plugin(plugin).train(...)호출하는 학습 Job 파이프라인 - ❌
POST /api/v1/serving/deploy—plugin.get_serving_runtime()결과로 KServe ClusterServingRuntime 템플릿 - ❌ UI
components/admin/MLPlugins/(List + Install Dialog + Capabilities tab) - ❌ AutoML 라우팅 —
supports_automl=Trueprovider 의 hyperparameter 탐색 wrapper - ❌ Secret-aware
config마스킹 —provider.redactable_config_keyshook - ❌ Soft delete + tombstone —
deleted_at/uninstalled_by컬럼 추가 (Alembic) - ❌ HMAC audit_chain 통합 (
project_audit_hmac_chain)
⚠️ 보안 — provider_class 는 동적 Python import 경로
MLPlugin.provider_class 는 importlib.import_module 에 그대로 전달되는 dotted Python 경로 다. 가드가 없으면:
- admin 이
provider_class = 'os.system'행을 insert → 다음load_plugin호출 시os.system이 import 됨 subprocess.run/builtins.__import__/importlib.import_module같은 RCE 벡터가 모두 동일- M2 mutation API 가 열리면 admin 권한 = RCE 권한이 되는 셈
가드 — 단일 규칙
services/ml_plugins/registry.py 가 importlib 호출 전에 provider_class 가 다음 prefix 로 시작하는지 검증:
gend_api.services.ml_plugins.
이 prefix 밖의 경로는 ValueError 로 즉시 거부된다. 결과:
- DB 등록만으로 신규 provider 동작 ✗ — provider Python 모듈도 같은 패키지 안에 PR 로 추가해야 한다.
- provider 추가 = code review 필수 = 임의 RCE 경로 차단.
- mid-string
in매칭이 아닌startswith—evilpkg.gend_api.services.ml_plugins.X같은 trojan path 도 거부.
회귀 가드
tests/test_no_provider_class_import_arbitrary.py 가 양방향 단언:
- ✅
gend_api.services.ml_plugins.sklearn_baseline.SklearnBaselineProvider는 import 성공 (positive control) - ❌
os.system/subprocess.run/builtins.__import__/importlib.import_module/gend_api.services.ml_plugins_evil.X/..attacker.Evil//etc/passwd/ 빈 문자열 / None / int / mid-string prefix 매치 — 모두ValueError
이 테스트 파일이 깨지면 즉시 RCE 위험 — 절대 skip 금지.
구성 요소
1. ORM 테이블 — ml_plugin
-- apps/api/src/gend_api/db/models/ml_plugin.py (ADR-002 baseline, no Alembic)
CREATE TABLE ml_plugin (
id UUID PRIMARY KEY,
workspace_id UUID NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
-- NULL = 글로벌 (모든 tenant 공유)
-- UUID = tenant 전용 plugin
name VARCHAR(128) NOT NULL, -- 예: 'sklearn-baseline', 'h2o-automl'
description TEXT NULL,
provider_class VARCHAR(255) NOT NULL,
-- dotted Python path
-- 예: 'gend_api.services.ml_plugins.
-- sklearn_baseline.
-- SklearnBaselineProvider'
-- ⚠️ allowlist prefix 강제 (위 참고)
config JSONB NOT NULL DEFAULT '{}', -- provider-specific (task,
-- hyperparameters,
-- automl, ...)
capabilities TEXT[] NOT NULL DEFAULT '{}',
-- ['train','predict','deploy']
serving_runtime VARCHAR(128) NULL, -- KServe ClusterServingRuntime
-- 예: 'kserve-sklearnserver'
enabled BOOLEAN NOT NULL DEFAULT TRUE,
installed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
installed_by VARCHAR(255) NOT NULL, -- audit (Keycloak sub or 'seed')
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, name) -- tenant 가 글로벌 plugin 을 같은 이름으로 override 가능
);
CREATE INDEX idx_ml_plugin_workspace_enabled ON ml_plugin (workspace_id, enabled);
JSONB/TEXT[]는 PG (prod) variant. SQLite (test) 는JSON으로 fallback (with_variant패턴 —MLBatchJob.feature_columns/DriftReport.drifted_features와 동일).UNIQUE (workspace_id, name)는 PG NULL semantics 상(NULL, 'sklearn-baseline')과(uuid, 'sklearn-baseline')이 별행 — tenant 가 글로벌과 같은 이름으로 자기 버전을 등록할 수 있다.- 베이스라인 정책 (ADR-002 /
feedback_alembic_baseline_recipe) — Alembic migration 없음.init_db.create_all로 생성. - 테이블 카운트 가드:
tests/test_db_models_package.py가_EXPECTED_TABLE_COUNT67 → 68 로 업데이트됨.
2. Provider ABC — MLPluginProvider
# apps/api/src/gend_api/services/ml_plugins/base.py
class MLPluginProvider(ABC):
name: str = "unnamed-plugin"
@abstractmethod
def train(self, X: pd.DataFrame, y: pd.Series | None, config: dict) -> ModelArtifact: ...
@abstractmethod
def predict(self, artifact: ModelArtifact, X: pd.DataFrame) -> np.ndarray: ...
@abstractmethod
def get_serving_runtime(self) -> str | None: ... # None = 학습 전용
@abstractmethod
def supports_automl(self) -> bool: ...
ModelArtifact는 의도적Any— provider 가 sklearn estimator / ONNX bytes / MLflow run id 등 자유 형식 반환. 레지스트리는 검사하지 않는다.- ABC (Protocol 아님) — 추상 메서드 누락 시 instantiation 자체에서
TypeError(services/parsers/base.py와 동일 패턴). train/predict는 sync — M2 Dagster asset 가 thread pool 에서 호출 (async wrapping 은 caller 책임).
3. Reference Provider — SklearnBaselineProvider
# apps/api/src/gend_api/services/ml_plugins/sklearn_baseline.py
class SklearnBaselineProvider(MLPluginProvider):
name = "sklearn-baseline"
# config = {"task": "classification" | "anomaly_detection",
# "hyperparameters": {...}}
# task='classification' → LogisticRegression
# task='anomaly_detection' → IsolationForest
# get_serving_runtime() → 'kserve-sklearnserver'
# supports_automl() → False (H2O-AutoML plugin 이 M2 에서 True)
4. Registry Loader — load_plugin
# apps/api/src/gend_api/services/ml_plugins/registry.py
from gend_api.services.ml_plugins import load_plugin
provider = load_plugin(ml_plugin_row)
# 1. _validate_provider_class_path — allowlist prefix 검증 (ValueError if not)
# 2. importlib.import_module — module portion
# 3. getattr + issubclass check — MLPluginProvider 의 concrete subclass 인지 확인
# 4. cls() — 무인자 생성자 (config 는 train/predict 인자로 전달)
API endpoint (read — M1)
| Method | Path | 설명 |
|---|---|---|
| GET | /api/v1/ml-plugins | workspace-fence + pagination |
| GET | /api/v1/ml-plugins/{id} | 단건, 404 / 403 split |
| GET | /api/v1/ml-plugins/{id}/capabilities | provider runtime resolve (allowlist guard 노출 surface) |
Workspace isolation
ModelGrant / DriftReport 와 동일한 fence 패턴:
- tenant caller: 자기 workspace 행 ∪ NULL workspace 행 (globally installed plugin) 만 가시
- admin caller: 전체 가시
- fail-closed: tenant_slug 가 JWT 에 있지만 매핑된 Workspace 행이 없으면 403 (silent fall-through 금지)
/capabilities endpoint — 보안 가드의 user-facing surface
이 endpoint 가 load_provider_class(plugin.provider_class) 를 호출 → allowlist 검증 → provider 인스턴스 생성 → runtime claim 반환:
{
"id": "uuid",
"name": "sklearn-baseline",
"declared_capabilities": ["train", "predict"],
"provider_class_name": "SklearnBaselineProvider",
"serving_runtime": "kserve-sklearnserver",
"supports_automl": false
}
provider_class 가 allowlist 밖이면 silent 500 이 아닌 명시적 HTTP 422 반환 (Invalid provider_class: ...) — admin 이 문제 행을 식별 가능.
API endpoint (mutation — M2)
POST /api/v1/ml-plugins # admin only
body: MLPluginCreate
→ 201 MLPluginRead | 403 | 409 (UNIQUE 위반) | 422 (allowlist 위반 / import 실패 / Pydantic)
PUT /api/v1/ml-plugins/{id} # admin only
body: MLPluginUpdate (description / config / capabilities / serving_runtime / enabled 만)
→ 200 MLPluginRead | 403 | 404 | 422
DELETE /api/v1/ml-plugins/{id} # admin only
→ 204 | 403 | 404
정책 요약 (mutation 3종 공통):
- 모두
require_admin— viewer / analyst 는 403. installed_by는 서버가 caller token 의safe_username으로 stamping (request body 에서 절대 수용 안 함 — spoof 방지).workspace_id도 서버가 callertenant_slug로 stamping:- tenant-scoped admin → 그 workspace_id
- admin without tenant_slug → NULL (legacy backfill posture)
UNIQUE (workspace_id, name)위반 → 409.- POST 의
provider_class는 INSERT 전에 allowlist 재검증 — out-of-allowlist 경로 / 존재하지 않는 클래스 /MLPluginProvider비-subclass → 422, DB 에 행이 남지 않음. - PUT 의 immutable 필드 (
name,workspace_id,provider_class) → 422 (extra='forbid'). 특히provider_class는 immutable 인 게 보안상 중요 — allowlisted plugin 을 out-of-allowlist 경로로 re-target 하는 우회를 차단한다 (M3 KServe deploy proxy 가 활성 plugin 의provider_class를 신뢰하므로). - DELETE 는 hard delete — M3 에서
deleted_attombstone 으로 전환 예정.
⚠️ 보안 — POST 가 단일 보안 진입점
provider_class 는 dotted Python import 경로 (위 보안 절 참고). M1 의 runtime 가드는 load_provider_class 호출 시점에 allowlist 를 재확인하지만, primary defence 는 INSERT 전에 거부하는 것. M2 의 MLPluginService.create_plugin 가:
- Layer 1 — prefix allowlist —
_validate_provider_class_path호출.gend_api.services.ml_plugins.prefix 가 아닌 경로는 422. - Layer 2 — import + subclass 체크 —
load_provider_class호출. 존재하지 않는 클래스 /MLPluginProvider비-subclass /..parent-traversal → 422. - Layer 3 — UNIQUE composite —
(workspace_id, name)중복 → 409 (IntegrityError매핑).
우회 불가 — provider_class 는 PUT 에서도 변경 불가. allowlist 밖의 경로를 활성 plugin 으로 만들려면 (a) Python 모듈을 gend_api.services.ml_plugins/ 안에 PR 로 추가하고, (b) admin token 으로 POST 해야 한다. 두 단계 모두 code review 가 필수.
curl 예제
TOKEN="<admin keycloak access token>"
API="https://gend.genon.ai/api/v1/ml-plugins"
# 1) POST — 신규 plugin 설치
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "sklearn-anomaly",
"description": "Isolation Forest baseline for anomaly detection",
"provider_class": "gend_api.services.ml_plugins.sklearn_baseline.SklearnBaselineProvider",
"config": {"task": "anomaly_detection"},
"capabilities": ["train", "predict"],
"serving_runtime": "kserve-sklearnserver"
}' "$API" | jq .
# 2) POST allowlist 위반 예시 — 422 + 행 미생성
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "evil",
"provider_class": "os.system",
"capabilities": []
}' "$API"
# → {"detail":"Invalid provider_class: provider_class 'os.system' is not in the allowlist (must start with 'gend_api.services.ml_plugins.')"}
# HTTP 422
# 3) PUT — capabilities + enabled 패치
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"capabilities": ["train", "predict", "deploy"],
"enabled": true
}' "$API/<plugin-uuid>" | jq .
# 4) PUT 의 immutable 필드 — provider_class 변경 시도 → 422
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"provider_class": "gend_api.services.ml_plugins.h2o_automl.H2OAutoMLProvider"}' \
"$API/<plugin-uuid>"
# → 422 (extra='forbid')
# 5) DELETE — uninstall
curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" \
"$API/<plugin-uuid>" -o /dev/null -w "%{http_code}\n"
# → 204
Audit trail (M2)
mutation 3종은 모두 gend.audit logger 에 structured line 을 emit 한다. action 별 prefix:
| action | prefix | 핵심 필드 |
|---|---|---|
| POST | ml_plugin.install | plugin_id, workspace, name, provider_class, installed_by |
| PUT | ml_plugin.update | plugin_id, name, changed=[...], previous={...}, user |
| DELETE | ml_plugin.uninstall | plugin_id, name, provider_class, installed_by, uninstalled_by |
install / uninstall 두 line 모두 provider_class 를 명시 기록 — 어떤 Python 모듈이 활성화 / 비활성화됐는지 forensic review 시 grep 가능. DELETE 의 audit line 은 installed_by (원래 설치자) 와 uninstalled_by (취소한 admin) 를 둘 다 기록 — 행 자체는 삭제돼도 두 identity 의 paper trail 은 보존된다. M3 에서 HMAC chain (project_audit_hmac_chain) 과 통합되어 tamper-evident 가 된다.
감사 조회 예시 (AKS prod)
# gend-api Pod 의 application log 에서 mutation 이력 grep
kubectl -n gend logs -l app=gend-api --tail=10000 | grep "ml_plugin\."
# 특정 provider_class 가 언제 활성화됐는지 추적
kubectl -n gend logs -l app=gend-api --tail=50000 | \
grep "ml_plugin.install" | grep "sklearn_baseline"
학습 실험의 워크스페이스 태깅 (v1.2+, #2913 I3)
학습 트리거(POST /api/v1/ml-plugins/{id}/train)는 플러그인 행의
workspace_id 를 slug 로 풀어 Dagster run config 에 싣고, 학습 asset 이
MLflow experiment + run 에 gend.workspace 태그를 자동 부여한다.
이전에는 외부 SDK 프록시 경로만 자동 태깅해서, Dagster 가 만든 실험은
워크스페이스 필터(?workspace=)와 실험 목록 그룹핑에서 조용히 빠졌다.
- 실험에 이미 다른 워크스페이스 태그가 있으면 덮지 않는다 — 공유 실험의 소유를 마지막 run 이 훔치는 형태 방지. run 태그는 per-run 이라 항상 남는다.
- 태깅 실패는 학습 등록을 죽이지 않는다(fail-open) — Dagster run 로그에 error 로 남는다.
workspace_id가 NULL 인 레거시/tenantless 행은 태깅 없이 동작한다 (staged backfill 자세 — 기존 목록 조회에는 계속 노출).
신규 provider 추가 절차
- Python 모듈 추가 —
apps/api/src/gend_api/services/ml_plugins/<your_provider>.py작성.MLPluginProvider를 상속한 concrete class 정의. - (선택)
services/ml_plugins/__init__.py재-export — 직접 import 가 필요한 경우만. 레지스트리 loader 는__init__.py노출 여부와 무관하게 동작 (full dotted path 사용). - PR 리뷰 통과 — 코드 PR 이 머지된 뒤에야 신규 provider 활성 가능.
- admin POST — M2 부터는 admin token +
POST /api/v1/ml-plugins가 표준 경로. 위 curl 예제 참고. DB 직접 INSERT 는 회귀 / DR 케이스에서만 사용한다 (audit emit 우회 — 권장하지 않음). 참고용 raw SQL:INSERT INTO ml_plugin (id, workspace_id, name, provider_class, config,capabilities, serving_runtime, installed_by)VALUES (gen_random_uuid(), NULL, 'h2o-automl','gend_api.services.ml_plugins.h2o_automl.H2OAutoMLProvider','{"max_runtime_secs": 3600}'::jsonb,ARRAY['train','predict','automl']::text[],'kserve-tritonserver','admin-sub-uuid'); - 확인 —
GET /api/v1/ml-plugins/{id}/capabilities가 200 + 기대한 runtime 필드 반환.
gend_api.services.ml_plugins. prefix 밖의 provider_class 를 POST 로 등록 시도하면 → 422 + 행 미생성 (M2 service-layer 가드). DB 직접 INSERT 로 우회한 행이라도 /capabilities 호출 시 422 + services/ml_plugins/registry.py 가 WARNING 로그.
테스트
cd apps/api && .venv/bin/python -m pytest \
tests/test_ml_plugin_router.py \
tests/test_ml_plugin_service.py \
tests/test_no_provider_class_import_arbitrary.py \
tests/test_db_models_package.py \
tests/test_protected_routers_registration.py -v
# 39 router + 17 service + 22 allowlist guard + ... = 108+ tests
다음 단계 (M3)
- Dagster 학습 Job 트리거 —
ml_trainasset 이load_plugin(plugin).train(...)호출, MLflow Run 으로 결과 etcd - KServe
/predictproxy —POST /api/v1/serving/deploy가plugin.get_serving_runtime()결과로 ClusterServingRuntime 템플릿 생성,/predict는 ModelGrant ABAC 게이트 통과 후 forward - UI —
/admin/ml-plugins페이지 (List + Install Dialog + Capabilities tab), provider_class allowlist 위반 시 422 detail 을 사용자에게 명시 - HMAC audit_chain 통합 —
gend.auditlogger emit → OpenSearch + HMAC chain (project_audit_hmac_chain) - Soft delete + tombstone —
deleted_at/uninstalled_by컬럼 추가 (Alembic), DELETE 가 hard delete → soft delete 로 전환 - AutoML 라우팅 —
supports_automl=Trueprovider 의 hyperparameter 탐색 wrapper - Secret-aware
config마스킹 —provider.redactable_config_keyshook
Serving = KServe (RFC #1475 결정 / #1507)
ml_plugin 의 production inference 경로 = KServe 단일 경로. ml_plugin 자체에는 predict router / asset 진입점이 없음 — 책무 분리를 위한 의도된 설계.
라우팅 다이어그램
Why no POST /api/v1/ml-plugins/{id}/predict?
3 옵션 비교 (RFC #1475 § 옵션 비교):
| A: Dagster predict asset | B: REST predict router | C: KServe only (선택) | |
|---|---|---|---|
| 실시간 inference | ✗ (수십초 오버헤드) | ✓ | ✓ |
| 결과 sink + drift hook | batch_predict 와 중복 | 별도 구현 | KServe v2 + downstream metric |
| gend-api CPU/메모리 영향 | 0 | ✗ 모델 로드 spike | 0 (KServe pod 격리) |
| 책무 분리 | 모호 (asset vs batch) | 모호 (router vs KServe) | ✓ 명확 (train = ml_plugin, serving = KServe) |
| 운영 자원 | Dagster image 추가 부담 | gend-api 메모리 spike | 이미 prod 배포 (kserve namespace) |
옵션 C 의 근거:
- KServe = production serving 전용 (scalable / GPU / canary / v2 OIP) — 이미 prod 배포됨
base.predict()/sklearn_baseline.predict()는batch_predict_asset.py의 내부 헬퍼 로 이미 작동 (구조 변경 없음)- 외부 진입점 추가 시 다음 cascade 회귀 위험 (#1430 audit 의 본질 회귀 패턴)
KServe 배포 가이드
→ admin-ops/serving/kserve-inference.md (PR #1505) — demo_credit_risk_plugin 실배포 evidence + manifest + 트러블슈팅
회귀 가드
scripts/test_cross_pr_consistency.py::test_predict_used_only_internally:
MLPluginProvider.predict호출처 grep- 허용 path:
pipelines/gend_pipelines/ml/batch_predict_asset.py(헬퍼) + provider 자체 + 테스트 - 금지: 외부 router / 외부 asset 진입점
- 위반 시 명확 ERROR + RFC #1475 결정 명시
관련 자료
- Theme G epic: #1154
- 이 epic: #1215
- M1 PoC PR: #1222
- 자매 PoC: #1213 Drift Monitoring, #1214 ModelGrant ABAC
- RFC #1475 결정 = C — predict 진입점 = KServe 단일 경로 (이슈 #1475, PR #1492)
- 후속 구현: #1507 — base.predict docstring + 본 섹션 + cross-PR 가드
Train 가이드 (#1537 — 진입 우선순위)
| 가이드 | 대상 | 진입 시점 |
|---|---|---|
| ml-plugin-train.md | 사람 운영자 (클릭) | UI 시나리오 우선 — GenD UI 안 Dagster iframe 임베드 |
| ml-plugin-train-trigger.md | 자동화 / CI / ETL | REST API + 3-layer allowlist + GraphQL launchRun 내부 구조 |
- 관련 패턴:
feedback_alembic_baseline_recipe(ADR-002),feedback_protected_routers_global_dep,feedback_fastapi_trailing_slash,feedback_pydantic_patch_null_guard