Model Access Control — M2
Theme G (#1154) → G-2 GAP #1214 의 M2 (mutation contract) 구현 가이드. 가스공사 RFP SFR-009 ③ "모델 버전·배포 시점·호출 이력 + 활용 이력" 의 backend.
기존 GenD 는 DataGrant (table 단위 ABAC) 만 보유했다. ModelGrant 는 그 모델 단위 peer 로, 어떤 사용자/그룹이 어떤 모델을 invoke / deploy / manage 할 수 있는지 정형화한다.
M2 scope
| 영역 | M1 PoC (PR #1221) | M2 (본 PR) | M3 / 후속 |
|---|---|---|---|
| DB 테이블 | model_grant ✅ | (동일 — 변경 없음) | deleted_at tombstone 컬럼 + Alembic |
| API (read) | GET list / detail / check ✅ | 동일 | (변경 없음) |
| API (mutation) | 없음 | POST / PUT / DELETE ✅ | (변경 없음) |
| Audit emit | 없음 | structured gend.audit log ✅ | HMAC chain (project_audit_hmac_chain) 통합 |
| ABAC 평가기 | check_access ✅ | (변경 없음) | condition_expr 평가기 (clearance 등) |
KServe /predict proxy | 없음 | 없음 | M3 PR — check_access 후 forward |
| UI | 없음 | 없음 | M3 PR — components/admin/ModelGrants/ |
| Keycloak group 멤버십 | placeholder | placeholder | M3 PR — group sync 통합 |
| Soft delete | hard delete | hard delete (M2 도 동일) | deleted_at tombstone 으로 전환 |
본 M2 에 포함되지 않은 것 (M3 작업 항목)
- ❌
POST /api/v1/serving/{name}/predictproxy — KServe forward + ABAC 게이트 - ❌ UI
components/admin/ModelGrants/(List + Dialog) - ❌ Keycloak group 멤버십 동기화 —
subject_type='group'행은 현재subject_id직접 매칭만 허용 - ❌
condition_expr평가 — 테이블에는 저장되지만check_access는 평가하지 않음 - ❌ Soft delete + tombstone — ADR-002 baseline 정책 상 PoC PR 에서는 Alembic 추가 금지
- ❌ HMAC audit_chain 통합 —
gend.auditlogger emit 만, OpenSearch 직접 write 는 M3
구성 요소
1. ORM 테이블 — model_grant
-- apps/api/src/gend_api/db/models/model_grant.py (ADR-002 baseline, no Alembic)
CREATE TABLE model_grant (
id UUID PRIMARY KEY,
workspace_id UUID NULL REFERENCES workspaces(id) ON DELETE RESTRICT,
model_name VARCHAR(255) NOT NULL,
model_version VARCHAR(32) NULL, -- NULL = 모든 버전 wildcard
model_alias VARCHAR(64) NULL, -- NULL = 모든 alias wildcard
subject_type VARCHAR(32) NOT NULL -- CHECK ('user','group','service_account')
CHECK (subject_type IN ('user','group','service_account')),
subject_id VARCHAR(255) NOT NULL, -- user=이메일 로컬파트(safe_username), group=path, SA=client id
action VARCHAR(32) NOT NULL -- CHECK ('invoke','deploy','manage')
CHECK (action IN ('invoke','deploy','manage')),
condition_expr TEXT NULL, -- M2 ABAC expression
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NULL, -- NULL = never expires
granted_by VARCHAR(255) NOT NULL, -- audit (Keycloak sub)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (workspace_id, model_name, model_alias, subject_type, subject_id, action)
);
CREATE INDEX idx_model_grant_lookup ON model_grant (model_name, subject_id, action);
user 는 이메일 로컬파트 (#2388)런타임 invoke 게이트는 caller 의 subject_id 를 TokenPayload.safe_username — 즉 이메일 로컬파트 (@도메인 제거, #816) — 로 판정한다 (routers/serving.py _caller_user_id → PredictCaller.user_id → check_access). 따라서 subject_type='user' grant 를 전체 이메일(sehoi.kim@genon.ai)로 발급하면 런타임 subject(sehoi.kim)와 매칭되지 않아 조용히 fail-closed 되고, /check 평가 패널은 매칭 불가한 저장 행에 대해 허위 allowed=true 를 반환한다.
- 정규화 지점:
ModelGrantService(create_grant/check_access/check_access_batch) 가usersubject_id 를 로컬파트로 정규화한다 (단일 진실원천). 발급·평가·배포 invokable 표시가 모두 런타임과 일치. group: Keycloak group path (/tenants/lng-ops) — 별도 group axis 에서 verbatim 매칭, 정규화 안 함.service_account: Keycloak client id —@도메인이 없어 정규화 대상 아님 (user 만 적용).- UI: 발급 폼 / 권한평가 패널이
user+@입력 시 로컬파트로 자동 변환하고 안내를 표시. - 마이그레이션: 기존 이메일 행은 prod 는
init_db(_PG_POST_MIGRATIONS: model_grant_user_subject_localpart, 멱등 dedup+정규화)로, dev/CI/패키징은 Alembic revisionmgsubjnorm2388으로 로컬파트로 정렬된다.
granted_by 는 이미 safe_username(로컬파트)로 stamping 되므로, 이 정규화로 subject 와 granted_by 형식이 일관된다.
Wildcard semantics:
model_alias IS NULL— grant 는 모든 alias 의 요청에 대해 매칭 (Production / Staging / Champion 무관).model_alias = 'Production'— Production 요청만 매칭. Staging 요청은 거부.- 요청 측에서
model_alias=None(alias 미지정) 으로 들어오면 wildcard 행만 매칭. (specific alias 행은 매칭 안 됨) model_version은 PoC M1 에서 evaluator 가 평가하지 않는다 — 테이블에만 저장. M2 에서 평가 추가 예정.
2. ABAC 평가기 — ModelGrantService.check_access
# apps/api/src/gend_api/services/model_grant_service.py
allowed = await service.check_access(
workspace_id=caller_workspace_id, # None = admin (no fence)
model_name="credit_risk",
subject_type="user",
subject_id="alice", # user=이메일 로컬파트(safe_username). "alice@corp"도 로컬파트로 정규화됨 (#2388)
action="invoke",
model_alias="Production",
)
매칭 조건 (모두 AND):
model_name일치action일치subject_type+subject_id일치 —user는 이메일 로컬파트로 정규화 후 매칭 (#2388),group은 멤버십 axis 매칭- alias axis: 행이 wildcard (NULL) 이거나 요청 alias 와 정확 일치
- workspace fence: 행이 caller 의 workspace 이거나 legacy NULL 행 (단,
workspace_id=None요청은 fence 비활성) - expiry:
expires_at IS NULL이거나 미래
Fail-closed: 빈 input (model_name='' / subject_id='' 등) 은 즉시 False.
3. API endpoint (read)
GET /api/v1/model-grants
?limit=50&offset=0
→ {"items": [...], "total": N}
GET /api/v1/model-grants/{id}
→ 200 ModelGrantRead | 404 | 403 (cross-tenant)
GET /api/v1/model-grants/check
?model=credit_risk
&subject_type=user
&subject_id=alice-sub
&action=invoke
&model_alias=Production # optional
→ {"allowed": true | false}
Workspace fence (Epic #1018):
- Caller
tenant_slug(Keycloak group/tenants/{slug}) →Workspace.id매핑 - list / get / check 모두 caller workspace + legacy NULL 행만 visible
- Admin (realm role
admin) 은 모든 workspace 의 행 visible tenant_slug가 JWT 에 있지만 Workspace row 가 없으면 fail-closed 403 (silent fall-through 금지 — PR #1095 패턴)
4. API endpoint (mutation — M2)
POST /api/v1/model-grants # admin only
body: ModelGrantCreate
→ 201 ModelGrantRead | 403 | 409 (UNIQUE 위반) | 422
PUT /api/v1/model-grants/{id} # admin only
body: ModelGrantUpdate (action / expires_at / condition_expr 만)
→ 200 ModelGrantRead | 403 | 404 | 422
DELETE /api/v1/model-grants/{id} # admin only
→ 204 | 403 | 404
정책 요약 (mutation 3종 공통):
- 모두
require_admin— viewer / analyst 는 403. granted_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, model_name, model_alias, subject_type, subject_id, action)위반 → 409.- PUT 의 immutable 필드 (
model_name,subject_id,model_alias,model_version,subject_type,workspace_id) → 422 (extra='forbid'). - DELETE 는 hard delete — M3 에서
deleted_attombstone 으로 전환 예정.
curl 예제
TOKEN="<admin keycloak access token>"
API="https://gend.genon.ai/api/v1/model-grants"
# 1) POST — 신규 grant 발급
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model_name": "anomaly-lng",
"model_alias": "Production",
"subject_type": "user",
"subject_id": "alice",
"action": "invoke",
"expires_at": "2026-12-31T23:59:59+00:00"
}' "$API" | jq .
# subject_id 는 이메일 로컬파트(safe_username). 전체 이메일 "alice@genon.ai" 로 보내도
# 서버가 "alice" 로 정규화해 저장한다 (#2388) — 런타임 invoke subject 와 일치.
# 2) PUT — expires_at 연장
curl -sS -X PUT -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"expires_at": "2027-06-30T23:59:59+00:00"}' \
"$API/<grant-uuid>" | jq .
# 3) DELETE — revoke
curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" \
"$API/<grant-uuid>" -o /dev/null -w "%{http_code}\n"
# → 204
5. Audit trail (M2)
mutation 3종은 모두 gend.audit logger 에 structured line 을 emit 한다. action 별 prefix:
| action | prefix | 핵심 필드 |
|---|---|---|
| POST | model_grant.create | grant_id, workspace, model, alias, subject_type, subject_id, action, granted_by |
| PUT | model_grant.update | grant_id, model, changed=[...], previous={...}, user |
| DELETE | model_grant.revoke | grant_id, model, alias, subject_*, action, granted_by, revoked_by |
DELETE 의 audit line 은 granted_by (원래 발급자) 와 revoked_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 "model_grant\."
# OpenSearch (M3 통합 후 — Phase 5)
# index pattern: gend-audit-*
# filter: message:"model_grant.create" OR message:"model_grant.update" OR message:"model_grant.revoke"
운영 절차 (M2 — mutation API 기반)
M1 은 mutation API 가 없어 DB 직접 INSERT 로 수행했지만, M2 부터는 admin token + POST /api/v1/model-grants 가 표준 경로다. DB 직접 INSERT 는 회귀 / DR 케이스에서만 사용한다 (audit emit 우회 — 권장하지 않음).
TOKEN="<platform-admin keycloak access token>"
# 1) grant 발급
curl -sS -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"model_name": "anomaly-lng",
"model_alias": "Production",
"subject_type": "user",
"subject_id": "alice",
"action": "invoke"
}' "https://gend.genon.ai/api/v1/model-grants" | jq .
# subject_id: user 는 이메일 로컬파트. "alice@genon.ai" 로 보내도 "alice" 로 정규화 (#2388).
# 2) 평가 — alice 가 anomaly-lng Production 을 invoke 할 수 있는가?
# (전체 이메일로 조회해도 서버가 로컬파트로 정규화해 런타임과 동일하게 평가)
curl -sS -H "Authorization: Bearer $TOKEN" \
"https://gend.genon.ai/api/v1/model-grants/check?\
model=anomaly-lng&subject_type=user&\
subject_id=alice&action=invoke&model_alias=Production"
# → {"allowed": true}
# 3) audit log 확인
kubectl -n gend logs -l app=gend-api --tail=200 | grep "model_grant\.create"
테스트
cd apps/api && .venv/bin/python -m pytest \
tests/test_model_grant_service.py \
tests/test_model_grant_router.py \
tests/test_db_models_package.py -v
# 25 service + 25 router + 4 package guard = 54 tests
다음 단계 (M3)
- KServe
/predictproxy —POST /api/v1/serving/{name}/predict가check_access후 KServe endpoint 로 forward, 거부 시 403 + auditdenied - UI —
/admin/model-grants페이지 (List + Dialog), 사용자별 모델 access matrix - HMAC audit_chain 통합 —
gend.auditlogger emit → OpenSearch + HMAC chain (project_audit_hmac_chain) - Keycloak group sync —
subject_type='group'행이 그룹 멤버 모두에게 transitively 적용되도록 group membership lookup 통합 - condition_expr 평가기 —
clearance >= confidential같은 ABAC expression 평가 - Soft delete + tombstone —
deleted_at/revoked_at/revoked_by컬럼 추가 (Alembic), DELETE 가 hard delete → soft delete 로 전환