본문으로 건너뛰기

Model Access Control — M2

Theme G (#1154) → G-2 GAP #1214M2 (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 멤버십placeholderplaceholderM3 PR — group sync 통합
Soft deletehard deletehard delete (M2 도 동일)deleted_at tombstone 으로 전환

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

  • POST /api/v1/serving/{name}/predict proxy — 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.audit logger 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);
subject_id 정규화 — user 는 이메일 로컬파트 (#2388)

런타임 invoke 게이트는 caller 의 subject_idTokenPayload.safe_username — 즉 이메일 로컬파트 (@도메인 제거, #816) — 로 판정한다 (routers/serving.py _caller_user_idPredictCaller.user_idcheck_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) 가 user subject_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 revision mgsubjnorm2388 으로 로컬파트로 정렬된다.

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):

  1. model_name 일치
  2. action 일치
  3. subject_type + subject_id 일치 — user 는 이메일 로컬파트로 정규화 후 매칭 (#2388), group 은 멤버십 axis 매칭
  4. alias axis: 행이 wildcard (NULL) 이거나 요청 alias 와 정확 일치
  5. workspace fence: 행이 caller 의 workspace 이거나 legacy NULL 행 (단, workspace_id=None 요청은 fence 비활성)
  6. 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 도 서버가 caller tenant_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_at tombstone 으로 전환 예정.

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:

actionprefix핵심 필드
POSTmodel_grant.creategrant_id, workspace, model, alias, subject_type, subject_id, action, granted_by
PUTmodel_grant.updategrant_id, model, changed=[...], previous={...}, user
DELETEmodel_grant.revokegrant_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)

  1. KServe /predict proxyPOST /api/v1/serving/{name}/predictcheck_access 후 KServe endpoint 로 forward, 거부 시 403 + audit denied
  2. UI/admin/model-grants 페이지 (List + Dialog), 사용자별 모델 access matrix
  3. HMAC audit_chain 통합gend.audit logger emit → OpenSearch + HMAC chain (project_audit_hmac_chain)
  4. Keycloak group syncsubject_type='group' 행이 그룹 멤버 모두에게 transitively 적용되도록 group membership lookup 통합
  5. condition_expr 평가기clearance >= confidential 같은 ABAC expression 평가
  6. Soft delete + tombstonedeleted_at / revoked_at / revoked_by 컬럼 추가 (Alembic), DELETE 가 hard delete → soft delete 로 전환