본문으로 건너뛰기

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 ABCMLPluginProvider(변경 없음)(변경 없음)
Reference providersklearn-baseline(변경 없음)H2O-AutoML / 자사
Registry loaderload_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 / uninstallHMAC chain 통합
학습 Job 트리거없음없음Dagster asset 이 provider.train() 호출
KServe deploy proxy없음없음serving_runtime 으로 InferenceService 템플릿
UI없음없음components/admin/MLPlugins/

본 M2 에 포함되지 않은 것 (M3 작업 항목)

  • ❌ Dagster ml_train asset 이 load_plugin(plugin).train(...) 호출하는 학습 Job 파이프라인
  • POST /api/v1/serving/deployplugin.get_serving_runtime() 결과로 KServe ClusterServingRuntime 템플릿
  • ❌ UI components/admin/MLPlugins/ (List + Install Dialog + Capabilities tab)
  • ❌ AutoML 라우팅 — supports_automl=True provider 의 hyperparameter 탐색 wrapper
  • ❌ Secret-aware config 마스킹 — provider.redactable_config_keys hook
  • ❌ Soft delete + tombstone — deleted_at / uninstalled_by 컬럼 추가 (Alembic)
  • ❌ HMAC audit_chain 통합 (project_audit_hmac_chain)

⚠️ 보안 — provider_class 는 동적 Python import 경로

MLPlugin.provider_classimportlib.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.pyimportlib 호출 전에 provider_class 가 다음 prefix 로 시작하는지 검증:

gend_api.services.ml_plugins.

이 prefix 밖의 경로는 ValueError 로 즉시 거부된다. 결과:

  1. DB 등록만으로 신규 provider 동작 ✗ — provider Python 모듈도 같은 패키지 안에 PR 로 추가해야 한다.
  2. provider 추가 = code review 필수 = 임의 RCE 경로 차단.
  3. mid-string in 매칭이 아닌 startswithevilpkg.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_COUNT 67 → 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)

MethodPath설명
GET/api/v1/ml-pluginsworkspace-fence + pagination
GET/api/v1/ml-plugins/{id}단건, 404 / 403 split
GET/api/v1/ml-plugins/{id}/capabilitiesprovider 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 도 서버가 caller tenant_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_at tombstone 으로 전환 예정.

⚠️ 보안 — POST 가 단일 보안 진입점

provider_class 는 dotted Python import 경로 (위 보안 절 참고). M1 의 runtime 가드는 load_provider_class 호출 시점에 allowlist 를 재확인하지만, primary defence 는 INSERT 전에 거부하는 것. M2 의 MLPluginService.create_plugin 가:

  1. Layer 1 — prefix allowlist_validate_provider_class_path 호출. gend_api.services.ml_plugins. prefix 가 아닌 경로는 422.
  2. Layer 2 — import + subclass 체크load_provider_class 호출. 존재하지 않는 클래스 / MLPluginProvider 비-subclass / .. parent-traversal → 422.
  3. 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:

actionprefix핵심 필드
POSTml_plugin.installplugin_id, workspace, name, provider_class, installed_by
PUTml_plugin.updateplugin_id, name, changed=[...], previous={...}, user
DELETEml_plugin.uninstallplugin_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 추가 절차

  1. Python 모듈 추가apps/api/src/gend_api/services/ml_plugins/<your_provider>.py 작성. MLPluginProvider 를 상속한 concrete class 정의.
  2. (선택) services/ml_plugins/__init__.py 재-export — 직접 import 가 필요한 경우만. 레지스트리 loader 는 __init__.py 노출 여부와 무관하게 동작 (full dotted path 사용).
  3. PR 리뷰 통과 — 코드 PR 이 머지된 뒤에야 신규 provider 활성 가능.
  4. 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');
  5. 확인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)

  1. Dagster 학습 Job 트리거ml_train asset 이 load_plugin(plugin).train(...) 호출, MLflow Run 으로 결과 etcd
  2. KServe /predict proxyPOST /api/v1/serving/deployplugin.get_serving_runtime() 결과로 ClusterServingRuntime 템플릿 생성, /predict 는 ModelGrant ABAC 게이트 통과 후 forward
  3. UI/admin/ml-plugins 페이지 (List + Install Dialog + Capabilities tab), provider_class allowlist 위반 시 422 detail 을 사용자에게 명시
  4. HMAC audit_chain 통합gend.audit logger emit → OpenSearch + HMAC chain (project_audit_hmac_chain)
  5. Soft delete + tombstonedeleted_at / uninstalled_by 컬럼 추가 (Alembic), DELETE 가 hard delete → soft delete 로 전환
  6. AutoML 라우팅supports_automl=True provider 의 hyperparameter 탐색 wrapper
  7. Secret-aware config 마스킹provider.redactable_config_keys hook

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 assetB: REST predict routerC: KServe only (선택)
실시간 inference✗ (수십초 오버헤드)
결과 sink + drift hookbatch_predict 와 중복별도 구현KServe v2 + downstream metric
gend-api CPU/메모리 영향0✗ 모델 로드 spike0 (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 결정 명시

관련 자료

Train 가이드 (#1537 — 진입 우선순위)

가이드대상진입 시점
ml-plugin-train.md사람 운영자 (클릭)UI 시나리오 우선 — GenD UI 안 Dagster iframe 임베드
ml-plugin-train-trigger.md자동화 / CI / ETLREST 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