diff --git a/README.md b/README.md
index 83fc799a..08345022 100644
--- a/README.md
+++ b/README.md
@@ -221,6 +221,10 @@ scripts/verify.sh # checkstyle + mvn verify(전체 테스트) + 의존성
| `PICKLE_RELAY_FIRST_CONTACT_GRACE_SECONDS` | 활성 릴레이가 첫 동기화 없이 허용되는 시간(초과 시 미접속 알림) | `900` |
| `PICKLE_RELAY_MAX_SYNC_BODY_BYTES` | 동기화 요청 본문 상한 | `1048576` |
| `PICKLE_RELAY_RESTRICTED_SOURCE_IPS` | 릴레이 동기화 경로 외 접근이 차단되는 출발지 목록(쉼표 구분) | `10.100.100.1` |
+| `PICKLE_LLM_GATEWAY_TOKEN` / `_PREVIOUS_TOKEN` | LLM 게이트웨이 `/internal/llm` 공유 bearer(교체 중에는 이전 값도 병행 허용). 비면 전 요청 거부 | 없음 |
+| `PICKLE_LLM_GATEWAY_SOURCE_IP` | `/internal/llm` 허용 출발지 | `172.30.1.40` |
+| `PICKLE_LLM_{SYNC,USAGE,BODIES}_RATE_LIMIT` | `/internal/llm` 하위 경로별 분당 한도(버킷 분리) | `60` / `120` / `120` |
+| `PICKLE_LLM_MAX_{SYNC,USAGE,BODIES}_BODY_BYTES` | `/internal/llm` 하위 경로별 요청 본문 상한 | `65536` / `4194304` / `8388608` |
### 시드 계정 (dev/test 전용, 멱등)
diff --git a/contract/openapi.yaml b/contract/openapi.yaml
index 7dd35860..565bff6a 100644
--- a/contract/openapi.yaml
+++ b/contract/openapi.yaml
@@ -869,6 +869,39 @@ components:
- "orgHeadroom"
- "workspace"
type: "object"
+ ApproveLlmKeyRequestSpec:
+ properties:
+ grantedConcurrency:
+ description: "부여 동시 요청 수. 비우면 서비스 기본값이 적용됩니다."
+ format: "int32"
+ maximum: 100
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ grantedDailyTokens:
+ description: "부여 일일 토큰 수. 비우면 서비스 기본값이 적용됩니다."
+ format: "int64"
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ grantedRpm:
+ description: "부여 분당 요청 수. 비우면 서비스 기본값이 적용됩니다."
+ format: "int32"
+ maximum: 10000
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ grantedTpm:
+ description: "부여 분당 토큰 수. 비우면 서비스 기본값이 적용됩니다."
+ format: "int32"
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ type: "object"
ApproveRequestRequest:
properties:
comment:
@@ -887,6 +920,10 @@ components:
type:
- "string"
- "null"
+ llmKey:
+ anyOf:
+ - $ref: "#/components/schemas/ApproveLlmKeyRequestSpec"
+ - type: "null"
vm:
anyOf:
- $ref: "#/components/schemas/ApproveVmRequestSpec"
@@ -1247,6 +1284,38 @@ components:
- "ports"
- "purpose"
type: "object"
+ CreateLlmKeyRequestSpec:
+ properties:
+ reqDailyTokens:
+ description: "희망 일일 토큰 수. 비우면 서비스 기본값을 받습니다."
+ format: "int64"
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ reqRpm:
+ description: "희망 분당 요청 수. 비우면 서비스 기본값을 받습니다."
+ format: "int32"
+ maximum: 10000
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ reqTpm:
+ description: "희망 분당 토큰 수. 비우면 서비스 기본값을 받습니다."
+ format: "int32"
+ minimum: 1
+ type:
+ - "integer"
+ - "null"
+ usagePlan:
+ description: "이 Key를 어디에 쓸지. 기본 한도로 충분하면 비워 두어도 됩니다."
+ maxLength: 2000
+ minLength: 0
+ type:
+ - "string"
+ - "null"
+ type: "object"
CreateOrgRequest:
properties:
description:
@@ -1295,6 +1364,10 @@ components:
type:
- "string"
- "null"
+ llmKey:
+ anyOf:
+ - $ref: "#/components/schemas/CreateLlmKeyRequestSpec"
+ - type: "null"
orgId:
format: "uuid"
type: "string"
@@ -1821,6 +1894,30 @@ components:
- "id"
- "name"
type: "object"
+ IssuedLlmKeyResponse:
+ properties:
+ expiresAt:
+ description: "만료 시각. 없으면 만료되지 않습니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ id:
+ description: "키 식별자"
+ format: "uuid"
+ type: "string"
+ name:
+ description: "키 이름"
+ type: "string"
+ token:
+ description: "발급된 API Key 평문. **이 응답에서만 볼 수 있습니다** — 서버에는 해시만 저장되며 다시 조회\
+ 할 수 없습니다. 분실하면 재발급해야 합니다."
+ type: "string"
+ required:
+ - "id"
+ - "name"
+ - "token"
+ type: "object"
LiveCoverage:
properties:
memoryMeasuredNodeCount:
@@ -1840,6 +1937,239 @@ components:
- "nodeCount"
- "storageMeasuredNodeCount"
type: "object"
+ LlmApiKeyStatus:
+ enum:
+ - "PENDING"
+ - "ACTIVE"
+ - "SUSPENDED"
+ - "REVOKED"
+ - "EXPIRED"
+ type: "string"
+ LlmKeyDetailResponse:
+ properties:
+ accessManageAllowed:
+ description: "접근 권한 목록을 관리할 수 있는지"
+ type: "boolean"
+ concurrency:
+ description: "동시 요청 한도. null이면 게이트웨이 기본값을 따릅니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ createdAt:
+ format: "date-time"
+ type: "string"
+ expiresAt:
+ description: "만료 시각. null이면 만료가 없습니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ id:
+ format: "uuid"
+ type: "string"
+ lastUsedAt:
+ description: "마지막 사용 시각. 게이트웨이가 배치로 보고하므로 지연될 수 있습니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ myResourceRole:
+ anyOf:
+ - $ref: "#/components/schemas/ResourceRole"
+ - type: "null"
+ description: "요청자가 이 키의 접근 목록에서 받은 등급"
+ name:
+ description: "키 이름"
+ type: "string"
+ purpose:
+ description: "용도"
+ type:
+ - "string"
+ - "null"
+ recordBodies:
+ description: "프롬프트·응답 본문 기록 여부"
+ type: "boolean"
+ revokedAt:
+ description: "회수된 시각. 회수되지 않았으면 null입니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ rpm:
+ description: "분당 요청 한도. null이면 게이트웨이 기본값을 따릅니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ status:
+ $ref: "#/components/schemas/LlmApiKeyStatus"
+ tokenPrefix:
+ description: "평문 앞부분 — 두 키를 구별하기 위한 값입니다. 아직 발급 전이면 null입니다."
+ type:
+ - "string"
+ - "null"
+ tpm:
+ description: "분당 토큰 한도. null이면 게이트웨이 기본값을 따릅니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ workspaceId:
+ description: "소유 워크스페이스. 행이 사라진 경우에만 null입니다."
+ format: "uuid"
+ type:
+ - "string"
+ - "null"
+ workspaceName:
+ type: "string"
+ required:
+ - "accessManageAllowed"
+ - "createdAt"
+ - "id"
+ - "name"
+ - "recordBodies"
+ - "status"
+ - "workspaceName"
+ type: "object"
+ LlmKeyRequestSpecResponse:
+ properties:
+ grantedConcurrency:
+ description: "부여 동시 요청 수. 비어 있으면 서비스 기본값입니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ grantedDailyTokens:
+ description: "부여 일일 토큰 수. 비어 있으면 서비스 기본값입니다."
+ format: "int64"
+ type:
+ - "integer"
+ - "null"
+ grantedRpm:
+ description: "부여 분당 요청 수. 비어 있으면 서비스 기본값입니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ grantedTpm:
+ description: "부여 분당 토큰 수. 비어 있으면 서비스 기본값입니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ reqDailyTokens:
+ description: "희망 일일 토큰 수"
+ format: "int64"
+ type:
+ - "integer"
+ - "null"
+ reqRpm:
+ description: "희망 분당 요청 수"
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ reqTpm:
+ description: "희망 분당 토큰 수"
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ usagePlan:
+ description: "사용 계획"
+ type:
+ - "string"
+ - "null"
+ type: "object"
+ LlmKeySummaryResponse:
+ properties:
+ accessLimited:
+ description: "true면 이 키의 접근 권한이 없어 이름·상태·소유자만 표시됩니다."
+ type: "boolean"
+ accessManageAllowed:
+ description: "접근 권한이 없어도 접근 권한 목록을 관리할 수 있는지. 워크스페이스 소유자가 참입니다."
+ type: "boolean"
+ concurrency:
+ description: "동시 요청 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ createdAt:
+ format: "date-time"
+ type: "string"
+ expiresAt:
+ description: "만료 시각. null이면 만료가 없습니다. 접근 권한이 없으면 생략됩니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ id:
+ format: "uuid"
+ type: "string"
+ lastUsedAt:
+ description: "마지막 사용 시각. 게이트웨이가 배치로 보고하므로 지연될 수 있습니다. 접근 권한이 없으면 생략됩니다."
+ format: "date-time"
+ type:
+ - "string"
+ - "null"
+ name:
+ description: "키 이름"
+ type: "string"
+ ownerNames:
+ description: "이 키의 소유자 이름. 접근을 요청할 상대입니다."
+ items:
+ type: "string"
+ type: "array"
+ purpose:
+ description: "용도. 접근 권한이 없으면 생략됩니다."
+ type:
+ - "string"
+ - "null"
+ recordBodies:
+ description: "프롬프트·응답 본문 기록 여부. 접근 권한이 없으면 생략됩니다."
+ type:
+ - "boolean"
+ - "null"
+ rpm:
+ description: "분당 요청 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ status:
+ $ref: "#/components/schemas/LlmApiKeyStatus"
+ tokenPrefix:
+ description: "평문 앞부분 — 목록에서 두 키를 구별하기 위한 값입니다. 접근 권한이 없거나 아직 발급 전이면 생략됩니\
+ 다."
+ type:
+ - "string"
+ - "null"
+ tpm:
+ description: "분당 토큰 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다."
+ format: "int32"
+ type:
+ - "integer"
+ - "null"
+ workspaceId:
+ description: "소유 워크스페이스. 행이 사라진 경우에만 null입니다."
+ format: "uuid"
+ type:
+ - "string"
+ - "null"
+ workspaceName:
+ type: "string"
+ required:
+ - "accessLimited"
+ - "accessManageAllowed"
+ - "createdAt"
+ - "id"
+ - "name"
+ - "ownerNames"
+ - "status"
+ - "workspaceName"
+ type: "object"
LoginRequest:
properties:
email:
@@ -2684,11 +3014,11 @@ components:
- "totalElements"
- "totalPages"
type: "object"
- PageResponseNotificationView:
+ PageResponseLlmKeySummaryResponse:
properties:
content:
items:
- $ref: "#/components/schemas/NotificationView"
+ $ref: "#/components/schemas/LlmKeySummaryResponse"
type: "array"
page:
format: "int32"
@@ -2709,11 +3039,11 @@ components:
- "totalElements"
- "totalPages"
type: "object"
- PageResponseRequestDetailResponse:
+ PageResponseNotificationView:
properties:
content:
items:
- $ref: "#/components/schemas/RequestDetailResponse"
+ $ref: "#/components/schemas/NotificationView"
type: "array"
page:
format: "int32"
@@ -2734,11 +3064,11 @@ components:
- "totalElements"
- "totalPages"
type: "object"
- PageResponseResourceSummaryResponse:
+ PageResponseRequestDetailResponse:
properties:
content:
items:
- $ref: "#/components/schemas/ResourceSummaryResponse"
+ $ref: "#/components/schemas/RequestDetailResponse"
type: "array"
page:
format: "int32"
@@ -2759,11 +3089,36 @@ components:
- "totalElements"
- "totalPages"
type: "object"
- PageResponseUserAdminViewResponse:
+ PageResponseResourceSummaryResponse:
properties:
content:
items:
- $ref: "#/components/schemas/UserAdminViewResponse"
+ $ref: "#/components/schemas/ResourceSummaryResponse"
+ type: "array"
+ page:
+ format: "int32"
+ type: "integer"
+ size:
+ format: "int32"
+ type: "integer"
+ totalElements:
+ format: "int64"
+ type: "integer"
+ totalPages:
+ format: "int32"
+ type: "integer"
+ required:
+ - "content"
+ - "page"
+ - "size"
+ - "totalElements"
+ - "totalPages"
+ type: "object"
+ PageResponseUserAdminViewResponse:
+ properties:
+ content:
+ items:
+ $ref: "#/components/schemas/UserAdminViewResponse"
type: "array"
page:
format: "int32"
@@ -3082,6 +3437,10 @@ components:
id:
format: "uuid"
type: "string"
+ llmKey:
+ anyOf:
+ - $ref: "#/components/schemas/LlmKeyRequestSpecResponse"
+ - type: "null"
orgId:
description: "신청 대상 기관. 행이 사라진 경우에만 null입니다."
format: "uuid"
@@ -3408,6 +3767,7 @@ components:
ResourceType:
enum:
- "VM"
+ - "LLM_API_KEY"
type: "string"
Resources:
properties:
@@ -3880,6 +4240,29 @@ components:
format: "int32"
type: "integer"
type: "object"
+ UpdateLlmKeyRequest:
+ properties:
+ name:
+ description: "키 이름. 생략하면 그대로 둡니다."
+ maxLength: 100
+ minLength: 1
+ type:
+ - "string"
+ - "null"
+ purpose:
+ description: "사용 목적. 생략하면 그대로 둡니다."
+ maxLength: 2000
+ minLength: 0
+ type:
+ - "string"
+ - "null"
+ recordBodies:
+ description: "요청·응답 본문 기록 여부. 기본값은 꺼짐이며, 켜면 이 키로 보낸 프롬프트와 응답이 수집됩니다. 생략하\
+ 면 그대로 둡니다."
+ type:
+ - "boolean"
+ - "null"
+ type: "object"
UpdateNodeStatusRequest:
properties:
status:
@@ -5019,7 +5402,7 @@ info:
description: "부산대학교 클라우드 플랫폼 Pickle의 REST API. 인증은 JWT Bearer, 오류 응답은 RFC 9457 problem+json(Problem\
\ 스키마)을 따릅니다."
title: "Pickle API"
- version: "0.39.0"
+ version: "0.40.0"
openapi: "3.1.0"
paths:
/admin/announcements:
@@ -7662,6 +8045,350 @@ paths:
description: "오류 — 상태 코드와 무관하게 Problem 형태"
tags:
- "publishing-controller"
+ /llm-keys:
+ get:
+ description: "내가 속한 워크스페이스의 키를 보여 줍니다. 접근 권한이 없는 키는 이름·상태·소유자만 담긴 제한된 행으로 표시\
+ 됩니다."
+ operationId: "listLlmKeys"
+ parameters:
+ - in: "query"
+ name: "workspaceId"
+ required: false
+ schema:
+ format: "uuid"
+ type: "string"
+ - in: "query"
+ name: "page"
+ required: false
+ schema:
+ default: 0
+ format: "int32"
+ minimum: 0
+ type: "integer"
+ - in: "query"
+ name: "size"
+ required: false
+ schema:
+ default: 20
+ format: "int32"
+ maximum: 100
+ minimum: 1
+ type: "integer"
+ responses:
+ "200":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/PageResponseLlmKeySummaryResponse"
+ description: "OK"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "LLM API 키 목록"
+ tags:
+ - "llm-keys"
+ /llm-keys/{keyId}:
+ get:
+ description: "접근 권한이 있는 키의 상세입니다. 키 평문과 그 해시는 어떤 응답에도 담기지 않습니다."
+ operationId: "getLlmKey"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ responses:
+ "200":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/LlmKeyDetailResponse"
+ description: "OK"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "LLM API 키 상세"
+ tags:
+ - "llm-keys"
+ patch:
+ description: "키 이름과 사용 목적, 본문 기록 여부를 바꿉니다. 생략한 항목은 그대로 둡니다."
+ operationId: "updateLlmKey"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateLlmKeyRequest"
+ required: true
+ responses:
+ "204":
+ description: "No Content"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "LLM API 키 수정"
+ tags:
+ - "llm-keys"
+ /llm-keys/{keyId}/access:
+ get:
+ description: "이 키의 접근 권한 전체와, 그 목록이 어느 키의 것인지 알려 주는 최소 정보입니다. 키 소유자와 워크스페이스\
+ \ 소유자만 볼 수 있습니다."
+ operationId: "listLlmKeyAccessGrants"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ responses:
+ "200":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/ResourceAccessListResponse"
+ description: "OK"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "접근 권한 목록"
+ tags:
+ - "llm-key-access"
+ post:
+ description: "지정한 사용자 또는 소유 워크스페이스 전체에 이 키의 접근 권한을 부여합니다. 사용자는 이 키를 소유한 워크스페\
+ 이스의 구성원이어야 하고, 워크스페이스 전체에는 참여자·열람자까지만 부여할 수 있습니다."
+ operationId: "addLlmKeyAccessGrant"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\
+ 거나 만료·무효면 403 REAUTH_REQUIRED."
+ in: "header"
+ name: "X-Reauth-Token"
+ required: false
+ schema:
+ type: "string"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/AddResourceAccessGrantRequest"
+ required: true
+ responses:
+ "201":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/ResourceAccessGrantView"
+ description: "Created"
+ "403":
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "재인증 필요 — 유효한 X-Reauth-Token 없음 (`REAUTH_REQUIRED`)"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "접근 권한 부여"
+ tags:
+ - "llm-key-access"
+ /llm-keys/{keyId}/access/{grantId}:
+ delete:
+ description: "회수해도 발급 시 이미 확인한 키 평문은 회수되지 않습니다. 필요하면 키를 재발급해 주세요."
+ operationId: "removeLlmKeyAccessGrant"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - in: "path"
+ name: "grantId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\
+ 거나 만료·무효면 403 REAUTH_REQUIRED."
+ in: "header"
+ name: "X-Reauth-Token"
+ required: false
+ schema:
+ type: "string"
+ responses:
+ "204":
+ description: "No Content"
+ "403":
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "재인증 필요 — 유효한 X-Reauth-Token 없음 (`REAUTH_REQUIRED`)"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "접근 권한 회수"
+ tags:
+ - "llm-key-access"
+ patch:
+ operationId: "updateLlmKeyAccessGrant"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - in: "path"
+ name: "grantId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\
+ 거나 만료·무효면 403 REAUTH_REQUIRED."
+ in: "header"
+ name: "X-Reauth-Token"
+ required: false
+ schema:
+ type: "string"
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateResourceAccessGrantRequest"
+ required: true
+ responses:
+ "200":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/ResourceAccessGrantView"
+ description: "OK"
+ "403":
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "재인증 필요 — 유효한 X-Reauth-Token 없음 (`REAUTH_REQUIRED`)"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "접근 권한 등급 변경"
+ tags:
+ - "llm-key-access"
+ /llm-keys/{keyId}/revoke:
+ post:
+ description: "이 키를 폐기합니다. 게이트웨이에는 폴링 주기 안에 반영되고, 이후 이 키로 보낸 요청은 '폐기된 키'로 거부됩\
+ 니다. 사용 기록은 남습니다."
+ operationId: "revokeLlmKey"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\
+ 거나 만료·무효면 403 REAUTH_REQUIRED."
+ in: "header"
+ name: "X-Reauth-Token"
+ required: false
+ schema:
+ type: "string"
+ responses:
+ "204":
+ description: "No Content"
+ "403":
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "재인증 필요 — 유효한 X-Reauth-Token 없음 (`REAUTH_REQUIRED`)"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "LLM API 키 폐기"
+ tags:
+ - "llm-keys"
+ /llm-keys/{keyId}/token:
+ post:
+ description: "이 키의 평문을 만들어 **한 번만** 돌려줍니다. 서버에는 해시만 남으므로 다시 조회할 수 없고, 분실하면 이\
+ \ 호출을 다시 해서 재발급해야 합니다. 재발급하면 이전 값은 곧바로 쓸 수 없게 됩니다."
+ operationId: "issueLlmKeyToken"
+ parameters:
+ - in: "path"
+ name: "keyId"
+ required: true
+ schema:
+ format: "uuid"
+ type: "string"
+ - description: "재인증(sudo-mode) 토큰 — POST /auth/reverify가 발급 (10분 유효, 다회용). 없\
+ 거나 만료·무효면 403 REAUTH_REQUIRED."
+ in: "header"
+ name: "X-Reauth-Token"
+ required: false
+ schema:
+ type: "string"
+ responses:
+ "200":
+ content:
+ '*/*':
+ schema:
+ $ref: "#/components/schemas/IssuedLlmKeyResponse"
+ description: "OK"
+ "403":
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "재인증 필요 — 유효한 X-Reauth-Token 없음 (`REAUTH_REQUIRED`)"
+ default:
+ content:
+ application/problem+json:
+ schema:
+ $ref: "#/components/schemas/Problem"
+ description: "오류 — 상태 코드와 무관하게 Problem 형태"
+ summary: "LLM API 키 발급"
+ tags:
+ - "llm-keys"
/me:
get:
operationId: "me"
@@ -9585,3 +10312,8 @@ servers:
tags:
- description: "VM 접근 권한 — 이 VM에 누가 접근할 수 있는지를 정합니다."
name: "vm-access"
+- description: "LLM API 키 접근 권한 — 이 키에 누가 접근할 수 있는지를 정합니다."
+ name: "llm-key-access"
+- description: "LLM API 키 — 워크스페이스가 보유한 키의 목록과 상세입니다. 키 평문은 발급 시 한 번만 표시되며 여기서는 다시\
+ \ 볼 수 없습니다."
+ name: "llm-keys"
diff --git a/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java
new file mode 100644
index 00000000..33d92926
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java
@@ -0,0 +1,102 @@
+package kr.ac.pusan.pickle.access;
+
+import static kr.ac.pusan.pickle.common.web.ClientIps.clientIp;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.validation.Valid;
+import java.util.UUID;
+import kr.ac.pusan.pickle.access.dto.AddResourceAccessGrantRequest;
+import kr.ac.pusan.pickle.access.dto.ResourceAccessGrantView;
+import kr.ac.pusan.pickle.access.dto.ResourceAccessListResponse;
+import kr.ac.pusan.pickle.access.dto.UpdateResourceAccessGrantRequest;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import kr.ac.pusan.pickle.security.RequireReauth;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * Contract tag {@code llm-key-access}: who may reach one LLM API key, and at
+ * what rung.
+ *
+ *
The rules are {@link ResourceAccessGrantService}'s and know nothing about
+ * keys; this class is the key's path into them, the same pass-through the VM
+ * has in {@link VmAccessGrantController}. What the type contributes — its
+ * refusal sentences and audit names — lives in {@link kr.ac.pusan.pickle.llm.LlmKeyResourceAdapter}
+ * until the key's resource adapter carries them.
+ */
+@Tag(name = "llm-key-access",
+ description = "LLM API 키 접근 권한 — 이 키에 누가 접근할 수 있는지를 정합니다.")
+@RestController
+@RequestMapping("/api/v1/llm-keys/{keyId}/access")
+public class LlmKeyAccessGrantController {
+
+ private final ResourceAccessGrantService service;
+
+ public LlmKeyAccessGrantController(ResourceAccessGrantService service) {
+ this.service = service;
+ }
+
+ @GetMapping
+ @Operation(summary = "접근 권한 목록",
+ description = "이 키의 접근 권한 전체와, 그 목록이 어느 키의 것인지 알려 주는 최소 정보입니다. "
+ + "키 소유자와 워크스페이스 소유자만 볼 수 있습니다.")
+ public ResourceAccessListResponse listLlmKeyAccessGrants(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId) {
+ return service.list(principal, ResourceType.LLM_API_KEY, keyId);
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ @RequireReauth
+ @Operation(summary = "접근 권한 부여",
+ description = "지정한 사용자 또는 소유 워크스페이스 전체에 이 키의 접근 권한을 부여합니다. "
+ + "사용자는 이 키를 소유한 워크스페이스의 구성원이어야 하고, 워크스페이스 전체에는 "
+ + "참여자·열람자까지만 부여할 수 있습니다.")
+ public ResourceAccessGrantView addLlmKeyAccessGrant(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId,
+ @Valid @RequestBody AddResourceAccessGrantRequest request,
+ HttpServletRequest httpRequest) {
+ return service.add(principal, ResourceType.LLM_API_KEY, keyId, request,
+ clientIp(httpRequest));
+ }
+
+ @PatchMapping("/{grantId}")
+ @RequireReauth
+ @Operation(summary = "접근 권한 등급 변경")
+ public ResourceAccessGrantView updateLlmKeyAccessGrant(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId,
+ @PathVariable UUID grantId,
+ @Valid @RequestBody UpdateResourceAccessGrantRequest request,
+ HttpServletRequest httpRequest) {
+ return service.update(principal, ResourceType.LLM_API_KEY, keyId, grantId, request,
+ clientIp(httpRequest));
+ }
+
+ @DeleteMapping("/{grantId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @RequireReauth
+ @Operation(summary = "접근 권한 회수",
+ description = "회수해도 발급 시 이미 확인한 키 평문은 회수되지 않습니다. "
+ + "필요하면 키를 재발급해 주세요.")
+ public void removeLlmKeyAccessGrant(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId,
+ @PathVariable UUID grantId,
+ HttpServletRequest httpRequest) {
+ service.remove(principal, ResourceType.LLM_API_KEY, keyId, grantId, clientIp(httpRequest));
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java
index 199f5c85..408d5000 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceAccessResolver.java
@@ -1,6 +1,15 @@
package kr.ac.pusan.pickle.access;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import kr.ac.pusan.pickle.user.User;
+import kr.ac.pusan.pickle.user.UserRepository;
import kr.ac.pusan.pickle.workspace.WorkspaceMember;
import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository;
import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole;
@@ -24,11 +33,13 @@ public class ResourceAccessResolver {
private final WorkspaceMemberRepository workspaceMemberRepository;
private final ResourceAccessGrantRepository grantRepository;
+ private final UserRepository userRepository;
public ResourceAccessResolver(WorkspaceMemberRepository workspaceMemberRepository,
- ResourceAccessGrantRepository grantRepository) {
+ ResourceAccessGrantRepository grantRepository, UserRepository userRepository) {
this.workspaceMemberRepository = workspaceMemberRepository;
this.grantRepository = grantRepository;
+ this.userRepository = userRepository;
}
/** Standing of one user on one resource of the workspace that owns it. */
@@ -43,6 +54,56 @@ public ResourceStanding standing(ResourceType type, long resourceId, long owning
membership != null, membership == WorkspaceMemberRole.OWNER);
}
+ /**
+ * Which of these resources the requester may see in full, and who to ask
+ * about the rest — one list view's worth of masking decisions.
+ *
+ * @param reachable the resources some grant opens for them
+ * @param ownerNames per resource, the names of its owners: who a member
+ * without a grant asks for one
+ */
+ public record ListAccess(Set reachable, Map> ownerNames) {
+ }
+
+ /**
+ * The batch form of {@link #standing} that list views run on: one grant
+ * query for a whole page instead of one per row, reduced to the only two
+ * answers a list needs. It lives here for the same reason {@code standing}
+ * does — reachability is the access rule, and a second copy of it per
+ * resource type would be a second policy within a release or two.
+ *
+ * Membership of the owning workspace is deliberately not re-checked per
+ * row: every caller builds its page from the requester's own workspace
+ * memberships, so each row already is a resource of a workspace they are in.
+ */
+ @Transactional(readOnly = true)
+ public ListAccess listAccess(ResourceType type, List resourceIds, long userId) {
+ if (resourceIds.isEmpty()) {
+ return new ListAccess(Set.of(), Map.of());
+ }
+ List grants =
+ grantRepository.findByResourceTypeAndResourceIdIn(type, resourceIds);
+ Set reachable = new HashSet<>();
+ Map> ownerIds = new LinkedHashMap<>();
+ for (ResourceAccessGrant grant : grants) {
+ if (grant.getGranteeType() == AccessGranteeType.WORKSPACE
+ || Long.valueOf(userId).equals(grant.getUserId())) {
+ reachable.add(grant.getResourceId());
+ }
+ if (grant.getRole() == ResourceRole.OWNER && grant.getUserId() != null) {
+ ownerIds.computeIfAbsent(grant.getResourceId(), key -> new ArrayList<>())
+ .add(grant.getUserId());
+ }
+ }
+ Map names = userRepository.findAllById(ownerIds.values().stream()
+ .flatMap(List::stream).distinct().toList()).stream()
+ .collect(Collectors.toMap(User::getId, User::getName));
+ Map> ownerNames = new LinkedHashMap<>();
+ ownerIds.forEach((resourceId, ids) -> ownerNames.put(resourceId, ids.stream()
+ .map(names::get).filter(Objects::nonNull).toList()));
+ return new ListAccess(reachable, ownerNames);
+ }
+
/**
* The strongest rung the access list gives this person: their own grant and
* the workspace-wide one, whichever is higher.
diff --git a/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java b/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java
index 31148629..a52cd334 100644
--- a/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java
+++ b/src/main/java/kr/ac/pusan/pickle/access/ResourceType.java
@@ -3,13 +3,14 @@
/**
* What kind of thing an access grant is attached to (DB enum {@code resource_type}).
*
- * Only VMs are wired to the access list today. Containers and LLM API keys
- * are decided additions that pose the same question — one object, its own set
- * of people — and each joins by adding a value here plus the adapter described
- * in the authorization design.
+ *
Every resource is owned by a workspace and carries the same access list, so
+ * a kind joins by adding a value here plus the adapter described in the
+ * authorization design — not by another authorization model. Containers are a
+ * decided addition on the same terms.
*/
public enum ResourceType {
- VM("VM");
+ VM("VM"),
+ LLM_API_KEY("LLM API 키");
private final String label;
diff --git a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveRequestRequest.java b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveRequestRequest.java
index 754cdabe..897f12cc 100644
--- a/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveRequestRequest.java
+++ b/src/main/java/kr/ac/pusan/pickle/admin/dto/ApproveRequestRequest.java
@@ -1,6 +1,7 @@
package kr.ac.pusan.pickle.admin.dto;
import jakarta.validation.Valid;
+import kr.ac.pusan.pickle.llm.dto.ApproveLlmKeyRequestSpec;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import org.jspecify.annotations.Nullable;
@@ -19,5 +20,12 @@ public record ApproveRequestRequest(
@Nullable String comment,
/** Required when approving a VM request, ignored otherwise. */
- @Valid @Nullable ApproveVmRequestSpec vm) {
+ @Valid @Nullable ApproveVmRequestSpec vm,
+
+ /**
+ * Required when approving an LLM API key request, ignored otherwise.
+ * May be empty: every limit on it is optional, and granting none is
+ * granting the service defaults, which is the ordinary decision.
+ */
+ @Valid @Nullable ApproveLlmKeyRequestSpec llmKey) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java
index 550b33b3..d75ed984 100644
--- a/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java
+++ b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java
@@ -61,6 +61,14 @@ public class AuditService {
public static final String VM_ACCESS_GRANT_REMOVE = "vm.access_grant_remove";
/** A workspace owner giving themselves a way inside — recorded apart from the ordinary grant. */
public static final String VM_ACCESS_BREAK_GLASS = "vm.access_break_glass";
+
+ public static final String LLM_KEY_ACCESS_GRANT_ADD = "llm_key.access_grant_add";
+ public static final String LLM_KEY_ACCESS_GRANT_UPDATE = "llm_key.access_grant_update";
+ public static final String LLM_KEY_ACCESS_GRANT_REMOVE = "llm_key.access_grant_remove";
+ public static final String LLM_KEY_ACCESS_BREAK_GLASS = "llm_key.access_break_glass";
+ public static final String LLM_KEY_ISSUE = "llm_key.issue";
+ public static final String LLM_KEY_REVOKE = "llm_key.revoke";
+ public static final String LLM_KEY_UPDATE = "llm_key.update";
// HTTP publishing.
public static final String VM_PUBLISH = "vm.publish";
public static final String DOMAIN_UPDATE = "domain.update";
@@ -122,6 +130,16 @@ public class AuditService {
public static final String PORT_MAPPING_UNSUSPEND = "port_mapping.unsuspend";
public static final String PORT_MAPPING_DELETE = "port_mapping.delete";
public static final String PORT_MAPPING_GUARDS_UPDATE = "port_mapping.guards_update";
+ // LLM gateway link (internal contract). The 5-second sync poll is never
+ // audited (it would flood the log); the one auditable event the poll can
+ // cause is the restored-backup counter raise below.
+ /**
+ * The gateway reported a generation above ours: this side's counter went
+ * backwards (restored backup) while the gateway's persisted high-water
+ * mark did not, so the counter was raised above the reported value and
+ * the full document served. Actor null.
+ */
+ public static final String LLM_GATEWAY_GENERATION_RAISE = "llm_gateway.generation_raise";
// 교내 IP requests (contract v0.27.0).
public static final String CAMPUS_IP_REQUEST = "campus_ip.request";
public static final String CAMPUS_IP_CANCEL = "campus_ip.cancel";
@@ -148,6 +166,8 @@ public class AuditService {
public static final String ACTOR_ROLE_SSHGW = "SSHGW";
/** Actor role stamped on relay-originated audits (sync violations, auto-suspend). */
public static final String ACTOR_ROLE_RELAY = "RELAY";
+ /** Actor role stamped on LLM-gateway-originated audits (no user identity). */
+ public static final String ACTOR_ROLE_LLM_GATEWAY = "LLM_GATEWAY";
private final JdbcTemplate jdbcTemplate;
private final ObjectMapper objectMapper;
diff --git a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
index 45d31f5f..77793e21 100644
--- a/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
+++ b/src/main/java/kr/ac/pusan/pickle/common/error/ErrorCodes.java
@@ -58,6 +58,8 @@ public final class ErrorCodes {
public static final String VM_INVALID_STATE = "VM_INVALID_STATE";
public static final String VM_CONFIRM_NAME_MISMATCH = "VM_CONFIRM_NAME_MISMATCH";
public static final String VM_ACCESS_GRANT_EXISTS = "VM_ACCESS_GRANT_EXISTS";
+ public static final String LLM_KEY_ACCESS_GRANT_EXISTS = "LLM_KEY_ACCESS_GRANT_EXISTS";
+ public static final String LLM_KEY_REVOKED = "LLM_KEY_REVOKED";
// VM protection settings (contract v0.9.0).
public static final String VM_DELETION_PROTECTED = "VM_DELETION_PROTECTED";
public static final String VM_STOP_PROTECTED = "VM_STOP_PROTECTED";
diff --git a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
index 69e2406a..6b16e7a7 100644
--- a/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
+++ b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java
@@ -41,7 +41,7 @@
public class OpenApiConfig {
/** Contract version served in {@code info.version}; bump on any contract change. */
- public static final String CONTRACT_VERSION = "0.39.0";
+ public static final String CONTRACT_VERSION = "0.40.0";
/** Name of the bearer-JWT security scheme in the published spec. */
private static final String BEARER_SCHEME = "bearerAuth";
diff --git a/src/main/java/kr/ac/pusan/pickle/common/text/Secrets.java b/src/main/java/kr/ac/pusan/pickle/common/text/Secrets.java
new file mode 100644
index 00000000..c7dff904
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/common/text/Secrets.java
@@ -0,0 +1,31 @@
+package kr.ac.pusan.pickle.common.text;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.HexFormat;
+
+/**
+ * The one hashing rule for secrets this platform issues and later recognizes.
+ *
+ *
Hex sha256 of the whole secret, lowercase. It is shared rather than
+ * repeated because two of the things that verify against it run in other
+ * processes — the relay agent and the LLM gateway both hash what a caller
+ * presented and compare — so a second implementation here would not fail
+ * loudly, it would just stop recognizing valid credentials.
+ */
+public final class Secrets {
+
+ private Secrets() {
+ }
+
+ /** Lowercase hex sha256 of the value, as stored and as compared. */
+ public static String sha256Hex(String value) {
+ try {
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable", e);
+ }
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/common/web/BodyCappingRequest.java b/src/main/java/kr/ac/pusan/pickle/common/web/BodyCappingRequest.java
new file mode 100644
index 00000000..783f775c
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/common/web/BodyCappingRequest.java
@@ -0,0 +1,83 @@
+package kr.ac.pusan.pickle.common.web;
+
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * Hard byte cap on a request body. A filter's Content-Length pre-check catches
+ * declared sizes; this wrapper is what bounds a chunked (undeclared-length)
+ * body — exceeding the cap aborts the read with
+ * {@link RequestBodyCapExceededException}, which the global handler maps to
+ * the same 413 as a declared-length violation. {@code getReader()} delegates
+ * through the same capped stream.
+ *
+ *
Shared by every internal surface that takes a machine-posted body behind
+ * its own filter chain (relay sync, the LLM gateway link); each caller picks
+ * its own cap.
+ */
+public final class BodyCappingRequest extends HttpServletRequestWrapper {
+
+ private final long cap;
+
+ public BodyCappingRequest(HttpServletRequest request, long cap) {
+ super(request);
+ this.cap = cap;
+ }
+
+ @Override
+ public java.io.BufferedReader getReader() throws IOException {
+ String encoding = getCharacterEncoding();
+ return new java.io.BufferedReader(new java.io.InputStreamReader(getInputStream(),
+ encoding != null ? encoding : StandardCharsets.UTF_8.name()));
+ }
+
+ @Override
+ public ServletInputStream getInputStream() throws IOException {
+ ServletInputStream delegate = super.getInputStream();
+ return new ServletInputStream() {
+ private long read;
+
+ private void count(long n) throws IOException {
+ if (n > 0) {
+ read += n;
+ if (read > cap) {
+ throw new RequestBodyCapExceededException(cap);
+ }
+ }
+ }
+
+ @Override
+ public int read() throws IOException {
+ int b = delegate.read();
+ count(b >= 0 ? 1 : 0);
+ return b;
+ }
+
+ @Override
+ public int read(byte[] b, int off, int len) throws IOException {
+ int n = delegate.read(b, off, len);
+ count(n);
+ return n;
+ }
+
+ @Override
+ public boolean isFinished() {
+ return delegate.isFinished();
+ }
+
+ @Override
+ public boolean isReady() {
+ return delegate.isReady();
+ }
+
+ @Override
+ public void setReadListener(ReadListener readListener) {
+ delegate.setReadListener(readListener);
+ }
+ };
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/config/InternalSecurityConfig.java b/src/main/java/kr/ac/pusan/pickle/config/InternalSecurityConfig.java
index 4e3dd805..89b25530 100644
--- a/src/main/java/kr/ac/pusan/pickle/config/InternalSecurityConfig.java
+++ b/src/main/java/kr/ac/pusan/pickle/config/InternalSecurityConfig.java
@@ -21,17 +21,18 @@
* rate limit); everything it lets through is already authorized, so the chain
* itself permits all.
*
- * Ordered one behind the relay chain ({@link RelaySecurityConfig}), which
- * carves {@code /internal/relays/**} out with its own per-relay auth. The
- * broad {@code /internal/**} matcher here is kept on purpose: any internal
- * path no more-specific chain claims still lands in this filter and fails
- * closed instead of falling through to the public chain.
+ * Ordered behind the relay chain ({@link RelaySecurityConfig}) and the LLM
+ * gateway chain ({@link LlmGatewaySecurityConfig}), which carve
+ * {@code /internal/relays/**} and {@code /internal/llm/**} out with their own
+ * auth. The broad {@code /internal/**} matcher here is kept on purpose: any
+ * internal path no more-specific chain claims still lands in this filter and
+ * fails closed instead of falling through to the public chain.
*/
@Configuration
public class InternalSecurityConfig {
@Bean
- @Order(Ordered.HIGHEST_PRECEDENCE + 1)
+ @Order(Ordered.HIGHEST_PRECEDENCE + 2)
SecurityFilterChain internalSecurityFilterChain(HttpSecurity http,
SshGatewayProperties sshGatewayProperties, RateLimitService rateLimitService,
ProblemJsonWriter problemJsonWriter) throws Exception {
diff --git a/src/main/java/kr/ac/pusan/pickle/config/LlmGatewayProperties.java b/src/main/java/kr/ac/pusan/pickle/config/LlmGatewayProperties.java
new file mode 100644
index 00000000..ee0f0ca0
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/config/LlmGatewayProperties.java
@@ -0,0 +1,83 @@
+package kr.ac.pusan.pickle.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * LLM gateway link settings ({@code pickle.llm-gateway.*}). The campus LLM
+ * gateway daemon (LXC 103) polls {@code POST /internal/llm/sync} for its
+ * authorization document and pushes usage/body batches to the sibling paths;
+ * the surface has its own filter chain, its own static bearer and one rate
+ * bucket per sub-path — nothing is shared with the sshgw filter, its global
+ * bucket, or the relay chain.
+ *
+ * The token has no default outside dev/test: when it is blank the
+ * {@code /internal/llm/**} chain fails closed (every request rejected)
+ * rather than accepting an empty bearer. {@code previousToken} exists from day
+ * one so a rotation needs no coordinated restart: set the new secret as
+ * {@code token}, move the old one to {@code previousToken}, restart the api,
+ * then reconfigure the gateway at leisure — both values authenticate during
+ * the overlap, and clearing {@code previousToken} ends it.
+ *
+ * One rate bucket per sub-path, never one for the link: sync polls
+ * every 5 seconds, bodies flush every 2 seconds, and usage fires a burst of
+ * consecutive POSTs whenever a backlog drains. A single bucket sized for sync
+ * would let a usage backlog 429 the authorization poll — the gateway would
+ * then sit on its last document while the api believes it is current.
+ *
+ * Body caps are per sub-path too: sync is a handful of gauges, a
+ * usage batch is up to 500 events, and the gateway caps its own body batch at
+ * 4 MiB before JSON transport overhead. A refused bodies batch is not retried,
+ * so a cap below what the gateway sends silently discards captured text —
+ * never lower these blindly.
+ *
+ * @param token static bearer secret from {@code PICKLE_LLM_GATEWAY_TOKEN}
+ * @param previousToken the outgoing secret during a rotation; blank when
+ * no rotation is in flight
+ * @param allowedSourceIp the only TCP peer allowed on {@code /internal/llm/**}
+ * (the LLM gateway LXC); defaults to 172.30.1.40
+ * @param syncRateLimitPerMinute sync-poll budget (default 60/min)
+ * @param usageRateLimitPerMinute usage-batch budget (default 120/min)
+ * @param bodiesRateLimitPerMinute bodies-batch budget (default 120/min)
+ * @param maxSyncBodyBytes sync body cap (default 64 KiB)
+ * @param maxUsageBodyBytes usage body cap (default 4 MiB)
+ * @param maxBodiesBodyBytes bodies body cap (default 8 MiB)
+ */
+@ConfigurationProperties(prefix = "pickle.llm-gateway")
+public record LlmGatewayProperties(
+ String token,
+ String previousToken,
+ String allowedSourceIp,
+ Integer syncRateLimitPerMinute,
+ Integer usageRateLimitPerMinute,
+ Integer bodiesRateLimitPerMinute,
+ Long maxSyncBodyBytes,
+ Long maxUsageBodyBytes,
+ Long maxBodiesBodyBytes) {
+
+ public LlmGatewayProperties {
+ allowedSourceIp = (allowedSourceIp != null && !allowedSourceIp.isBlank())
+ ? allowedSourceIp : "172.30.1.40";
+ syncRateLimitPerMinute = (syncRateLimitPerMinute != null && syncRateLimitPerMinute > 0)
+ ? syncRateLimitPerMinute : 60;
+ usageRateLimitPerMinute = (usageRateLimitPerMinute != null && usageRateLimitPerMinute > 0)
+ ? usageRateLimitPerMinute : 120;
+ bodiesRateLimitPerMinute = (bodiesRateLimitPerMinute != null && bodiesRateLimitPerMinute > 0)
+ ? bodiesRateLimitPerMinute : 120;
+ maxSyncBodyBytes = (maxSyncBodyBytes != null && maxSyncBodyBytes > 0)
+ ? maxSyncBodyBytes : 65_536L;
+ maxUsageBodyBytes = (maxUsageBodyBytes != null && maxUsageBodyBytes > 0)
+ ? maxUsageBodyBytes : 4_194_304L;
+ maxBodiesBodyBytes = (maxBodiesBodyBytes != null && maxBodiesBodyBytes > 0)
+ ? maxBodiesBodyBytes : 8_388_608L;
+ }
+
+ /** True when no bearer secret is configured — the filter must fail closed. */
+ public boolean tokenUnset() {
+ return token == null || token.isBlank();
+ }
+
+ /** True when a rotation overlap is in flight (an old secret still valid). */
+ public boolean previousTokenSet() {
+ return previousToken != null && !previousToken.isBlank();
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/config/LlmGatewaySecurityConfig.java b/src/main/java/kr/ac/pusan/pickle/config/LlmGatewaySecurityConfig.java
new file mode 100644
index 00000000..26f86571
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/config/LlmGatewaySecurityConfig.java
@@ -0,0 +1,44 @@
+package kr.ac.pusan.pickle.config;
+
+import kr.ac.pusan.pickle.auth.RateLimitService;
+import kr.ac.pusan.pickle.common.error.ProblemJsonWriter;
+import kr.ac.pusan.pickle.llm.LlmGatewayAuthFilter;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+
+/**
+ * Dedicated security chain for the LLM gateway surface
+ * ({@code /internal/llm/**}). Matched after the relay chain
+ * ({@link RelaySecurityConfig}) and strictly ahead of the broad
+ * {@code /internal/**} catch-all in {@link InternalSecurityConfig}, and shares
+ * nothing with either — no sshgw token, no sshgw source pin, no shared rate
+ * bucket. Access is decided solely by {@link LlmGatewayAuthFilter} (sub-path
+ * allowlist + source pin + static bearer with rotation overlap + per-sub-path
+ * rate bucket and body cap); everything it lets through is already authorized,
+ * so the chain itself permits all.
+ */
+@Configuration
+public class LlmGatewaySecurityConfig {
+
+ @Bean
+ @Order(Ordered.HIGHEST_PRECEDENCE + 1)
+ SecurityFilterChain llmGatewaySecurityFilterChain(HttpSecurity http,
+ LlmGatewayProperties llmGatewayProperties, RateLimitService rateLimitService,
+ ProblemJsonWriter problemJsonWriter) throws Exception {
+ LlmGatewayAuthFilter authFilter = new LlmGatewayAuthFilter(llmGatewayProperties,
+ rateLimitService, problemJsonWriter);
+ http
+ .securityMatcher("/internal/llm/**")
+ .csrf(csrf -> csrf.disable())
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
+ .addFilterBefore(authFilter, UsernamePasswordAuthenticationFilter.class);
+ return http.build();
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java
new file mode 100644
index 00000000..14c888bc
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java
@@ -0,0 +1,242 @@
+package kr.ac.pusan.pickle.llm;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import java.time.Instant;
+import java.util.UUID;
+import org.hibernate.annotations.JdbcTypeCode;
+import org.hibernate.type.SqlTypes;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * One issued LLM API key.
+ *
+ * The plaintext is shown once at issue and stored nowhere: what lives here
+ * is its sha256, which is also what the gateway computes from the token a
+ * student presents. A lost key is reissued, never recovered — the requirements
+ * say so, and it is the only claim this table can honestly make.
+ *
+ *
Owned by a workspace like every other resource, and reachable through the
+ * same access list, so who may use it is decided by grants rather than by a
+ * column here.
+ */
+@Entity
+@Table(name = "llm_api_keys")
+public class LlmApiKey {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+
+ @JdbcTypeCode(SqlTypes.UUID)
+ @Column(name = "public_id", nullable = false, updatable = false)
+ private UUID publicId;
+
+ @Column(name = "workspace_id", nullable = false)
+ private Long workspaceId;
+
+ @Column(name = "org_id", nullable = false)
+ private Long orgId;
+
+ @Column(name = "request_id", nullable = false)
+ private Long requestId;
+
+ @Column(nullable = false)
+ private String name;
+
+ @Column
+ private @Nullable String purpose;
+
+ /**
+ * Null until the owner issues; see {@link LlmApiKeyStatus#PENDING}.
+ *
+ *
The column is {@code char(64)}, and Hibernate validates the schema at
+ * startup: without the explicit CHAR mapping it expects varchar, the
+ * mismatch fails validation, and the whole application context refuses to
+ * build. {@code Relay.tokenHash} carries the same pair for the same reason.
+ */
+ @JdbcTypeCode(SqlTypes.CHAR)
+ @Column(name = "token_hash", length = 64)
+ private @Nullable String tokenHash;
+
+ @Column(name = "token_prefix")
+ private @Nullable String tokenPrefix;
+
+ @Enumerated(EnumType.STRING)
+ @JdbcTypeCode(SqlTypes.NAMED_ENUM)
+ @Column(nullable = false)
+ private LlmApiKeyStatus status = LlmApiKeyStatus.PENDING;
+
+ @Column(name = "expires_at")
+ private @Nullable Instant expiresAt;
+
+ /** Stamped from the usage the gateway ships, so it lags by a batch. */
+ @Column(name = "last_used_at")
+ private @Nullable Instant lastUsedAt;
+
+ @Column
+ private @Nullable Integer rpm;
+
+ @Column
+ private @Nullable Integer tpm;
+
+ @Column
+ private @Nullable Integer concurrency;
+
+ @Column(name = "record_bodies", nullable = false)
+ private boolean recordBodies;
+
+ @Column(name = "created_by", nullable = false)
+ private Long createdBy;
+
+ @Column(name = "created_at", nullable = false, updatable = false)
+ private Instant createdAt = Instant.now();
+
+ @Column(name = "updated_at", nullable = false)
+ private Instant updatedAt = Instant.now();
+
+ @Column(name = "revoked_at")
+ private @Nullable Instant revokedAt;
+
+ protected LlmApiKey() {
+ }
+
+ /** A key an approval created. The secret is minted later, by its owner. */
+ public LlmApiKey(long workspaceId, long orgId, long requestId, String name,
+ @Nullable String purpose, @Nullable Instant expiresAt, @Nullable Integer rpm,
+ @Nullable Integer tpm, @Nullable Integer concurrency, long createdBy) {
+ this.publicId = UUID.randomUUID();
+ this.workspaceId = workspaceId;
+ this.orgId = orgId;
+ this.requestId = requestId;
+ this.name = name;
+ this.purpose = purpose;
+ this.expiresAt = expiresAt;
+ this.rpm = rpm;
+ this.tpm = tpm;
+ this.concurrency = concurrency;
+ this.createdBy = createdBy;
+ }
+
+ /**
+ * Marks the key revoked, keeping the row. Idempotent: revoking twice is
+ * what a retried click looks like, and the second one must not move the
+ * timestamp that says when access actually ended.
+ */
+ public void revoke(Instant when) {
+ if (status == LlmApiKeyStatus.REVOKED) {
+ return;
+ }
+ this.status = LlmApiKeyStatus.REVOKED;
+ this.revokedAt = when;
+ this.updatedAt = when;
+ }
+
+ /**
+ * Mints or replaces the secret. Rotation is the same operation as the first
+ * issue: the old hash is gone the moment this returns, which is what makes
+ * "the old value stops working immediately" true rather than aspirational.
+ */
+ public void issue(String tokenHash, String tokenPrefix, Instant when) {
+ this.tokenHash = tokenHash;
+ this.tokenPrefix = tokenPrefix;
+ if (status == LlmApiKeyStatus.PENDING) {
+ this.status = LlmApiKeyStatus.ACTIVE;
+ }
+ this.updatedAt = when;
+ }
+
+ /** Whether a secret exists for this key at all. */
+ public boolean isIssued() {
+ return tokenHash != null;
+ }
+
+ public void rename(String name, Instant when) {
+ this.name = name;
+ this.updatedAt = when;
+ }
+
+ public void setPurpose(@Nullable String purpose, Instant when) {
+ this.purpose = purpose;
+ this.updatedAt = when;
+ }
+
+ public void setRecordBodies(boolean recordBodies, Instant when) {
+ this.recordBodies = recordBodies;
+ this.updatedAt = when;
+ }
+
+ public Long getId() {
+ return id;
+ }
+
+ public UUID getPublicId() {
+ return publicId;
+ }
+
+ public Long getWorkspaceId() {
+ return workspaceId;
+ }
+
+ public Long getOrgId() {
+ return orgId;
+ }
+
+ public Long getRequestId() {
+ return requestId;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public @Nullable String getPurpose() {
+ return purpose;
+ }
+
+ public @Nullable String getTokenPrefix() {
+ return tokenPrefix;
+ }
+
+ public LlmApiKeyStatus getStatus() {
+ return status;
+ }
+
+ public @Nullable Instant getExpiresAt() {
+ return expiresAt;
+ }
+
+ public @Nullable Instant getLastUsedAt() {
+ return lastUsedAt;
+ }
+
+ public @Nullable Integer getRpm() {
+ return rpm;
+ }
+
+ public @Nullable Integer getTpm() {
+ return tpm;
+ }
+
+ public @Nullable Integer getConcurrency() {
+ return concurrency;
+ }
+
+ public boolean isRecordBodies() {
+ return recordBodies;
+ }
+
+ public Instant getCreatedAt() {
+ return createdAt;
+ }
+
+ public @Nullable Instant getRevokedAt() {
+ return revokedAt;
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java
new file mode 100644
index 00000000..0d0b2d9e
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java
@@ -0,0 +1,140 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import kr.ac.pusan.pickle.access.ResourceAccessResolver;
+import kr.ac.pusan.pickle.access.ResourceStanding;
+import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.common.web.PageResponse;
+import kr.ac.pusan.pickle.llm.dto.LlmKeyDetailResponse;
+import kr.ac.pusan.pickle.llm.dto.LlmKeySummaryResponse;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import kr.ac.pusan.pickle.workspace.Workspace;
+import kr.ac.pusan.pickle.workspace.WorkspaceMember;
+import kr.ac.pusan.pickle.workspace.WorkspaceMemberRepository;
+import kr.ac.pusan.pickle.workspace.WorkspaceMemberRole;
+import kr.ac.pusan.pickle.workspace.WorkspaceRepository;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Read-only LLM API key views (contract tag {@code llm-keys}). Visibility is
+ * the platform's, not the type's: members of the owning workspace see that a
+ * key exists; only a grant opens the row; a non-member is answered as if the
+ * key did not exist. The contract defines no 403 for the list, so a
+ * workspaceId filter outside my workspaces yields an empty page.
+ *
+ *
What a restricted row must hide is sharper here than for a VM: nothing
+ * that authenticates or helps guess may leave. The token hash is absent from
+ * every view by construction — no DTO carries a field for it — and the token
+ * prefix, safe only as a label for people already inside, is dropped from the
+ * restricted row.
+ */
+@Service
+public class LlmApiKeyQueryService {
+
+ private final LlmApiKeyRepository keyRepository;
+ private final WorkspaceMemberRepository workspaceMemberRepository;
+ private final WorkspaceRepository workspaceRepository;
+ private final ResourceAccessResolver resourceAccessResolver;
+
+ public LlmApiKeyQueryService(LlmApiKeyRepository keyRepository,
+ WorkspaceMemberRepository workspaceMemberRepository,
+ WorkspaceRepository workspaceRepository,
+ ResourceAccessResolver resourceAccessResolver) {
+ this.keyRepository = keyRepository;
+ this.workspaceMemberRepository = workspaceMemberRepository;
+ this.workspaceRepository = workspaceRepository;
+ this.resourceAccessResolver = resourceAccessResolver;
+ }
+
+ @Transactional(readOnly = true)
+ public PageResponse list(AuthenticatedUser actor, UUID workspaceId,
+ int page, int size) {
+ Page result = listPage(actor, workspaceId,
+ PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")));
+ return PageResponse.of(result.getContent(), result);
+ }
+
+ /**
+ * The same list, as a {@link Page} — the shape the resource inventory
+ * reuses so that one set of visibility rules serves both surfaces, the way
+ * {@code VmQueryService.listPage} serves the VM's adapter.
+ */
+ @Transactional(readOnly = true)
+ public Page listPage(AuthenticatedUser actor, UUID workspaceId,
+ Pageable pageable) {
+ List memberships =
+ workspaceMemberRepository.findWithWorkspaceByUserId(actor.id());
+ Map workspaces = memberships.stream()
+ .collect(Collectors.toMap(m -> m.getWorkspace().getId(),
+ WorkspaceMember::getWorkspace, (first, second) -> first));
+ List workspaceIds = List.copyOf(workspaces.keySet());
+ Page result;
+ if (workspaceId != null) {
+ // An unknown workspace id and one outside my memberships answer the
+ // same empty page: the contract defines no 403 for the list.
+ Long filterId = workspaceRepository.findByPublicId(workspaceId)
+ .map(Workspace::getId).orElse(null);
+ result = filterId != null && workspaceIds.contains(filterId)
+ ? keyRepository.findByWorkspaceId(filterId, pageable)
+ : Page.empty(pageable);
+ } else {
+ result = workspaceIds.isEmpty()
+ ? Page.empty(pageable)
+ : keyRepository.findByWorkspaceIdIn(workspaceIds, pageable);
+ }
+ List keys = result.getContent();
+ Set ownedWorkspaceIds = memberships.stream()
+ .filter(m -> m.getRole() == WorkspaceMemberRole.OWNER)
+ .map(m -> m.getWorkspace().getId())
+ .collect(Collectors.toSet());
+ ResourceAccessResolver.ListAccess access = resourceAccessResolver.listAccess(
+ ResourceType.LLM_API_KEY, keys.stream().map(LlmApiKey::getId).toList(),
+ actor.id());
+ return new PageImpl<>(keys.stream()
+ .map(key -> {
+ Workspace workspace = workspaces.get(key.getWorkspaceId());
+ UUID workspacePublicId = workspace == null ? null : workspace.getPublicId();
+ String workspaceName = workspace == null ? "" : workspace.getName();
+ // Only a grant opens the row. A workspace owner without one
+ // gets the same restricted row as anyone else, plus the flag
+ // that lets the console offer them the access list — the way
+ // back in for a key whose own owner is gone.
+ if (access.reachable().contains(key.getId())) {
+ return LlmKeySummaryResponse.from(key, workspacePublicId, workspaceName);
+ }
+ return LlmKeySummaryResponse.restricted(key, workspacePublicId, workspaceName,
+ access.ownerNames().getOrDefault(key.getId(), List.of()),
+ ownedWorkspaceIds.contains(key.getWorkspaceId()));
+ })
+ .toList(), pageable, result.getTotalElements());
+ }
+
+ /**
+ * The full detail, for a grant holder. Unknown key and existing-but-masked
+ * key both answer 404; a member of the owning workspace who can already see
+ * it listed is refused in the open with 403.
+ */
+ @Transactional(readOnly = true)
+ public LlmKeyDetailResponse get(AuthenticatedUser actor, UUID keyId) {
+ LlmApiKey key = keyRepository.findByPublicId(keyId)
+ .orElseThrow(() -> LlmKeyResourceAdapter.MESSAGES.notFound());
+ ResourceStanding standing = resourceAccessResolver.standing(ResourceType.LLM_API_KEY,
+ key.getId(), key.getWorkspaceId(), actor.id());
+ standing.requireVisible(LlmKeyResourceAdapter.MESSAGES);
+ Workspace workspace = workspaceRepository.findById(key.getWorkspaceId()).orElse(null);
+ return LlmKeyDetailResponse.from(key,
+ workspace == null ? null : workspace.getPublicId(),
+ workspace == null ? "" : workspace.getName(),
+ standing.role(), standing.manages());
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java
new file mode 100644
index 00000000..9f430057
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java
@@ -0,0 +1,25 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LlmApiKeyRepository extends JpaRepository {
+
+ Optional findByPublicId(UUID publicId);
+
+ Page findByWorkspaceId(long workspaceId, Pageable pageable);
+
+ Page findByWorkspaceIdIn(List workspaceIds, Pageable pageable);
+
+ List findByWorkspaceId(long workspaceId);
+
+ /**
+ * Keys that still count as the workspace holding something. A revoked key
+ * keeps its row so its usage stays readable, but it holds nothing.
+ */
+ long countByWorkspaceIdAndStatusNot(long workspaceId, LlmApiKeyStatus status);
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java
new file mode 100644
index 00000000..c6613861
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java
@@ -0,0 +1,185 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.UUID;
+import kr.ac.pusan.pickle.access.ResourceAccessResolver;
+import kr.ac.pusan.pickle.access.ResourceRole;
+import kr.ac.pusan.pickle.access.ResourceStanding;
+import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.audit.AuditService;
+import kr.ac.pusan.pickle.common.error.ApiException;
+import kr.ac.pusan.pickle.common.error.ErrorCodes;
+import kr.ac.pusan.pickle.common.text.Texts;
+import kr.ac.pusan.pickle.llm.dto.IssuedLlmKeyResponse;
+import kr.ac.pusan.pickle.llm.dto.UpdateLlmKeyRequest;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import kr.ac.pusan.pickle.user.UserRole;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * The writes on an LLM API key: minting its secret, replacing it, revoking it,
+ * and changing what it is called or whether it records bodies.
+ *
+ * Every one of them bumps the document generation before it writes,
+ * in the same transaction. That order is the whole mechanism: the bump takes a
+ * row lock until commit, so commit order and generation order agree. Skip it
+ * and the change simply never reaches the gateway — the poll that would have
+ * carried it already answered "you are current", and nothing bumps again.
+ */
+@Service
+public class LlmApiKeyService {
+
+ private final LlmApiKeyRepository keyRepository;
+ private final LlmGatewayGenerations generations;
+ private final ResourceAccessResolver resourceAccessResolver;
+ private final AuditService auditService;
+
+ public LlmApiKeyService(LlmApiKeyRepository keyRepository, LlmGatewayGenerations generations,
+ ResourceAccessResolver resourceAccessResolver, AuditService auditService) {
+ this.keyRepository = keyRepository;
+ this.generations = generations;
+ this.resourceAccessResolver = resourceAccessResolver;
+ this.auditService = auditService;
+ }
+
+ /**
+ * Mints this key's secret and returns the plaintext — the only time it
+ * exists outside the caller's screen.
+ *
+ *
Issuing again is how a key is rotated, and it is the same operation:
+ * the old hash is gone when this returns, so the old value stops working
+ * at the gateway's next poll. Nothing here can hand back a value that was
+ * issued earlier; that is the point of storing only the hash.
+ */
+ @Transactional
+ public IssuedLlmKeyResponse issue(AuthenticatedUser actor, UUID keyId) {
+ LlmApiKey key = writable(actor, keyId, ResourceRole.OWNER);
+ if (key.getStatus() == LlmApiKeyStatus.REVOKED) {
+ throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.LLM_KEY_REVOKED,
+ "폐기된 키입니다", "폐기된 키는 다시 발급할 수 없습니다. 새로 신청해 주세요.");
+ }
+ boolean rotation = key.isIssued();
+ String token = LlmApiKeyTokens.newToken();
+ generations.bump();
+ // Re-read after the lock. The status check above ran against a snapshot
+ // taken before this transaction serialized on the counter, so a revoke
+ // that committed in between would otherwise be overwritten here — the
+ // key would come back ACTIVE with a fresh secret and no revoked_at,
+ // which is the one outcome "폐기된 키는 다시 발급할 수 없습니다" promises
+ // cannot happen.
+ if (keyRepository.findById(key.getId())
+ .map(LlmApiKey::getStatus)
+ .orElse(LlmApiKeyStatus.REVOKED) == LlmApiKeyStatus.REVOKED) {
+ throw new ApiException(HttpStatus.CONFLICT, ErrorCodes.LLM_KEY_REVOKED,
+ "폐기된 키입니다", "폐기된 키는 다시 발급할 수 없습니다. 새로 신청해 주세요.");
+ }
+ key.issue(LlmApiKeyTokens.hash(token), LlmApiKeyTokens.visiblePrefix(token), Instant.now());
+
+ Map args = new LinkedHashMap<>();
+ args.put("rotation", rotation);
+ auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.LLM_KEY_ISSUE,
+ "llm_key", key.getPublicId(), args, null);
+ return IssuedLlmKeyResponse.of(key, token);
+ }
+
+ /**
+ * Revokes the key, keeping its row. The row outlives the secret so the
+ * usage it produced stays readable and so the gateway can answer "this key
+ * was revoked" rather than "no such key" — different sentences, and only
+ * one of them sends a student looking for a typo.
+ */
+ @Transactional
+ public void revoke(AuthenticatedUser actor, UUID keyId) {
+ LlmApiKey key = revocable(actor, keyId);
+ if (key.getStatus() == LlmApiKeyStatus.REVOKED) {
+ return; // idempotent: a retried click must not move revoked_at
+ }
+ generations.bump();
+ key.revoke(Instant.now());
+ auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.LLM_KEY_REVOKE,
+ "llm_key", key.getPublicId(), Map.of(), null);
+ }
+
+ /**
+ * The key, if this caller may revoke it.
+ *
+ * Revoking is a standing right, not a granted one: a workspace owner may
+ * always take a resource of their workspace away, and a platform or
+ * organisation administrator may too. Requiring a grant here would mean
+ * that during a leaked-key incident the only people who can stop it are the
+ * ones already on its access list — and a workspace owner whose key owner
+ * has left would have to grant themselves content access first, recording a
+ * break-glass entry for what the model already gives them.
+ */
+ private LlmApiKey revocable(AuthenticatedUser actor, UUID keyId) {
+ LlmApiKey key = keyRepository.findByPublicId(keyId)
+ .orElseThrow(() -> LlmKeyResourceAdapter.MESSAGES.notFound());
+ if (actor.role() == UserRole.SYS_ADMIN) {
+ return key;
+ }
+ if (actor.role() == UserRole.ORG_ADMIN) {
+ if (!key.getOrgId().equals(actor.orgId())) {
+ throw LlmKeyResourceAdapter.MESSAGES.notFound();
+ }
+ return key;
+ }
+ ResourceStanding standing = resourceAccessResolver.standing(ResourceType.LLM_API_KEY,
+ key.getId(), key.getWorkspaceId(), actor.id());
+ if (!standing.manages()) {
+ standing.requireVisible(LlmKeyResourceAdapter.MESSAGES);
+ throw LlmKeyResourceAdapter.MESSAGES.noGrant();
+ }
+ return key;
+ }
+
+ /** Renames the key or turns body recording on or off. */
+ @Transactional
+ public void update(AuthenticatedUser actor, UUID keyId, UpdateLlmKeyRequest form) {
+ LlmApiKey key = writable(actor, keyId, ResourceRole.EDITOR);
+ Map args = new LinkedHashMap<>();
+ // The name never reaches the gateway, but the recording flag does, so
+ // the bump is unconditional rather than conditional on which field
+ // moved: a rule with an exception is the rule somebody later copies
+ // the exception from.
+ generations.bump();
+ // Each member is independent: absent means "leave it alone", which is
+ // what the contract promises. Folding purpose into the rename would
+ // erase a purpose whenever somebody renamed without resending it.
+ if (form.name() != null) {
+ key.rename(form.name().trim(), Instant.now());
+ args.put("name", key.getName());
+ }
+ if (form.purpose() != null) {
+ // Blank is how a purpose is cleared; absent is how it is kept.
+ key.setPurpose(Texts.blankToNull(form.purpose()), Instant.now());
+ args.put("purpose", key.getPurpose());
+ }
+ if (form.recordBodies() != null) {
+ key.setRecordBodies(form.recordBodies(), Instant.now());
+ args.put("recordBodies", form.recordBodies());
+ }
+ auditService.recordAfterCommit(actor.id(), actor.role().name(), AuditService.LLM_KEY_UPDATE,
+ "llm_key", key.getPublicId(), args, null);
+ }
+
+ /**
+ * The key, if this caller may change it at the given rung. A caller who
+ * may not see it at all gets the masking 404, never a 403 that confirms
+ * the key exists.
+ */
+ private LlmApiKey writable(AuthenticatedUser actor, UUID keyId, ResourceRole minimum) {
+ LlmApiKey key = keyRepository.findByPublicId(keyId)
+ .orElseThrow(() -> LlmKeyResourceAdapter.MESSAGES.notFound());
+ ResourceStanding standing = resourceAccessResolver.standing(ResourceType.LLM_API_KEY,
+ key.getId(), key.getWorkspaceId(), actor.id());
+ standing.requireVisible(LlmKeyResourceAdapter.MESSAGES);
+ if (!standing.atLeast(minimum)) {
+ throw LlmKeyResourceAdapter.MESSAGES.noGrant();
+ }
+ return key;
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java
new file mode 100644
index 00000000..a201fe0c
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java
@@ -0,0 +1,27 @@
+package kr.ac.pusan.pickle.llm;
+
+/**
+ * What state an issued key is in (DB enum {@code llm_api_key_status}).
+ *
+ * Anything but {@link #ACTIVE} refuses requests at the gateway; the
+ * distinction only changes what the student is told, and that is the whole
+ * reason a revoked key keeps its row instead of disappearing — "this key was
+ * revoked" and "no such key" are different sentences, and only one of them
+ * sends somebody looking for a typo.
+ */
+public enum LlmApiKeyStatus {
+ /**
+ * Approved, but the secret has not been minted yet. Only the owner may ever
+ * see a plaintext, and the approver is not there when they do, so approval
+ * creates the key and the owner issues it.
+ *
+ *
The gateway never hears about this state: a key with no secret cannot
+ * authenticate anything, so it is absent from the document rather than
+ * present and refused.
+ */
+ PENDING,
+ ACTIVE,
+ SUSPENDED,
+ REVOKED,
+ EXPIRED
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyTokens.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyTokens.java
new file mode 100644
index 00000000..cb35e023
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyTokens.java
@@ -0,0 +1,52 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.security.SecureRandom;
+import kr.ac.pusan.pickle.common.text.Secrets;
+
+/**
+ * The plaintext an LLM API key is, and what is kept of it.
+ *
+ *
The shape is fixed by the gateway, not chosen here: it recognizes a
+ * bearer by hashing whatever was presented, and students paste the value into
+ * an OpenAI SDK. So it stays URL- and header-safe, carries a prefix a person
+ * can recognize in a support request, and has enough entropy that guessing is
+ * not a strategy — 43 base62 characters is a little over 256 bits.
+ */
+public final class LlmApiKeyTokens {
+
+ /** What every issued key starts with, so one is recognizable on sight. */
+ public static final String PREFIX = "pickle-";
+ /** How much of the plaintext a list may show. */
+ public static final int VISIBLE_PREFIX_LENGTH = PREFIX.length() + 6;
+
+ private static final String ALPHABET =
+ "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
+ private static final int RANDOM_LENGTH = 43;
+ private static final SecureRandom RANDOM = new SecureRandom();
+
+ private LlmApiKeyTokens() {
+ }
+
+ /** A fresh plaintext key. Shown once, then only its hash survives. */
+ public static String newToken() {
+ StringBuilder token = new StringBuilder(PREFIX.length() + RANDOM_LENGTH);
+ token.append(PREFIX);
+ for (int i = 0; i < RANDOM_LENGTH; i++) {
+ token.append(ALPHABET.charAt(RANDOM.nextInt(ALPHABET.length())));
+ }
+ return token.toString();
+ }
+
+ /** What is stored, and what the gateway computes from a presented token. */
+ public static String hash(String token) {
+ return Secrets.sha256Hex(token);
+ }
+
+ /**
+ * The leading characters kept alongside the hash, so a list can tell two
+ * keys apart without holding anything that authenticates.
+ */
+ public static String visiblePrefix(String token) {
+ return token.substring(0, Math.min(VISIBLE_PREFIX_LENGTH, token.length()));
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayAuthFilter.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayAuthFilter.java
new file mode 100644
index 00000000..182ab566
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayAuthFilter.java
@@ -0,0 +1,193 @@
+package kr.ac.pusan.pickle.llm;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import kr.ac.pusan.pickle.auth.RateLimitService;
+import kr.ac.pusan.pickle.common.error.ApiException;
+import kr.ac.pusan.pickle.common.error.ErrorCodes;
+import kr.ac.pusan.pickle.common.error.ProblemJsonWriter;
+import kr.ac.pusan.pickle.common.web.BodyCappingRequest;
+import kr.ac.pusan.pickle.config.LlmGatewayProperties;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+/**
+ * Sole access gate for the LLM gateway surface ({@code /internal/llm/**}).
+ * Deliberately NOT the sshgw internal filter (that chain is one token, one
+ * pinned source, one shared global bucket — reusing it would hand a
+ * compromised gateway the sshgw token's slug→VM oracle and let a usage
+ * backlog 429 every user's SSH auth) and not the relay chain (per-client
+ * hashed tokens exist for many off-host relays, which does not apply to a
+ * single infra-bridge peer). Checks, in order:
+ *
+ *
+ * - Sub-path — only the three known calls (sync/usage/bodies)
+ * exist on this surface; anything else answers a generic 403.
+ * - Source pin — the TCP peer ({@code getRemoteAddr()}, never a
+ * spoofable X-Forwarded-For; the endpoint is called directly on :8080)
+ * must be the LLM gateway LXC.
+ * - Static bearer — constant-time match against the configured
+ * token, or against the previous token while a rotation overlap is in
+ * flight. Fails closed when the current token is unset, so a
+ * mis-provisioned profile rejects every call rather than accepting an
+ * empty bearer.
+ * - Per-sub-path rate bucket — {@code llm_sync} / {@code llm_usage}
+ * / {@code llm_bodies}, three buckets on purpose: the three calls have
+ * rates an order of magnitude apart, and one shared bucket would let
+ * the loud two throttle the one that carries authorization.
+ * - Per-sub-path body cap — a declared Content-Length over the cap
+ * answers 413, and the input stream is wrapped so a chunked body is
+ * hard-capped at the same byte count.
+ *
+ */
+public class LlmGatewayAuthFilter extends OncePerRequestFilter {
+
+ static final String SYNC_RATE_LIMIT_SCOPE = "llm_sync";
+ static final String USAGE_RATE_LIMIT_SCOPE = "llm_usage";
+ static final String BODIES_RATE_LIMIT_SCOPE = "llm_bodies";
+
+ /** One peer, one subject: the source pin already narrows the caller. */
+ private static final String RATE_LIMIT_SUBJECT = "gateway";
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ private final LlmGatewayProperties properties;
+ private final RateLimitService rateLimitService;
+ private final ProblemJsonWriter problemJsonWriter;
+
+ public LlmGatewayAuthFilter(LlmGatewayProperties properties,
+ RateLimitService rateLimitService, ProblemJsonWriter problemJsonWriter) {
+ this.properties = properties;
+ this.rateLimitService = rateLimitService;
+ this.problemJsonWriter = problemJsonWriter;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+ FilterChain filterChain) throws ServletException, IOException {
+ SubPath subPath = SubPath.of(request.getRequestURI());
+ if (subPath == null) {
+ forbidden(request, response);
+ return;
+ }
+ if (!properties.allowedSourceIp().equals(request.getRemoteAddr())) {
+ forbidden(request, response);
+ return;
+ }
+ if (!tokenMatches(request)) {
+ unauthorized(request, response);
+ return;
+ }
+ try {
+ rateLimitService.hit(subPath.rateLimitScope, RATE_LIMIT_SUBJECT,
+ subPath.limitPerMinute(properties));
+ } catch (ApiException e) {
+ rateLimited(request, response, e);
+ return;
+ }
+ long cap = subPath.bodyCapBytes(properties);
+ if (request.getContentLengthLong() > cap) {
+ payloadTooLarge(request, response);
+ return;
+ }
+ filterChain.doFilter(new BodyCappingRequest(request, cap), response);
+ }
+
+ /**
+ * Constant-time bearer comparison against the current token, then the
+ * previous one (rotation overlap); fails closed when no current token is
+ * configured — a rotation must never run on the previous value alone.
+ */
+ private boolean tokenMatches(HttpServletRequest request) {
+ if (properties.tokenUnset()) {
+ return false;
+ }
+ String header = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (header == null || !header.startsWith(BEARER_PREFIX)) {
+ return false;
+ }
+ String presented = header.substring(BEARER_PREFIX.length()).trim();
+ if (constantTimeEquals(presented, properties.token())) {
+ return true;
+ }
+ return properties.previousTokenSet()
+ && constantTimeEquals(presented, properties.previousToken());
+ }
+
+ private static boolean constantTimeEquals(String presented, String expected) {
+ return MessageDigest.isEqual(
+ presented.getBytes(StandardCharsets.UTF_8),
+ expected.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private void unauthorized(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ problemJsonWriter.write(request, response, HttpServletResponse.SC_UNAUTHORIZED,
+ ErrorCodes.AUTH_TOKEN_INVALID, "인증이 필요합니다", "유효한 인증 토큰이 필요합니다.");
+ }
+
+ private void forbidden(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ problemJsonWriter.write(request, response, HttpServletResponse.SC_FORBIDDEN,
+ ErrorCodes.ACCESS_DENIED, "접근이 거부되었습니다", "허용되지 않은 접근입니다.");
+ }
+
+ private void rateLimited(HttpServletRequest request, HttpServletResponse response,
+ ApiException e) throws IOException {
+ if (e.getRetryAfterSeconds() != null) {
+ response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()));
+ }
+ problemJsonWriter.write(request, response, 429,
+ ErrorCodes.RATE_LIMITED, e.getTitle(), e.getDetail());
+ }
+
+ private void payloadTooLarge(HttpServletRequest request, HttpServletResponse response)
+ throws IOException {
+ problemJsonWriter.write(request, response, 413, ErrorCodes.VALIDATION_FAILED,
+ "요청 본문이 너무 큽니다", "요청 본문이 허용 크기를 초과했습니다.");
+ }
+
+ /** The three calls this surface serves, each with its own bucket and cap. */
+ private enum SubPath {
+ SYNC("/internal/llm/sync", SYNC_RATE_LIMIT_SCOPE),
+ USAGE("/internal/llm/usage", USAGE_RATE_LIMIT_SCOPE),
+ BODIES("/internal/llm/bodies", BODIES_RATE_LIMIT_SCOPE);
+
+ private final String uri;
+ private final String rateLimitScope;
+
+ SubPath(String uri, String rateLimitScope) {
+ this.uri = uri;
+ this.rateLimitScope = rateLimitScope;
+ }
+
+ static SubPath of(String requestUri) {
+ for (SubPath subPath : values()) {
+ if (subPath.uri.equals(requestUri)) {
+ return subPath;
+ }
+ }
+ return null;
+ }
+
+ int limitPerMinute(LlmGatewayProperties properties) {
+ return switch (this) {
+ case SYNC -> properties.syncRateLimitPerMinute();
+ case USAGE -> properties.usageRateLimitPerMinute();
+ case BODIES -> properties.bodiesRateLimitPerMinute();
+ };
+ }
+
+ long bodyCapBytes(LlmGatewayProperties properties) {
+ return switch (this) {
+ case SYNC -> properties.maxSyncBodyBytes();
+ case USAGE -> properties.maxUsageBodyBytes();
+ case BODIES -> properties.maxBodiesBodyBytes();
+ };
+ }
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayController.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayController.java
new file mode 100644
index 00000000..00bfe0d1
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayController.java
@@ -0,0 +1,74 @@
+package kr.ac.pusan.pickle.llm;
+
+import io.swagger.v3.oas.annotations.Hidden;
+import jakarta.validation.Valid;
+import kr.ac.pusan.pickle.llm.dto.LlmBodiesRequest;
+import kr.ac.pusan.pickle.llm.dto.LlmSyncRequest;
+import kr.ac.pusan.pickle.llm.dto.LlmSyncResponse;
+import kr.ac.pusan.pickle.llm.dto.LlmUsageRequest;
+import kr.ac.pusan.pickle.llm.dto.LlmUsageResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * LLM gateway link endpoints (outside {@code /api/v1}, hidden from the public
+ * contract). Auth lives entirely in {@link LlmGatewayAuthFilter} on the
+ * dedicated {@code /internal/llm/**} chain: by the time a handler runs, the
+ * caller IS the gateway (source pin + static bearer), so no principal is
+ * consulted here.
+ */
+@Hidden
+@RestController
+@RequestMapping("/internal/llm")
+public class LlmGatewayController {
+
+ private static final Logger log = LoggerFactory.getLogger(LlmGatewayController.class);
+
+ private final LlmSyncService llmSyncService;
+ private final LlmUsageService llmUsageService;
+
+ public LlmGatewayController(LlmSyncService llmSyncService, LlmUsageService llmUsageService) {
+ this.llmSyncService = llmSyncService;
+ this.llmUsageService = llmUsageService;
+ }
+
+ @PostMapping("/sync")
+ public LlmSyncResponse sync(@Valid @RequestBody LlmSyncRequest request) {
+ return llmSyncService.sync(request);
+ }
+
+ /**
+ * No {@code @Valid} here, on purpose: a constraint violation would answer
+ * 400, which the gateway reads as "this batch is the problem" — it skips
+ * the batch, moves its checkpoint past it, and the events are gone.
+ * Per-event validation lives in the service and reports through the
+ * response tally instead.
+ */
+ @PostMapping("/usage")
+ public LlmUsageResponse usage(@RequestBody LlmUsageRequest request) {
+ return llmUsageService.ingest(request);
+ }
+
+ /**
+ * Accepts and counts opted-in prompt/response records, and deliberately
+ * stores nothing. That is a chosen state, not an unfinished one: the
+ * storage, its encryption key and its retention policy are a later round
+ * gated on a privacy-policy revision, and until that decision exists no
+ * captured text may be persisted anywhere on this side. Answering 2xx
+ * keeps the gateway's bounded in-memory queue draining (it drops rather
+ * than blocks, and never spools these to its disk), so turning storage on
+ * later is purely an api-side change.
+ */
+ @PostMapping("/bodies")
+ public ResponseEntity bodies(@RequestBody LlmBodiesRequest request) {
+ int count = request.records() == null ? 0 : request.records().size();
+ log.info("LLM bodies batch: accepted and discarded {} records (storage deferred "
+ + "pending the privacy-policy decision)", count);
+ return ResponseEntity.noContent().build();
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayGenerations.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayGenerations.java
new file mode 100644
index 00000000..34cd3000
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayGenerations.java
@@ -0,0 +1,61 @@
+package kr.ac.pusan.pickle.llm;
+
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Component;
+
+/**
+ * The document-generation counter the gateway polls against: a single row in
+ * {@code llm_gateway_state}, bumped with a row-locking upsert — deliberately
+ * never a sequence. The upsert takes a row lock until commit, which is what
+ * makes commit order and generation order agree; with a sequence, a
+ * transaction holding the lower number can commit second, and a poll at the
+ * higher generation reads a table that does not yet contain that change —
+ * which then never reaches the gateway at all, because nothing bumps again.
+ *
+ * Every write the document is built from (key issue, revoke, suspend,
+ * limit edit, record-bodies toggle, service kill switch — and model writes
+ * once the catalogue exists) must call {@link #bump()} BEFORE touching the
+ * rows, in the same transaction. Same discipline as the relay mapping
+ * counter.
+ *
+ * The row is not seeded by any migration (migrations carry schema, not
+ * rows): both operations here are upserts, so the row comes into existence on
+ * the first write — the sync handler's contact stamp or the first key
+ * write.
+ */
+@Component
+public class LlmGatewayGenerations {
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public LlmGatewayGenerations(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ /** Increments and returns the document generation (row-locking upsert). */
+ public long bump() {
+ return jdbcTemplate.queryForObject("""
+ insert into llm_gateway_state (id) values (true)
+ on conflict (id) do update
+ set generation = llm_gateway_state.generation + 1, updated_at = now()
+ returning generation
+ """, Long.class);
+ }
+
+ /**
+ * Raises the counter strictly above a floor the gateway already served —
+ * the restored-backup path: the api's counter went backwards while the
+ * gateway's persisted high-water mark did not, and every document this
+ * side could otherwise produce sits below the gateway's floor forever.
+ * {@code greatest} keeps a concurrent {@link #bump()} from being undone.
+ */
+ public long raiseAbove(long floor) {
+ return jdbcTemplate.queryForObject("""
+ insert into llm_gateway_state (id, generation) values (true, ?)
+ on conflict (id) do update
+ set generation = greatest(llm_gateway_state.generation + 1, excluded.generation),
+ updated_at = now()
+ returning generation
+ """, Long.class, floor + 1);
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java
new file mode 100644
index 00000000..f57fe307
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java
@@ -0,0 +1,95 @@
+package kr.ac.pusan.pickle.llm;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import java.util.UUID;
+import kr.ac.pusan.pickle.common.web.PageResponse;
+import kr.ac.pusan.pickle.security.RequireReauth;
+import kr.ac.pusan.pickle.llm.dto.IssuedLlmKeyResponse;
+import kr.ac.pusan.pickle.llm.dto.LlmKeyDetailResponse;
+import kr.ac.pusan.pickle.llm.dto.LlmKeySummaryResponse;
+import kr.ac.pusan.pickle.llm.dto.UpdateLlmKeyRequest;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+/** Contract tag {@code llm-keys} (server /api/v1). */
+@Tag(name = "llm-keys", description = "LLM API 키 — 워크스페이스가 보유한 키의 목록과 상세입니다. "
+ + "키 평문은 발급 시 한 번만 표시되며 여기서는 다시 볼 수 없습니다.")
+@RestController
+@RequestMapping("/api/v1/llm-keys")
+public class LlmKeyController {
+
+ private final LlmApiKeyQueryService queryService;
+ private final LlmApiKeyService keyService;
+
+ public LlmKeyController(LlmApiKeyQueryService queryService, LlmApiKeyService keyService) {
+ this.queryService = queryService;
+ this.keyService = keyService;
+ }
+
+ @GetMapping
+ @Operation(summary = "LLM API 키 목록",
+ description = "내가 속한 워크스페이스의 키를 보여 줍니다. 접근 권한이 없는 키는 "
+ + "이름·상태·소유자만 담긴 제한된 행으로 표시됩니다.")
+ public PageResponse listLlmKeys(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @RequestParam(required = false) UUID workspaceId,
+ @RequestParam(defaultValue = "0") @Min(0) int page,
+ @RequestParam(defaultValue = "20") @Min(1) @Max(100) int size) {
+ return queryService.list(principal, workspaceId, page, size);
+ }
+
+ @GetMapping("/{keyId}")
+ @Operation(summary = "LLM API 키 상세",
+ description = "접근 권한이 있는 키의 상세입니다. 키 평문과 그 해시는 어떤 응답에도 담기지 않습니다.")
+ public LlmKeyDetailResponse getLlmKey(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId) {
+ return queryService.get(principal, keyId);
+ }
+
+ @PostMapping("/{keyId}/token")
+ @RequireReauth
+ @Operation(summary = "LLM API 키 발급",
+ description = "이 키의 평문을 만들어 **한 번만** 돌려줍니다. 서버에는 해시만 남으므로 "
+ + "다시 조회할 수 없고, 분실하면 이 호출을 다시 해서 재발급해야 합니다. "
+ + "재발급하면 이전 값은 곧바로 쓸 수 없게 됩니다.")
+ public IssuedLlmKeyResponse issueLlmKeyToken(
+ @AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId) {
+ return keyService.issue(principal, keyId);
+ }
+
+ @PostMapping("/{keyId}/revoke")
+ @RequireReauth
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @Operation(summary = "LLM API 키 폐기",
+ description = "이 키를 폐기합니다. 게이트웨이에는 폴링 주기 안에 반영되고, 이후 이 키로 "
+ + "보낸 요청은 '폐기된 키'로 거부됩니다. 사용 기록은 남습니다.")
+ public void revokeLlmKey(@AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId) {
+ keyService.revoke(principal, keyId);
+ }
+
+ @PatchMapping("/{keyId}")
+ @ResponseStatus(HttpStatus.NO_CONTENT)
+ @Operation(summary = "LLM API 키 수정",
+ description = "키 이름과 사용 목적, 본문 기록 여부를 바꿉니다. 생략한 항목은 그대로 둡니다.")
+ public void updateLlmKey(@AuthenticationPrincipal AuthenticatedUser principal,
+ @PathVariable UUID keyId, @Valid @RequestBody UpdateLlmKeyRequest form) {
+ keyService.update(principal, keyId, form);
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetail.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetail.java
new file mode 100644
index 00000000..1bee207d
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetail.java
@@ -0,0 +1,106 @@
+package kr.ac.pusan.pickle.llm;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * What an LLM API key request asks for, and what the reviewer granted.
+ *
+ * Keyed by its request, like the VM's. Every field is optional on both
+ * sides: a key that names no limits is asking for the service's defaults, and
+ * an approval that grants none is granting exactly that. The row's existence is
+ * therefore the whole assertion the approved-grant trigger makes for this type
+ * — there is no per-field granted specification to check.
+ */
+@Entity
+@Table(name = "llm_key_request_details")
+public class LlmKeyRequestDetail {
+
+ @Id
+ @Column(name = "request_id")
+ private Long requestId;
+
+ @Column(name = "req_purpose")
+ private @Nullable String reqPurpose;
+
+ @Column(name = "req_rpm")
+ private @Nullable Integer reqRpm;
+
+ @Column(name = "req_tpm")
+ private @Nullable Integer reqTpm;
+
+ @Column(name = "req_daily_tokens")
+ private @Nullable Long reqDailyTokens;
+
+ @Column(name = "granted_rpm")
+ private @Nullable Integer grantedRpm;
+
+ @Column(name = "granted_tpm")
+ private @Nullable Integer grantedTpm;
+
+ @Column(name = "granted_concurrency")
+ private @Nullable Integer grantedConcurrency;
+
+ @Column(name = "granted_daily_tokens")
+ private @Nullable Long grantedDailyTokens;
+
+ protected LlmKeyRequestDetail() {
+ }
+
+ public LlmKeyRequestDetail(long requestId, @Nullable String reqPurpose, @Nullable Integer reqRpm,
+ @Nullable Integer reqTpm, @Nullable Long reqDailyTokens) {
+ this.requestId = requestId;
+ this.reqPurpose = reqPurpose;
+ this.reqRpm = reqRpm;
+ this.reqTpm = reqTpm;
+ this.reqDailyTokens = reqDailyTokens;
+ }
+
+ /** Records what the reviewer granted. Nulls mean the service defaults. */
+ public void grant(@Nullable Integer rpm, @Nullable Integer tpm, @Nullable Integer concurrency,
+ @Nullable Long dailyTokens) {
+ this.grantedRpm = rpm;
+ this.grantedTpm = tpm;
+ this.grantedConcurrency = concurrency;
+ this.grantedDailyTokens = dailyTokens;
+ }
+
+ public Long getRequestId() {
+ return requestId;
+ }
+
+ public @Nullable String getReqPurpose() {
+ return reqPurpose;
+ }
+
+ public @Nullable Integer getReqRpm() {
+ return reqRpm;
+ }
+
+ public @Nullable Integer getReqTpm() {
+ return reqTpm;
+ }
+
+ public @Nullable Long getReqDailyTokens() {
+ return reqDailyTokens;
+ }
+
+ public @Nullable Integer getGrantedRpm() {
+ return grantedRpm;
+ }
+
+ public @Nullable Integer getGrantedTpm() {
+ return grantedTpm;
+ }
+
+ public @Nullable Integer getGrantedConcurrency() {
+ return grantedConcurrency;
+ }
+
+ public @Nullable Long getGrantedDailyTokens() {
+ return grantedDailyTokens;
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetailRepository.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetailRepository.java
new file mode 100644
index 00000000..a2690ec1
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetailRepository.java
@@ -0,0 +1,12 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.util.List;
+import java.util.Optional;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface LlmKeyRequestDetailRepository extends JpaRepository {
+
+ Optional findByRequestId(long requestId);
+
+ List findByRequestIdIn(List requestIds);
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestSupport.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestSupport.java
new file mode 100644
index 00000000..ab2ec7a8
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestSupport.java
@@ -0,0 +1,131 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.admin.dto.ApproveRequestRequest;
+import kr.ac.pusan.pickle.common.error.FieldValidationError;
+import kr.ac.pusan.pickle.common.text.Texts;
+import kr.ac.pusan.pickle.llm.dto.ApproveLlmKeyRequestSpec;
+import kr.ac.pusan.pickle.llm.dto.CreateLlmKeyRequestSpec;
+import kr.ac.pusan.pickle.request.Request;
+import kr.ac.pusan.pickle.request.RequestTypeHandler;
+import kr.ac.pusan.pickle.request.dto.CreateRequestRequest;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import org.springframework.stereotype.Component;
+
+/**
+ * Everything about a request that is particular to LLM API keys.
+ *
+ * The shape differs from the VM's in one way worth stating: approval does
+ * not produce a usable resource. It produces a key with no secret. Only the
+ * key's owner may ever see a plaintext, and the approver is not there when
+ * they do, so the owner mints it themselves — see {@link LlmApiKeyService}.
+ * What approval settles is that they may have one, and on what limits.
+ */
+@Component
+public class LlmKeyRequestSupport implements RequestTypeHandler {
+
+ private final LlmKeyRequestDetailRepository detailRepository;
+ private final LlmApiKeyRepository keyRepository;
+ private final LlmGatewayGenerations generations;
+
+ public LlmKeyRequestSupport(LlmKeyRequestDetailRepository detailRepository,
+ LlmApiKeyRepository keyRepository, LlmGatewayGenerations generations) {
+ this.detailRepository = detailRepository;
+ this.keyRepository = keyRepository;
+ this.generations = generations;
+ }
+
+ @Override
+ public ResourceType type() {
+ return ResourceType.LLM_API_KEY;
+ }
+
+ @Override
+ public void validateCreate(CreateRequestRequest form, List errors) {
+ CreateLlmKeyRequestSpec spec = form.llmKey();
+ if (spec == null) {
+ errors.add(new FieldValidationError("llmKey", "LLM API 키 신청 항목(llmKey)을 입력해 주세요."));
+ return;
+ }
+ // Every limit is optional: a request that names none is asking for the
+ // service defaults, which is what most of them are. What is checked is
+ // only that a number somebody did write is a number that can be granted.
+ if (spec.reqRpm() != null && spec.reqTpm() != null && spec.reqTpm() < spec.reqRpm()) {
+ errors.add(new FieldValidationError("llmKey.reqTpm",
+ "분당 토큰 수는 분당 요청 수보다 작을 수 없습니다."));
+ }
+ }
+
+ @Override
+ public void saveDetail(Request request, CreateRequestRequest form) {
+ CreateLlmKeyRequestSpec spec = form.llmKey();
+ detailRepository.save(new LlmKeyRequestDetail(request.getId(),
+ Texts.blankToNull(spec.usagePlan()), spec.reqRpm(), spec.reqTpm(),
+ spec.reqDailyTokens()));
+ }
+
+ @Override
+ public Map submitAuditArgs(Request request) {
+ Map args = new LinkedHashMap<>();
+ detailRepository.findByRequestId(request.getId()).ifPresent(detail -> {
+ args.put("reqRpm", detail.getReqRpm());
+ args.put("reqTpm", detail.getReqTpm());
+ args.put("reqDailyTokens", detail.getReqDailyTokens());
+ });
+ return args;
+ }
+
+ @Override
+ public void validateApprove(Request request, ApproveRequestRequest form,
+ List errors) {
+ ApproveLlmKeyRequestSpec spec = form.llmKey();
+ if (spec == null) {
+ // Unlike a VM, approving with nothing filled in is the ordinary
+ // decision: it grants the service defaults. The member has to be
+ // present so the reviewer's intent is explicit, but it may be empty.
+ errors.add(new FieldValidationError("llmKey",
+ "LLM API 키 승인 항목(llmKey)을 입력해 주세요. 기본 한도로 승인하려면 빈 객체를 보내주세요."));
+ return;
+ }
+ if (spec.grantedRpm() != null && spec.grantedTpm() != null
+ && spec.grantedTpm() < spec.grantedRpm()) {
+ errors.add(new FieldValidationError("llmKey.grantedTpm",
+ "분당 토큰 수는 분당 요청 수보다 작을 수 없습니다."));
+ }
+ }
+
+ @Override
+ public Materialized materialize(Request request, ApproveRequestRequest form,
+ AuthenticatedUser actor) {
+ ApproveLlmKeyRequestSpec spec = form.llmKey();
+ LlmKeyRequestDetail detail = detailRepository.findByRequestId(request.getId()).orElseThrow();
+ detail.grant(spec.grantedRpm(), spec.grantedTpm(), spec.grantedConcurrency(),
+ spec.grantedDailyTokens());
+
+ // Bump before the write, in this transaction: the row lock is what makes
+ // commit order and generation order agree. The key lands PENDING, so
+ // nothing servable changes yet — but the discipline has no exceptions,
+ // because an exception is what a later reader would copy.
+ generations.bump();
+ LlmApiKey key = keyRepository.save(new LlmApiKey(request.getWorkspaceId(),
+ request.getOrgId(), request.getId(), request.getDisplayName(),
+ detail.getReqPurpose(),
+ form.grantedEndDate() == null ? null
+ : form.grantedEndDate().plusDays(1).atStartOfDay(
+ java.time.ZoneId.of("Asia/Seoul")).toInstant(),
+ spec.grantedRpm(), spec.grantedTpm(), spec.grantedConcurrency(),
+ request.getRequesterId()));
+
+ Map auditArgs = new LinkedHashMap<>();
+ auditArgs.put("llmKeyId", key.getPublicId());
+ auditArgs.put("grantedRpm", spec.grantedRpm());
+ auditArgs.put("grantedTpm", spec.grantedTpm());
+ auditArgs.put("grantedConcurrency", spec.grantedConcurrency());
+ auditArgs.put("grantedDailyTokens", spec.grantedDailyTokens());
+ return new Materialized(key.getId(), key.getName(), auditArgs, () -> {
+ });
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyResourceAdapter.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyResourceAdapter.java
new file mode 100644
index 00000000..9ff69be6
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyResourceAdapter.java
@@ -0,0 +1,116 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import kr.ac.pusan.pickle.access.ResourceAccessAudit;
+import kr.ac.pusan.pickle.access.ResourceAccessMessages;
+import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.audit.AuditService;
+import kr.ac.pusan.pickle.common.error.ErrorCodes;
+import kr.ac.pusan.pickle.llm.dto.LlmKeySummaryResponse;
+import kr.ac.pusan.pickle.resource.ResourceIdentity;
+import kr.ac.pusan.pickle.resource.ResourceTypeAdapter;
+import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
+import org.springframework.stereotype.Component;
+
+/** What the resource-generic machinery needs to know about an LLM API key. */
+@Component
+public class LlmKeyResourceAdapter implements ResourceTypeAdapter {
+
+ /** Every sentence the access machinery says about a key. */
+ public static final ResourceAccessMessages MESSAGES = new ResourceAccessMessages(
+ "해당 LLM API 키가 존재하지 않습니다.",
+ new ResourceAccessMessages.Refusal("이 키에 접근할 권한이 없습니다",
+ "이 LLM API 키의 접근 목록에 등록되어 있지 않습니다. 자원 소유자에게 접근 권한을 요청해 주세요."),
+ new ResourceAccessMessages.Refusal("접근 권한을 관리할 권한이 없습니다",
+ "이 LLM API 키의 소유자 또는 워크스페이스 소유자만 접근 권한을 관리할 수 있습니다."),
+ "이 키를 소유한 워크스페이스의 구성원만 접근 권한을 받을 수 있습니다. 먼저 워크스페이스에 추가해 주세요.",
+ ErrorCodes.LLM_KEY_ACCESS_GRANT_EXISTS,
+ new ResourceAccessMessages.Refusal("이미 접근 권한이 있습니다",
+ "이 대상은 이미 이 LLM API 키의 접근 목록에 있습니다. 등급을 바꾸려면 기존 항목을 수정해 주세요."));
+
+ private static final ResourceAccessAudit AUDIT = new ResourceAccessAudit("llm_key",
+ AuditService.LLM_KEY_ACCESS_GRANT_ADD, AuditService.LLM_KEY_ACCESS_GRANT_UPDATE,
+ AuditService.LLM_KEY_ACCESS_GRANT_REMOVE, AuditService.LLM_KEY_ACCESS_BREAK_GLASS);
+
+ private final LlmApiKeyRepository keyRepository;
+ private final LlmApiKeyQueryService queryService;
+
+ public LlmKeyResourceAdapter(LlmApiKeyRepository keyRepository,
+ LlmApiKeyQueryService queryService) {
+ this.keyRepository = keyRepository;
+ this.queryService = queryService;
+ }
+
+ @Override
+ public ResourceType type() {
+ return ResourceType.LLM_API_KEY;
+ }
+
+ @Override
+ public Optional identify(long resourceId) {
+ // No filter on status: a revoked key keeps its row so the people who
+ // used it can still read what it did, and the access list is what
+ // decides who they are.
+ return keyRepository.findById(resourceId).map(LlmKeyResourceAdapter::identityOf);
+ }
+
+ @Override
+ public Optional identifyByPublicId(UUID publicId) {
+ return keyRepository.findByPublicId(publicId).map(LlmKeyResourceAdapter::identityOf);
+ }
+
+ @Override
+ public ResourceAccessMessages accessMessages() {
+ return MESSAGES;
+ }
+
+ @Override
+ public ResourceAccessAudit accessAudit() {
+ return AUDIT;
+ }
+
+ @Override
+ public List idsOwnedByWorkspace(long workspaceId) {
+ return keyRepository.findByWorkspaceId(workspaceId).stream().map(LlmApiKey::getId).toList();
+ }
+
+ @Override
+ public long countLiveInWorkspace(long workspaceId) {
+ // A revoked key holds nothing; anything else does, including one that
+ // has been approved but not yet minted — the right to it still exists.
+ return keyRepository.countByWorkspaceIdAndStatusNot(workspaceId, LlmApiKeyStatus.REVOKED);
+ }
+
+ @Override
+ public InventoryHead inventoryHead(AuthenticatedUser actor, UUID workspaceId, int limit) {
+ // Reuses the key list rather than re-deriving visibility: a restricted
+ // row must say the same thing in both places, and there is only one
+ // place that decides what it says.
+ //
+ // The sort keys are named here and nowhere else — the inventory asks for
+ // "newest first, ties in this type's own order" and this is where that
+ // becomes property names of this entity.
+ var page = queryService.listPage(actor, workspaceId,
+ PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")
+ .and(Sort.by(Sort.Direction.DESC, "id"))));
+ return new InventoryHead(
+ page.getContent().stream().map(LlmKeyResourceAdapter::toSummary).toList(),
+ page.getTotalElements());
+ }
+
+ private static ResourceIdentity identityOf(LlmApiKey key) {
+ return new ResourceIdentity(key.getId(), key.getPublicId(), key.getWorkspaceId(),
+ key.getName(), null, key.getStatus().name());
+ }
+
+ private static ResourceSummaryResponse toSummary(LlmKeySummaryResponse key) {
+ return new ResourceSummaryResponse(key.id(), ResourceType.LLM_API_KEY, key.name(), null,
+ key.status().name(), key.workspaceId(), key.workspaceName(), key.accessLimited(),
+ key.ownerNames(), key.accessManageAllowed(), key.createdAt());
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java
new file mode 100644
index 00000000..d071bf72
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java
@@ -0,0 +1,304 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import kr.ac.pusan.pickle.audit.AuditService;
+import kr.ac.pusan.pickle.common.text.Texts;
+import kr.ac.pusan.pickle.llm.dto.LlmSyncRequest;
+import kr.ac.pusan.pickle.llm.dto.LlmSyncResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * The LLM gateway sync heartbeat: stores the gateway's self-report, stamps
+ * contact, and answers either the bare generation or the full authorization
+ * document.
+ *
+ * The unchanged path never builds the document. The report upsert
+ * returns the current generation, and only when the reported
+ * {@code appliedGeneration} differs does the document query run — 12 polls a
+ * minute answered by what is effectively one small upsert. (The relay sync
+ * service is deliberately NOT the template here: it runs its full snapshot
+ * join on every poll and throws the rows away when the generation matches.)
+ *
+ * The document is read by one SQL statement (generation +
+ * service-enabled + key rows in a single MVCC view). This tree never raises
+ * the isolation level, so reading them separately under READ COMMITTED could
+ * pair a new generation with an older row set — which the gateway would then
+ * confirm as applied, and the missed change would never be re-sent.
+ *
+ * A reported generation above ours is not treated as a violation:
+ * it means this side went backwards (a restored backup) while the gateway's
+ * persisted high-water mark did not. Refusing to act would wedge the link
+ * permanently — every document we could produce would sit below the gateway's
+ * floor — so the counter is raised above the reported value, the full
+ * document served, and the event audited once. (The relay link audits and
+ * discards here; copying that would wedge this link.)
+ */
+@Service
+public class LlmSyncService {
+
+ /** Server-side cap on any gateway-reported string persisted anywhere. */
+ static final int REPORTED_TEXT_MAX = 1024;
+
+ /** The one document format this build can produce. */
+ static final int DOCUMENT_FORMAT = 1;
+
+ /** Upstream names stored from the report; anything past this is dropped. */
+ static final int MAX_UPSTREAM_REFS = 32;
+
+ private static final Logger log = LoggerFactory.getLogger(LlmSyncService.class);
+
+ /**
+ * Generation + kill switch + key rows in ONE statement (single MVCC
+ * view). The join condition — not a where clause, so a state with zero
+ * keys still yields its generation row — implements the retention the
+ * gateway relies on:
+ *
+ *
+ * - A key without a minted secret (PENDING, or any row with no
+ * {@code token_hash}) is ABSENT from the document, not
+ * present-and-refused: it authenticates nothing, so there is no
+ * state to publish about it, and a null hash would be a document
+ * the gateway rejects outright. The null-hash condition is the real
+ * gate (a revoked-before-mint key is not PENDING but still has no
+ * secret); the status condition is the belt.
+ * - Revoked and expired keys STAY in the document for a 30-day grace
+ * period, status included, so the gateway can answer
+ * "api_key_revoked" instead of "invalid_api_key"; past the grace
+ * they are dropped and "invalid_api_key" becomes the correct
+ * answer. The payload stays bounded that way.
+ * - An EXPIRED row must carry the expiry the gateway enforces; one
+ * without it (inconsistent data) is left out rather than served in
+ * a shape that could fail open.
+ *
+ */
+ private static final String DOCUMENT_SQL = """
+ select s.generation, s.service_enabled,
+ (select coalesce(json_agg(json_build_object(
+ 'publicName', m.public_name,
+ 'upstreamRef', m.upstream_ref,
+ 'upstreamModel', m.upstream_model,
+ 'fallbackRef', m.fallback_ref,
+ 'visibility', m.visibility,
+ 'maxInputTokens', m.max_input_tokens,
+ 'maxOutputTokens', m.max_output_tokens)), '[]'::json)
+ from llm_models m where m.enabled) as models,
+ k.public_id, k.token_hash, k.status::text as status, k.expires_at,
+ k.rpm, k.tpm, k.concurrency, k.record_bodies
+ from llm_gateway_state s
+ left join llm_api_keys k
+ on k.token_hash is not null
+ and k.status <> 'PENDING'
+ and (k.status <> 'REVOKED' or k.revoked_at is null
+ or k.revoked_at > now() - interval '30 days')
+ and (k.expires_at is null or k.expires_at > now() - interval '30 days')
+ and not (k.status = 'EXPIRED' and k.expires_at is null)
+ where s.id
+ """;
+
+ private final JdbcTemplate jdbcTemplate;
+ private final LlmGatewayGenerations llmGatewayGenerations;
+ private final AuditService auditService;
+ private final ObjectMapper objectMapper;
+
+ public LlmSyncService(JdbcTemplate jdbcTemplate, LlmGatewayGenerations llmGatewayGenerations,
+ AuditService auditService, ObjectMapper objectMapper) {
+ this.jdbcTemplate = jdbcTemplate;
+ this.llmGatewayGenerations = llmGatewayGenerations;
+ this.auditService = auditService;
+ this.objectMapper = objectMapper;
+ }
+
+ @Transactional
+ public LlmSyncResponse sync(LlmSyncRequest request) {
+ long generation = upsertReport(request);
+ long reported = request.appliedGeneration();
+
+ boolean forceFull = false;
+ if (reported > generation) {
+ long raisedTo = llmGatewayGenerations.raiseAbove(reported);
+ // Direct record (not after-commit): a data-recovery signal worth
+ // keeping even if something later in this tx were to fail.
+ auditService.record(null, AuditService.ACTOR_ROLE_LLM_GATEWAY,
+ AuditService.LLM_GATEWAY_GENERATION_RAISE, "llm_gateway", null,
+ Map.of("reportedGeneration", reported, "previousGeneration", generation,
+ "raisedTo", raisedTo), null);
+ log.warn("LLM gateway reported generation {} above ours ({}) — counter restored "
+ + "backwards? raised to {} and serving the full document",
+ reported, generation, raisedTo);
+ generation = raisedTo;
+ forceFull = true;
+ }
+ if (!forceFull && reported == generation) {
+ return new LlmSyncResponse.Unchanged(generation);
+ }
+ if (request.supportedFormat() < DOCUMENT_FORMAT) {
+ // Nothing below format 1 exists to serve; the gateway will refuse
+ // and report it via rejectedEntries/lastError, which is the only
+ // visible outcome possible here.
+ log.warn("LLM gateway supports document format {} below the minimum {}",
+ request.supportedFormat(), DOCUMENT_FORMAT);
+ }
+ return readDocument();
+ }
+
+ /**
+ * Stores the self-report and stamps contact in one upsert — the sync IS
+ * the liveness signal, so contact-lost clears here — and returns the
+ * current generation without touching the key table. Creates the state
+ * row on the very first poll (no migration seeds it).
+ */
+ private long upsertReport(LlmSyncRequest request) {
+ String agentVersion = Texts.sanitizeReported(request.agentVersion(), REPORTED_TEXT_MAX);
+ String lastError = Texts.sanitizeReported(request.lastError(), REPORTED_TEXT_MAX);
+ String upstreamRefs = upstreamRefsJson(request.upstreamRefs());
+ return jdbcTemplate.queryForObject("""
+ insert into llm_gateway_state (id, applied_generation, supported_format,
+ agent_version, started_at, in_flight, max_in_flight, upstream_refs,
+ rejected_entries, reload_failures, last_error, bodies_dropped,
+ usage_ship_failures, spool_write_failures, last_contact_at,
+ contact_lost_since, updated_at)
+ values (true, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), null, now())
+ on conflict (id) do update set
+ applied_generation = excluded.applied_generation,
+ supported_format = excluded.supported_format,
+ agent_version = excluded.agent_version,
+ started_at = excluded.started_at,
+ in_flight = excluded.in_flight,
+ max_in_flight = excluded.max_in_flight,
+ upstream_refs = excluded.upstream_refs,
+ rejected_entries = excluded.rejected_entries,
+ reload_failures = excluded.reload_failures,
+ last_error = excluded.last_error,
+ bodies_dropped = excluded.bodies_dropped,
+ usage_ship_failures = excluded.usage_ship_failures,
+ spool_write_failures = excluded.spool_write_failures,
+ last_contact_at = now(), contact_lost_since = null, updated_at = now()
+ returning generation
+ """, Long.class,
+ request.appliedGeneration(), request.supportedFormat(), agentVersion,
+ request.startedAt() == null ? null : request.startedAt().atOffset(ZoneOffset.UTC),
+ request.inFlight(), request.maxInFlight(), upstreamRefs,
+ request.rejectedEntries(), request.reloadFailures(), lastError,
+ request.bodiesDropped(), request.usageShipFailures(),
+ request.spoolWriteFailures());
+ }
+
+ /**
+ * The reported upstream names, sanitized and stored as a JSON array. Kept
+ * so the model-save flow (DGX round) can validate a model's
+ * {@code upstreamRef}/{@code fallbackRef} against what the caller actually
+ * has configured — case-insensitively, as the gateway matches — instead of
+ * letting a typo cost the model entry at load time.
+ */
+ private String upstreamRefsJson(List reported) {
+ if (reported == null || reported.isEmpty()) {
+ return null;
+ }
+ List sanitized = new ArrayList<>();
+ for (String ref : reported) {
+ if (sanitized.size() >= MAX_UPSTREAM_REFS) {
+ break;
+ }
+ String clean = Texts.sanitizeReported(ref, 128);
+ if (clean != null) {
+ sanitized.add(clean);
+ }
+ }
+ return sanitized.isEmpty() ? null : objectMapper.writeValueAsString(sanitized);
+ }
+
+ // ── document ─────────────────────────────────────────────────────────────
+
+ private LlmSyncResponse readDocument() {
+ return jdbcTemplate.query(DOCUMENT_SQL, rs -> {
+ long generation = 0;
+ boolean serviceEnabled = true;
+ List models = List.of();
+ List keys = new ArrayList<>();
+ while (rs.next()) {
+ generation = rs.getLong("generation");
+ serviceEnabled = rs.getBoolean("service_enabled");
+ // Aggregated in the same statement rather than read separately:
+ // the whole point of one statement is that the generation and
+ // everything the document says cannot come from two different
+ // moments. Identical on every row of the key join, so it is
+ // parsed once.
+ if (models.isEmpty()) {
+ models = parseModels(rs.getString("models"));
+ }
+ UUID publicId = rs.getObject("public_id", UUID.class);
+ if (publicId == null) {
+ continue; // left-join row of a state with no servable key
+ }
+ String status = rs.getString("status");
+ OffsetDateTime expiresAt = rs.getObject("expires_at", OffsetDateTime.class);
+ keys.add(new LlmSyncResponse.KeyEntry(
+ publicId.toString(),
+ rs.getString("token_hash"),
+ // The gateway's vocabulary has no EXPIRED: it enforces
+ // expiresAt itself (a key expires between polls, with
+ // no write to bump the generation), so an api-side
+ // EXPIRED row is served ACTIVE with its real expiry
+ // and the gateway keeps saying "expired", not
+ // "revoked". Rows lacking that expiry never reach
+ // here (see DOCUMENT_SQL).
+ "EXPIRED".equals(status) ? "ACTIVE" : status,
+ expiresAt == null ? null : expiresAt.toInstant(),
+ // No RESTRICTED model exists yet; empty reaches PUBLIC
+ // models only, which is the fail-closed default.
+ List.of(),
+ limits(rs.getObject("rpm", Integer.class),
+ rs.getObject("tpm", Integer.class),
+ rs.getObject("concurrency", Integer.class)),
+ // Long-window quota accounting is a later round; until
+ // it exists the api has decided nothing, and absent
+ // accounting must not lock keys out.
+ false,
+ rs.getBoolean("record_bodies")));
+ }
+ // models is an EMPTY ARRAY, not an omission: "no models" is this
+ // deployment's real state until the catalogue lands with the DGX
+ // round, and the gateway must apply it (omission would mean
+ // "unchanged"). Serving it alongside keys keeps the
+ // both-or-neither rule intact.
+ return new LlmSyncResponse.Document(DOCUMENT_FORMAT, generation, serviceEnabled,
+ models, List.copyOf(keys));
+ });
+ }
+
+ /**
+ * The aggregated model rows. An empty array is a real state the gateway
+ * applies ("this deployment serves nothing"), distinct from the member
+ * being absent, which means unchanged — so a parse failure must not
+ * silently become one: it throws, the poll fails, and the gateway keeps
+ * its last good document rather than being told everything is gone.
+ */
+ private List parseModels(String json) {
+ if (json == null || json.isBlank()) {
+ return List.of();
+ }
+ try {
+ return List.of(objectMapper.readValue(json, LlmSyncResponse.ModelEntry[].class));
+ } catch (Exception e) {
+ throw new IllegalStateException("unreadable model catalogue", e);
+ }
+ }
+
+ private static LlmSyncResponse.KeyLimits limits(Integer rpm, Integer tpm,
+ Integer concurrency) {
+ if (rpm == null && tpm == null && concurrency == null) {
+ return null; // no limits of its own: the member drops out entirely
+ }
+ return new LlmSyncResponse.KeyLimits(rpm, tpm, concurrency);
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmUsageService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmUsageService.java
new file mode 100644
index 00000000..0017ffe2
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmUsageService.java
@@ -0,0 +1,219 @@
+package kr.ac.pusan.pickle.llm;
+
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeParseException;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import kr.ac.pusan.pickle.common.text.Texts;
+import kr.ac.pusan.pickle.llm.dto.LlmUsageRequest;
+import kr.ac.pusan.pickle.llm.dto.LlmUsageResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * Usage-event ingest. Delivery is at-least-once from a persisted checkpoint,
+ * so the design constraints are:
+ *
+ *
+ * - Dedup on the event id with {@code on conflict do nothing
+ * returning id} read as a nullable — never {@code do update}, the same
+ * id always carries the same content. The nullable read is how accepted
+ * is counted against duplicate (the IP allocator's idiom).
+ * - A problem with individual events is never a 4xx. The gateway
+ * reads 400/409/413/422 as "this batch is the problem", skips it and
+ * moves its checkpoint past it — those events are gone. Bad events are
+ * counted into {@code rejected} and the batch answers 2xx.
+ * - Events do not arrive in time order (the checkpoint is per day
+ * file; a request straddling UTC midnight ships late). Everything
+ * time-derived orders by {@code requested_at}, and the last-used stamp
+ * only ever moves forward.
+ *
+ */
+@Service
+public class LlmUsageService {
+
+ /** The contract bound on the opaque event id. */
+ static final int MAX_EVENT_ID_LENGTH = 64;
+
+ /** Cap on any reported free-text field persisted here. */
+ static final int REPORTED_TEXT_MAX = 256;
+
+ private static final Logger log = LoggerFactory.getLogger(LlmUsageService.class);
+
+ private final JdbcTemplate jdbcTemplate;
+
+ public LlmUsageService(JdbcTemplate jdbcTemplate) {
+ this.jdbcTemplate = jdbcTemplate;
+ }
+
+ @Transactional
+ public LlmUsageResponse ingest(LlmUsageRequest request) {
+ List events =
+ request.events() == null ? List.of() : request.events();
+ Map keyIds = resolveKeyIds(events);
+ Map lastUsed = new HashMap<>();
+
+ int accepted = 0;
+ int duplicates = 0;
+ int rejected = 0;
+ for (LlmUsageRequest.UsageEvent event : events) {
+ if (event == null) {
+ rejected++;
+ continue;
+ }
+ String eventId = event.eventUuid();
+ String status = Texts.sanitizeReported(event.status(), REPORTED_TEXT_MAX);
+ Instant requestedAt = parseInstant(event.requestedAt());
+ if (eventId == null || eventId.isBlank() || eventId.length() > MAX_EVENT_ID_LENGTH
+ || containsControlChars(eventId) || status == null || requestedAt == null) {
+ rejected++;
+ continue;
+ }
+ // Unattributed events (no keyId, or one that resolves to nothing)
+ // are kept with a null key: they are the only trace of a client
+ // looping on a bad key.
+ Long keyId = event.keyId() == null ? null : keyIds.get(event.keyId().strip());
+ Long insertedId = tryInsert(event, eventId, status, requestedAt, keyId);
+ if (insertedId == null) {
+ duplicates++;
+ continue;
+ }
+ accepted++;
+ if (keyId != null) {
+ lastUsed.merge(keyId, requestedAt, (a, b) -> a.isAfter(b) ? a : b);
+ }
+ }
+ stampLastUsed(lastUsed);
+ if (rejected > 0) {
+ log.warn("LLM usage batch: accepted {}, duplicates {}, rejected {}",
+ accepted, duplicates, rejected);
+ } else {
+ log.info("LLM usage batch: accepted {}, duplicates {}", accepted, duplicates);
+ }
+ return new LlmUsageResponse(accepted, duplicates, rejected);
+ }
+
+ /** The dedup insert; null means the event id already exists (duplicate). */
+ private Long tryInsert(LlmUsageRequest.UsageEvent event, String eventId, String status,
+ Instant requestedAt, Long keyId) {
+ return jdbcTemplate.query("""
+ insert into llm_usage_events (event_id, key_id, generation, public_model_name,
+ upstream_ref, attempts, status, error_type, input_tokens, output_tokens,
+ estimated, latency_ms, ttft_ms, requested_at)
+ values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ on conflict (event_id) do nothing
+ returning id
+ """, rs -> rs.next() ? rs.getLong(1) : null,
+ eventId, keyId, event.generation(),
+ Texts.sanitizeReported(event.publicModelName(), REPORTED_TEXT_MAX),
+ Texts.sanitizeReported(event.upstreamRef(), REPORTED_TEXT_MAX),
+ event.attempts(), status,
+ Texts.sanitizeReported(event.errorType(), REPORTED_TEXT_MAX),
+ nonNegative(event.inputTokens()), nonNegative(event.outputTokens()),
+ Boolean.TRUE.equals(event.estimated()),
+ nonNegative(event.latencyMs()), event.ttftMs(),
+ requestedAt.atOffset(ZoneOffset.UTC));
+ }
+
+ /**
+ * One batched lookup from the reported (opaque) key ids to internal key
+ * rows. Keys live on soft delete precisely so this join keeps working for
+ * revoked keys' historical usage.
+ */
+ private Map resolveKeyIds(List events) {
+ Map parsed = new HashMap<>();
+ Set wanted = new LinkedHashSet<>();
+ for (LlmUsageRequest.UsageEvent event : events) {
+ if (event == null || event.keyId() == null || event.keyId().isBlank()) {
+ continue;
+ }
+ String reported = event.keyId().strip();
+ if (parsed.containsKey(reported)) {
+ continue;
+ }
+ try {
+ UUID publicId = UUID.fromString(reported);
+ parsed.put(reported, publicId);
+ wanted.add(publicId);
+ } catch (IllegalArgumentException e) {
+ // Not an id this side ever issued; the event stays, unattributed.
+ }
+ }
+ if (wanted.isEmpty()) {
+ return Map.of();
+ }
+ String placeholders = String.join(", ", java.util.Collections.nCopies(wanted.size(), "?"));
+ Map byPublicId = new HashMap<>();
+ jdbcTemplate.query(
+ "select id, public_id from llm_api_keys where public_id in (" + placeholders + ")",
+ rs -> {
+ byPublicId.put(rs.getObject("public_id", UUID.class), rs.getLong("id"));
+ }, wanted.toArray());
+ Map resolved = new HashMap<>();
+ for (Map.Entry entry : parsed.entrySet()) {
+ Long id = byPublicId.get(entry.getValue());
+ if (id != null) {
+ resolved.put(entry.getKey(), id);
+ }
+ }
+ return resolved;
+ }
+
+ /**
+ * Moves each key's last-used stamp forward to the newest accepted
+ * {@code requestedAt} — forward only, because batches arrive out of time
+ * order and a late batch must not walk the stamp backwards.
+ */
+ private void stampLastUsed(Map lastUsed) {
+ for (Map.Entry entry : lastUsed.entrySet()) {
+ OffsetDateTime at = entry.getValue().atOffset(ZoneOffset.UTC);
+ jdbcTemplate.update("""
+ update llm_api_keys
+ set last_used_at = ?
+ where id = ? and (last_used_at is null or last_used_at < ?)
+ """, at, entry.getKey(), at);
+ }
+ }
+
+ /**
+ * Lenient per-event timestamp parse: a value Jackson could not bind would
+ * have failed the whole batch, so the field arrives as a string and an
+ * unparseable one rejects only its own event.
+ */
+ private static Instant parseInstant(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ try {
+ return OffsetDateTime.parse(value.strip()).toInstant();
+ } catch (DateTimeParseException e) {
+ return null;
+ }
+ }
+
+ private static boolean containsControlChars(String value) {
+ for (int i = 0; i < value.length(); i++) {
+ if (Character.isISOControl(value.charAt(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static int nonNegative(Integer value) {
+ return value == null || value < 0 ? 0 : value;
+ }
+
+ private static long nonNegative(Long value) {
+ return value == null || value < 0 ? 0 : value;
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/ApproveLlmKeyRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/ApproveLlmKeyRequestSpec.java
new file mode 100644
index 00000000..abece66a
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/ApproveLlmKeyRequestSpec.java
@@ -0,0 +1,36 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code ApproveLlmKeyRequestSpec}: what the reviewer grants an
+ * LLM API key request.
+ *
+ * All four are optional, and that is the ordinary approval: a key with no
+ * limits of its own runs on the gateway's defaults, which is what the service
+ * is tuned for. Granting a number here is the exception a reviewer makes
+ * deliberately, so leaving the form untouched must mean "the usual", not "no
+ * limit at all".
+ */
+public record ApproveLlmKeyRequestSpec(
+ @Schema(description = "부여 분당 요청 수. 비우면 서비스 기본값이 적용됩니다.")
+ @Min(value = 1, message = "분당 요청 수는 1 이상이어야 합니다.")
+ @Max(value = 10000, message = "분당 요청 수가 너무 큽니다.")
+ @Nullable Integer grantedRpm,
+
+ @Schema(description = "부여 분당 토큰 수. 비우면 서비스 기본값이 적용됩니다.")
+ @Min(value = 1, message = "분당 토큰 수는 1 이상이어야 합니다.")
+ @Nullable Integer grantedTpm,
+
+ @Schema(description = "부여 동시 요청 수. 비우면 서비스 기본값이 적용됩니다.")
+ @Min(value = 1, message = "동시 요청 수는 1 이상이어야 합니다.")
+ @Max(value = 100, message = "동시 요청 수가 너무 큽니다.")
+ @Nullable Integer grantedConcurrency,
+
+ @Schema(description = "부여 일일 토큰 수. 비우면 서비스 기본값이 적용됩니다.")
+ @Min(value = 1, message = "일일 토큰 수는 1 이상이어야 합니다.")
+ @Nullable Long grantedDailyTokens) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/CreateLlmKeyRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/CreateLlmKeyRequestSpec.java
new file mode 100644
index 00000000..57707111
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/CreateLlmKeyRequestSpec.java
@@ -0,0 +1,34 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.Size;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code CreateLlmKeyRequestSpec}: what an LLM API key request
+ * asks for beyond the fields every request has.
+ *
+ *
Every limit here is optional. A key that names none is asking for the
+ * service's defaults, which is what most requests are; the fields exist for the
+ * case where somebody knows they need more and has to say why.
+ */
+public record CreateLlmKeyRequestSpec(
+ @Schema(description = "이 Key를 어디에 쓸지. 기본 한도로 충분하면 비워 두어도 됩니다.")
+ @Size(max = 2000, message = "사용 계획은 2000자 이하여야 합니다.")
+ @Nullable String usagePlan,
+
+ @Schema(description = "희망 분당 요청 수. 비우면 서비스 기본값을 받습니다.")
+ @Min(value = 1, message = "분당 요청 수는 1 이상이어야 합니다.")
+ @Max(value = 10000, message = "분당 요청 수가 너무 큽니다.")
+ @Nullable Integer reqRpm,
+
+ @Schema(description = "희망 분당 토큰 수. 비우면 서비스 기본값을 받습니다.")
+ @Min(value = 1, message = "분당 토큰 수는 1 이상이어야 합니다.")
+ @Nullable Integer reqTpm,
+
+ @Schema(description = "희망 일일 토큰 수. 비우면 서비스 기본값을 받습니다.")
+ @Min(value = 1, message = "일일 토큰 수는 1 이상이어야 합니다.")
+ @Nullable Long reqDailyTokens) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/IssuedLlmKeyResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/IssuedLlmKeyResponse.java
new file mode 100644
index 00000000..f4415e6c
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/IssuedLlmKeyResponse.java
@@ -0,0 +1,37 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.Instant;
+import java.util.UUID;
+import kr.ac.pusan.pickle.llm.LlmApiKey;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code IssuedLlmKey}: the answer to issuing or rotating a
+ * key, and the only response anywhere that carries a plaintext.
+ *
+ *
It is not stored, and no other endpoint can produce it again — what the
+ * database holds is a hash. A client that loses this response has to rotate,
+ * which is the behaviour the requirements ask for rather than a limitation of
+ * this one.
+ */
+public record IssuedLlmKeyResponse(
+ @Schema(description = "키 식별자")
+ UUID id,
+
+ @Schema(description = "키 이름")
+ String name,
+
+ @Schema(description = """
+ 발급된 API Key 평문. **이 응답에서만 볼 수 있습니다** — 서버에는 해시만 \
+ 저장되며 다시 조회할 수 없습니다. 분실하면 재발급해야 합니다.""")
+ String token,
+
+ @Schema(description = "만료 시각. 없으면 만료되지 않습니다.")
+ @Nullable Instant expiresAt) {
+
+ public static IssuedLlmKeyResponse of(LlmApiKey key, String plaintext) {
+ return new IssuedLlmKeyResponse(key.getPublicId(), key.getName(), plaintext,
+ key.getExpiresAt());
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmBodiesRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmBodiesRequest.java
new file mode 100644
index 00000000..fb19a82b
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmBodiesRequest.java
@@ -0,0 +1,36 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import java.util.List;
+import tools.jackson.databind.JsonNode;
+
+/**
+ * Gateway → api opted-in prompt/response text (internal contract). Held in
+ * memory gateway-side and posted directly — never spooled to its disk — so a
+ * refused batch is text lost, though never accounting (the usage event went
+ * to the durable spool regardless).
+ *
+ *
No bean-validation constraints, for the same reason as
+ * {@link LlmUsageRequest}: a non-2xx costs captured text.
+ */
+public record LlmBodiesRequest(
+ String agentVersion,
+ List records) {
+
+ /**
+ * One captured exchange. {@code request} is either the messages array as
+ * sent or — when truncated — a JSON string holding the prefix
+ * (cutting JSON mid-way produces nothing a parser will take), which is why
+ * it is a {@link JsonNode} and not a typed shape. The two truncation flags
+ * are separate because a cut prompt and a cut answer mean different things
+ * to whoever reads the record.
+ */
+ public record BodyRecord(
+ String eventUuid,
+ String keyId,
+ String requestedAt,
+ JsonNode request,
+ String response,
+ Boolean requestTruncated,
+ Boolean responseTruncated) {
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyDetailResponse.java
new file mode 100644
index 00000000..c86f27c1
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyDetailResponse.java
@@ -0,0 +1,61 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.Instant;
+import java.util.UUID;
+import kr.ac.pusan.pickle.access.ResourceRole;
+import kr.ac.pusan.pickle.llm.LlmApiKey;
+import kr.ac.pusan.pickle.llm.LlmApiKeyStatus;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code LlmKeyDetail}. Reaching it at all takes a grant —
+ * a non-member is answered 404 and a member without a grant an honest 403 —
+ * so unlike the summary it has no restricted shape.
+ *
+ * The token hash appears in no field, and neither does anything derived
+ * from it beyond the prefix the plaintext was minted with. A lost key is
+ * reissued, never recovered, and this response is one of the places that
+ * makes that claim true.
+ */
+public record LlmKeyDetailResponse(
+ UUID id,
+ @Schema(description = "키 이름")
+ String name,
+ @Schema(description = "용도")
+ @Nullable String purpose,
+ LlmApiKeyStatus status,
+ @Schema(description = "평문 앞부분 — 두 키를 구별하기 위한 값입니다. 아직 발급 전이면 null입니다.")
+ @Nullable String tokenPrefix,
+ @Schema(description = "만료 시각. null이면 만료가 없습니다.")
+ @Nullable Instant expiresAt,
+ @Schema(description = "마지막 사용 시각. 게이트웨이가 배치로 보고하므로 지연될 수 있습니다.")
+ @Nullable Instant lastUsedAt,
+ @Schema(description = "분당 요청 한도. null이면 게이트웨이 기본값을 따릅니다.")
+ @Nullable Integer rpm,
+ @Schema(description = "분당 토큰 한도. null이면 게이트웨이 기본값을 따릅니다.")
+ @Nullable Integer tpm,
+ @Schema(description = "동시 요청 한도. null이면 게이트웨이 기본값을 따릅니다.")
+ @Nullable Integer concurrency,
+ @Schema(description = "프롬프트·응답 본문 기록 여부")
+ boolean recordBodies,
+ @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID workspaceId,
+ String workspaceName,
+ Instant createdAt,
+ @Schema(description = "회수된 시각. 회수되지 않았으면 null입니다.")
+ @Nullable Instant revokedAt,
+ @Schema(description = "요청자가 이 키의 접근 목록에서 받은 등급")
+ @Nullable ResourceRole myResourceRole,
+ @Schema(description = "접근 권한 목록을 관리할 수 있는지")
+ boolean accessManageAllowed) {
+
+ public static LlmKeyDetailResponse from(LlmApiKey key, UUID workspaceId, String workspaceName,
+ @Nullable ResourceRole myResourceRole, boolean accessManageAllowed) {
+ return new LlmKeyDetailResponse(key.getPublicId(), key.getName(), key.getPurpose(),
+ key.getStatus(), key.getTokenPrefix(), key.getExpiresAt(), key.getLastUsedAt(),
+ key.getRpm(), key.getTpm(), key.getConcurrency(), key.isRecordBodies(),
+ workspaceId, workspaceName, key.getCreatedAt(), key.getRevokedAt(),
+ myResourceRole, accessManageAllowed);
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyRequestSpecResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyRequestSpecResponse.java
new file mode 100644
index 00000000..ab8de30c
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyRequestSpecResponse.java
@@ -0,0 +1,47 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import kr.ac.pusan.pickle.llm.LlmKeyRequestDetail;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code LlmKeyRequestSpec}: what an LLM API key request asked
+ * for and what the reviewer granted of it.
+ *
+ *
Every number is optional on both sides. Null on the requested side means
+ * the applicant did not ask for a particular limit; null on the granted side
+ * means the reviewer granted the service defaults — which is the ordinary
+ * decision, not an omission.
+ */
+public record LlmKeyRequestSpecResponse(
+ @Schema(description = "사용 계획")
+ @Nullable String usagePlan,
+
+ @Schema(description = "희망 분당 요청 수")
+ @Nullable Integer reqRpm,
+
+ @Schema(description = "희망 분당 토큰 수")
+ @Nullable Integer reqTpm,
+
+ @Schema(description = "희망 일일 토큰 수")
+ @Nullable Long reqDailyTokens,
+
+ @Schema(description = "부여 분당 요청 수. 비어 있으면 서비스 기본값입니다.")
+ @Nullable Integer grantedRpm,
+
+ @Schema(description = "부여 분당 토큰 수. 비어 있으면 서비스 기본값입니다.")
+ @Nullable Integer grantedTpm,
+
+ @Schema(description = "부여 동시 요청 수. 비어 있으면 서비스 기본값입니다.")
+ @Nullable Integer grantedConcurrency,
+
+ @Schema(description = "부여 일일 토큰 수. 비어 있으면 서비스 기본값입니다.")
+ @Nullable Long grantedDailyTokens) {
+
+ public static LlmKeyRequestSpecResponse from(LlmKeyRequestDetail detail) {
+ return new LlmKeyRequestSpecResponse(detail.getReqPurpose(), detail.getReqRpm(),
+ detail.getReqTpm(), detail.getReqDailyTokens(), detail.getGrantedRpm(),
+ detail.getGrantedTpm(), detail.getGrantedConcurrency(),
+ detail.getGrantedDailyTokens());
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeySummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeySummaryResponse.java
new file mode 100644
index 00000000..fdeb178d
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeySummaryResponse.java
@@ -0,0 +1,80 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+import kr.ac.pusan.pickle.llm.LlmApiKey;
+import kr.ac.pusan.pickle.llm.LlmApiKeyStatus;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code LlmKeySummary}. {@code workspaceName} is joined for
+ * the list view.
+ *
+ *
A member of the owning workspace who holds no grant on a key still sees
+ * that it exists, and the row is then restricted: {@code accessLimited}
+ * is true, {@code ownerNames} says who to ask, and everything else is omitted
+ * here rather than blanked in the console — a field the API sends has already
+ * left the building. Unlike the VM, whose listed name is its SSH slug, a key's
+ * name is only the label its requester chose, so a restricted row keeps it;
+ * what it drops is {@code tokenPrefix}, the one field derived from the secret.
+ *
+ *
The token hash itself appears in no view, restricted or not: what
+ * authenticates at the gateway is never part of any response.
+ */
+public record LlmKeySummaryResponse(
+ UUID id,
+ @Schema(description = "키 이름")
+ String name,
+ @Schema(description = "용도. 접근 권한이 없으면 생략됩니다.")
+ @Nullable String purpose,
+ LlmApiKeyStatus status,
+ @Schema(description = "평문 앞부분 — 목록에서 두 키를 구별하기 위한 값입니다. "
+ + "접근 권한이 없거나 아직 발급 전이면 생략됩니다.")
+ @Nullable String tokenPrefix,
+ @Schema(description = "만료 시각. null이면 만료가 없습니다. 접근 권한이 없으면 생략됩니다.")
+ @Nullable Instant expiresAt,
+ @Schema(description = "마지막 사용 시각. 게이트웨이가 배치로 보고하므로 지연될 수 있습니다. "
+ + "접근 권한이 없으면 생략됩니다.")
+ @Nullable Instant lastUsedAt,
+ @Schema(description = "분당 요청 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다.")
+ @Nullable Integer rpm,
+ @Schema(description = "분당 토큰 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다.")
+ @Nullable Integer tpm,
+ @Schema(description = "동시 요청 한도. null이면 게이트웨이 기본값을 따릅니다. 접근 권한이 없으면 생략됩니다.")
+ @Nullable Integer concurrency,
+ @Schema(description = "프롬프트·응답 본문 기록 여부. 접근 권한이 없으면 생략됩니다.")
+ @Nullable Boolean recordBodies,
+ @Schema(description = "소유 워크스페이스. 행이 사라진 경우에만 null입니다.")
+ @Nullable UUID workspaceId,
+ String workspaceName,
+ Instant createdAt,
+ @Schema(description = "true면 이 키의 접근 권한이 없어 이름·상태·소유자만 표시됩니다.")
+ boolean accessLimited,
+ @Schema(description = "이 키의 소유자 이름. 접근을 요청할 상대입니다.")
+ List ownerNames,
+ @Schema(description = "접근 권한이 없어도 접근 권한 목록을 관리할 수 있는지. 워크스페이스 소유자가 참입니다.")
+ boolean accessManageAllowed) {
+
+ /** The full row a grant opens. */
+ public static LlmKeySummaryResponse from(LlmApiKey key, UUID workspaceId,
+ String workspaceName) {
+ return new LlmKeySummaryResponse(key.getPublicId(), key.getName(), key.getPurpose(),
+ key.getStatus(), key.getTokenPrefix(), key.getExpiresAt(), key.getLastUsedAt(),
+ key.getRpm(), key.getTpm(), key.getConcurrency(), key.isRecordBodies(),
+ workspaceId, workspaceName, key.getCreatedAt(), false, List.of(), false);
+ }
+
+ /**
+ * Name, state and who to ask — nothing about the key itself. The token
+ * prefix stays out: it exists so a list can tell two keys apart, and a
+ * person this row is restricted for has no two keys to tell apart.
+ */
+ public static LlmKeySummaryResponse restricted(LlmApiKey key, UUID workspaceId,
+ String workspaceName, List ownerNames, boolean accessManageAllowed) {
+ return new LlmKeySummaryResponse(key.getPublicId(), key.getName(), null, key.getStatus(),
+ null, null, null, null, null, null, null, workspaceId, workspaceName,
+ key.getCreatedAt(), true, ownerNames, accessManageAllowed);
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncRequest.java
new file mode 100644
index 00000000..5efbffd7
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncRequest.java
@@ -0,0 +1,36 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotNull;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Gateway → api sync poll (internal contract, not part of the public one).
+ * This request is the only channel from the gateway to the api — the api
+ * never calls the gateway — so everything on it is a claim to display, never
+ * to act on: strings are control-stripped and truncated server-side before
+ * touching any row, and gauges are stored as reported.
+ *
+ * Only {@code appliedGeneration}, {@code supportedFormat} and
+ * {@code inFlight} are always present; every other member is omitted when
+ * zero or empty, so an ordinary poll from a healthy gateway carries a handful
+ * of fields. Deliberately no rejecting constraints beyond the three required
+ * members: a 400 here would repeat on every 5-second poll and freeze the
+ * authorization channel over a cosmetic report field.
+ */
+public record LlmSyncRequest(
+ @NotNull @Min(0) Long appliedGeneration,
+ @NotNull Integer supportedFormat,
+ String agentVersion,
+ Instant startedAt,
+ @NotNull Integer inFlight,
+ Integer maxInFlight,
+ List upstreamRefs,
+ Integer rejectedEntries,
+ Long reloadFailures,
+ String lastError,
+ Long bodiesDropped,
+ Long usageShipFailures,
+ Long spoolWriteFailures) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncResponse.java
new file mode 100644
index 00000000..932cc7be
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncResponse.java
@@ -0,0 +1,87 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * api → gateway sync answer, in exactly two shapes — the split is structural
+ * on purpose:
+ *
+ *
+ * - {@link Unchanged} is the whole "you are current" answer:
+ * {@code {"generation": N}} and nothing else.
+ * - {@link Document} always carries {@code models} AND {@code keys}
+ * together. A response with exactly one of them is a contract violation
+ * the gateway refuses: omission is how "unchanged" is expressed, so
+ * {@code models} alone would revoke every key and {@code keys} alone
+ * would remove every model. Two record shapes make the invalid state
+ * inexpressible — no {@code @JsonInclude(NON_NULL)} on the collections,
+ * no null to drop out by accident.
+ *
+ *
+ * {@code generation} and {@code serviceEnabled} are primitives, never
+ * boxed: a null {@code serviceEnabled} would vanish from the JSON, and the
+ * gateway refuses a document without it rather than reading absence as
+ * {@code false} — the api must not be able to express that document at
+ * all.
+ *
+ * An empty {@code keys} array is a real state ("no keys at all")
+ * that the gateway applies, distinct from the member being absent
+ * ("unchanged"); the same holds for {@code models}.
+ */
+public sealed interface LlmSyncResponse {
+
+ /** The caller is current: generation only, no document members at all. */
+ record Unchanged(long generation) implements LlmSyncResponse {
+ }
+
+ /** The full authorization document, from one MVCC snapshot. */
+ record Document(
+ int formatVersion,
+ long generation,
+ boolean serviceEnabled,
+ List models,
+ List keys) implements LlmSyncResponse {
+ }
+
+ /**
+ * One servable model. No rows exist yet — the model catalogue arrives
+ * with the DGX round — but the entry shape is pinned here so the wire
+ * format is settled before the first row is.
+ */
+ record ModelEntry(
+ String publicName,
+ String upstreamRef,
+ String upstreamModel,
+ String fallbackRef,
+ String visibility,
+ int maxInputTokens,
+ int maxOutputTokens) {
+ }
+
+ /**
+ * One issued key as the gateway needs it: the sha256 of the bearer (never
+ * plaintext), a status the gateway's vocabulary accepts (exactly ACTIVE |
+ * SUSPENDED | REVOKED — an entry outside it is dropped gateway-side and
+ * its owner loses service, so mapping down happens before serving), and
+ * the limits the gateway enforces. {@code expiresAt} and {@code limits}
+ * are omitted when absent (gateway defaults apply).
+ */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ record KeyEntry(
+ String keyId,
+ String tokenHash,
+ String status,
+ Instant expiresAt,
+ List allowedModels,
+ KeyLimits limits,
+ boolean quotaExhausted,
+ boolean recordBodies) {
+ }
+
+ /** Short-window limits enforced in the gateway; null members omitted. */
+ @JsonInclude(JsonInclude.Include.NON_NULL)
+ record KeyLimits(Integer rpm, Integer tpm, Integer concurrency) {
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageRequest.java
new file mode 100644
index 00000000..af176aeb
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageRequest.java
@@ -0,0 +1,50 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import java.util.List;
+
+/**
+ * Gateway → api usage batch (internal contract). Delivery is at-least-once
+ * from a persisted checkpoint, so duplicates are normal, not a malfunction.
+ *
+ * Deliberately no bean-validation constraints anywhere on this type.
+ * The gateway reads 400/409/413/422 as "this batch is the problem", skips the
+ * batch and moves its checkpoint past it — those events are then gone for
+ * good. A single malformed event must therefore never surface as a 4xx: the
+ * events are validated one by one in the service, bad ones are counted into
+ * {@code rejected}, and the batch as a whole answers 2xx.
+ *
+ * {@code requestedAt} is a string parsed per event for the same reason —
+ * a timestamp Jackson cannot bind would fail the whole batch at
+ * deserialization, before any per-event handling could run.
+ */
+public record LlmUsageRequest(
+ String agentVersion,
+ List events) {
+
+ /**
+ * One spooled event, verbatim. Only {@code eventUuid}, {@code status},
+ * {@code inputTokens}, {@code outputTokens}, {@code latencyMs} and
+ * {@code requestedAt} are always present; the rest are omitted when zero
+ * or empty. {@code eventUuid} is an opaque string of at most 64
+ * characters, NOT necessarily a UUID (the gateway falls back to a
+ * timestamp-derived id when its random source fails). {@code keyId} may
+ * be absent — several error paths never resolve a key, and those events
+ * are kept, not dropped.
+ */
+ public record UsageEvent(
+ String eventUuid,
+ Long generation,
+ String keyId,
+ String publicModelName,
+ String upstreamRef,
+ Integer attempts,
+ String status,
+ String errorType,
+ Integer inputTokens,
+ Integer outputTokens,
+ Boolean estimated,
+ Long latencyMs,
+ Long ttftMs,
+ String requestedAt) {
+ }
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageResponse.java
new file mode 100644
index 00000000..6d2581ed
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageResponse.java
@@ -0,0 +1,10 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+/**
+ * Ingest tally for one usage batch. The gateway discards this body — it is
+ * for the api's own log — but the split matters there: {@code duplicates} are
+ * ordinary at-least-once re-sends, while a growing {@code rejected} means the
+ * gateway is shipping events this side cannot store.
+ */
+public record LlmUsageResponse(int accepted, int duplicates, int rejected) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/llm/dto/UpdateLlmKeyRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/UpdateLlmKeyRequest.java
new file mode 100644
index 00000000..0cc0d02d
--- /dev/null
+++ b/src/main/java/kr/ac/pusan/pickle/llm/dto/UpdateLlmKeyRequest.java
@@ -0,0 +1,28 @@
+package kr.ac.pusan.pickle.llm.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Size;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Contract schema {@code UpdateLlmKey}: what the owner may change about a key
+ * after it exists.
+ *
+ * Both members are optional and absent means "leave it alone", so a client
+ * that only wants to flip recording does not have to resend the name it is not
+ * changing.
+ */
+public record UpdateLlmKeyRequest(
+ @Schema(description = "키 이름. 생략하면 그대로 둡니다.")
+ @Size(min = 1, max = 100, message = "키 이름은 1자 이상 100자 이하여야 합니다.")
+ @Nullable String name,
+
+ @Schema(description = "사용 목적. 생략하면 그대로 둡니다.")
+ @Size(max = 2000, message = "사용 목적은 2000자 이하여야 합니다.")
+ @Nullable String purpose,
+
+ @Schema(description = """
+ 요청·응답 본문 기록 여부. 기본값은 꺼짐이며, 켜면 이 키로 보낸 프롬프트와 \
+ 응답이 수집됩니다. 생략하면 그대로 둡니다.""")
+ @Nullable Boolean recordBodies) {
+}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelayAuthFilter.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayAuthFilter.java
index 6dd64490..87d849af 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/RelayAuthFilter.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/RelayAuthFilter.java
@@ -1,11 +1,8 @@
package kr.ac.pusan.pickle.relay;
import jakarta.servlet.FilterChain;
-import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletException;
-import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -16,7 +13,7 @@
import kr.ac.pusan.pickle.common.error.ApiException;
import kr.ac.pusan.pickle.common.error.ErrorCodes;
import kr.ac.pusan.pickle.common.error.ProblemJsonWriter;
-import kr.ac.pusan.pickle.common.web.RequestBodyCapExceededException;
+import kr.ac.pusan.pickle.common.web.BodyCappingRequest;
import kr.ac.pusan.pickle.config.RelayProperties;
import org.springframework.http.HttpHeaders;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -149,75 +146,4 @@ private void payloadTooLarge(HttpServletRequest request, HttpServletResponse res
problemJsonWriter.write(request, response, 413, ErrorCodes.VALIDATION_FAILED,
"요청 본문이 너무 큽니다", "동기화 요청 본문이 허용 크기를 초과했습니다.");
}
-
- /**
- * Hard byte cap on the request body. The Content-Length pre-check catches
- * declared sizes; this wrapper is what bounds a chunked (undeclared-length)
- * body — exceeding the cap aborts the read with
- * {@link RequestBodyCapExceededException}, which the global handler maps
- * to the same 413 as a declared-length violation. {@code getReader()}
- * delegates through the same capped stream.
- */
- static final class BodyCappingRequest extends HttpServletRequestWrapper {
-
- private final long cap;
-
- BodyCappingRequest(HttpServletRequest request, long cap) {
- super(request);
- this.cap = cap;
- }
-
- @Override
- public java.io.BufferedReader getReader() throws IOException {
- String encoding = getCharacterEncoding();
- return new java.io.BufferedReader(new java.io.InputStreamReader(getInputStream(),
- encoding != null ? encoding : StandardCharsets.UTF_8.name()));
- }
-
- @Override
- public ServletInputStream getInputStream() throws IOException {
- ServletInputStream delegate = super.getInputStream();
- return new ServletInputStream() {
- private long read;
-
- private void count(long n) throws IOException {
- if (n > 0) {
- read += n;
- if (read > cap) {
- throw new RequestBodyCapExceededException(cap);
- }
- }
- }
-
- @Override
- public int read() throws IOException {
- int b = delegate.read();
- count(b >= 0 ? 1 : 0);
- return b;
- }
-
- @Override
- public int read(byte[] b, int off, int len) throws IOException {
- int n = delegate.read(b, off, len);
- count(n);
- return n;
- }
-
- @Override
- public boolean isFinished() {
- return delegate.isFinished();
- }
-
- @Override
- public boolean isReady() {
- return delegate.isReady();
- }
-
- @Override
- public void setReadListener(ReadListener readListener) {
- delegate.setReadListener(readListener);
- }
- };
- }
- }
}
diff --git a/src/main/java/kr/ac/pusan/pickle/relay/RelayTokens.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayTokens.java
index b0ac4bf9..1df1b660 100644
--- a/src/main/java/kr/ac/pusan/pickle/relay/RelayTokens.java
+++ b/src/main/java/kr/ac/pusan/pickle/relay/RelayTokens.java
@@ -1,10 +1,8 @@
package kr.ac.pusan.pickle.relay;
-import java.nio.charset.StandardCharsets;
-import java.security.MessageDigest;
-import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import java.util.HexFormat;
+import kr.ac.pusan.pickle.common.text.Secrets;
/**
* Per-relay sync tokens: 32 random bytes as 64 lowercase hex chars (hex only —
@@ -26,11 +24,6 @@ static String newToken() {
}
static String sha256Hex(String value) {
- try {
- MessageDigest digest = MessageDigest.getInstance("SHA-256");
- return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
- } catch (NoSuchAlgorithmException e) {
- throw new IllegalStateException("SHA-256 unavailable", e);
- }
+ return Secrets.sha256Hex(value);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
index 6c930aec..083d36c9 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java
@@ -22,6 +22,9 @@
import kr.ac.pusan.pickle.user.UserRepository;
import kr.ac.pusan.pickle.request.dto.RequestDetailResponse;
import kr.ac.pusan.pickle.request.dto.RequestReviewResponse;
+import kr.ac.pusan.pickle.llm.LlmKeyRequestDetail;
+import kr.ac.pusan.pickle.llm.LlmKeyRequestDetailRepository;
+import kr.ac.pusan.pickle.llm.dto.LlmKeyRequestSpecResponse;
import kr.ac.pusan.pickle.request.vm.VmRequestDetail;
import kr.ac.pusan.pickle.request.vm.VmRequestDetailRepository;
import kr.ac.pusan.pickle.request.vm.dto.VmRequestSpecResponse;
@@ -37,6 +40,7 @@ public class RequestAssembler {
private final RequestReviewRepository reviewRepository;
private final VmRequestDetailRepository vmDetailRepository;
+ private final LlmKeyRequestDetailRepository llmKeyDetailRepository;
private final WorkspaceRepository workspaceRepository;
private final OrgRepository orgRepository;
private final UserRepository userRepository;
@@ -45,12 +49,15 @@ public class RequestAssembler {
private final NodeRepository nodeRepository;
public RequestAssembler(RequestReviewRepository reviewRepository,
- VmRequestDetailRepository vmDetailRepository, WorkspaceRepository workspaceRepository,
+ VmRequestDetailRepository vmDetailRepository,
+ LlmKeyRequestDetailRepository llmKeyDetailRepository,
+ WorkspaceRepository workspaceRepository,
OrgRepository orgRepository, UserRepository userRepository,
OsImageRepository osImageRepository, VmFlavorRepository vmFlavorRepository,
NodeRepository nodeRepository) {
this.reviewRepository = reviewRepository;
this.vmDetailRepository = vmDetailRepository;
+ this.llmKeyDetailRepository = llmKeyDetailRepository;
this.workspaceRepository = workspaceRepository;
this.orgRepository = orgRepository;
this.userRepository = userRepository;
@@ -84,6 +91,9 @@ public List toDetails(List requests) {
Map vmDetails = vmDetailRepository
.findByRequestIdIn(idsOfType(requests, ResourceType.VM)).stream()
.collect(Collectors.toMap(VmRequestDetail::getRequestId, Function.identity()));
+ Map llmKeyDetails = llmKeyDetailRepository
+ .findByRequestIdIn(idsOfType(requests, ResourceType.LLM_API_KEY)).stream()
+ .collect(Collectors.toMap(LlmKeyRequestDetail::getRequestId, Function.identity()));
// The per-type spec reports each catalog reference by public id AND by
// name, so the rows behind them are batched here beside the display-name
@@ -106,6 +116,7 @@ public List toDetails(List requests) {
Org org = orgs.get(request.getOrgId());
User requester = users.get(request.getRequesterId());
VmRequestDetail vmDetail = vmDetails.get(request.getId());
+ LlmKeyRequestDetail llmKeyDetail = llmKeyDetails.get(request.getId());
details.add(new RequestDetailResponse(
request.getPublicId(),
request.getResourceType(),
@@ -125,6 +136,7 @@ public List toDetails(List requests) {
flavors.get(vmDetail.getFlavorId()),
images.get(vmDetail.getGrantedImageId()),
nodes.get(vmDetail.getNodeId())) : null,
+ llmKeyDetail != null ? LlmKeyRequestSpecResponse.from(llmKeyDetail) : null,
request.getCreatedAt(), request.getUpdatedAt()));
}
return details;
diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
index 5d98a11d..1ad31d36 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java
@@ -7,6 +7,7 @@
import java.time.LocalDate;
import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.llm.dto.CreateLlmKeyRequestSpec;
import kr.ac.pusan.pickle.request.vm.dto.CreateVmRequestSpec;
import org.jspecify.annotations.Nullable;
@@ -51,5 +52,8 @@ public record CreateRequestRequest(
String displayName,
/** Required when {@code type} is VM, ignored otherwise. */
- @Valid @Nullable CreateVmRequestSpec vm) {
+ @Valid @Nullable CreateVmRequestSpec vm,
+
+ /** Required when {@code type} is LLM_API_KEY, ignored otherwise. */
+ @Valid @Nullable CreateLlmKeyRequestSpec llmKey) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
index 9e8ecba4..3f0e9405 100644
--- a/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
+++ b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java
@@ -6,6 +6,7 @@
import java.util.UUID;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.request.RequestStatus;
+import kr.ac.pusan.pickle.llm.dto.LlmKeyRequestSpecResponse;
import kr.ac.pusan.pickle.request.vm.dto.VmRequestSpecResponse;
import org.jspecify.annotations.Nullable;
@@ -35,6 +36,9 @@ public record RequestDetailResponse(
RequestStatus status,
@Nullable RequestReviewResponse review,
@Nullable VmRequestSpecResponse vm,
+
+ /** Present when {@code type} is LLM_API_KEY, null otherwise. */
+ @Nullable LlmKeyRequestSpecResponse llmKey,
Instant createdAt,
Instant updatedAt) {
}
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
index 264d71e6..41993966 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceIndexService.java
@@ -1,5 +1,7 @@
package kr.ac.pusan.pickle.resource;
+import java.util.ArrayList;
+import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -9,10 +11,6 @@
import kr.ac.pusan.pickle.common.web.PageResponse;
import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageRequest;
-import org.springframework.data.domain.Pageable;
-import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -20,49 +18,73 @@
* The type-agnostic inventory behind {@code GET /resources}: what the console
* shows on the dashboard and in a workspace's resource list.
*
- * With one resource type this delegates; with several it will have to merge
- * pages that each know their own ordering, which is a real decision (a merged
- * page cannot be produced by asking each type for page N). It is deliberately
- * not solved before there is a second type to solve it against, and
- * {@link #soleAdapter()} fails loudly rather than letting one type's rows pass
- * for the whole inventory.
+ *
A merged page cannot be produced by asking each type for page N — a type
+ * that runs out shifts every later offset, and the result looks right on the
+ * first page. So each type is asked for the first {@code (page + 1) * size}
+ * rows of its own ordering and the merge slices the page out of the combined
+ * head. That head is always large enough: the global order restricted to one
+ * type is that type's own order, so no row outside a type's first
+ * {@code (page + 1) * size} can appear that early globally.
+ *
+ *
The merge never compares ids. Rows are ordered by creation time,
+ * ties broken by type name, and equal keys keep the order their adapter
+ * delivered them in — {@link List#sort} is stable, and that is what carries
+ * each type's own tiebreak through without the caller having to know it. The
+ * tempting alternative, breaking ties on the public UUID, would be wrong in a
+ * way that hides: Java compares a UUID as two signed longs and PostgreSQL
+ * orders it as unsigned bytes, so the database's idea of "the next row" and
+ * this code's would diverge on exactly the same-instant rows that batch
+ * creation produces, dropping some from every page and repeating others.
*/
@Service
public class ResourceIndexService {
private final Map adapters;
+ /** Fixed order, so a page requested twice is assembled identically. */
+ private final List ordered;
public ResourceIndexService(List adapters) {
this.adapters = adapters.stream()
.collect(Collectors.toMap(ResourceTypeAdapter::type, Function.identity()));
+ this.ordered = adapters.stream()
+ .sorted(Comparator.comparing(a -> a.type().name()))
+ .toList();
}
@Transactional(readOnly = true)
public PageResponse list(AuthenticatedUser actor, ResourceType type,
UUID workspaceId, int page, int size) {
- // Newest first by creation time rather than by id: an id is an opaque
- // handle, and ordering by it would break the day one stops being a
- // number.
- Pageable pageable = PageRequest.of(page, size,
- Sort.by(Sort.Direction.DESC, "createdAt").and(Sort.by(Sort.Direction.DESC, "id")));
- ResourceTypeAdapter adapter = type != null ? adapters.get(type) : soleAdapter();
- if (adapter == null) {
- return PageResponse.of(List.of(), Page.empty(pageable));
+ List targets;
+ if (type == null) {
+ targets = ordered;
+ } else {
+ ResourceTypeAdapter adapter = adapters.get(type);
+ if (adapter == null) {
+ return new PageResponse<>(List.of(), page, size, 0, 0);
+ }
+ targets = List.of(adapter);
}
- Page result = adapter.page(actor, workspaceId, pageable);
- return PageResponse.of(result.getContent(), result);
- }
+ // Long arithmetic first: a caller asking for a far page must not wrap
+ // the head size into something small and get a plausible short page.
+ long head = Math.min((long) (page + 1) * size, Integer.MAX_VALUE);
- /**
- * Untyped listing while exactly one type exists. A second type turns this
- * into the merge described above, which is why it fails loudly rather than
- * quietly returning one type's rows as if they were everything.
- */
- private ResourceTypeAdapter soleAdapter() {
- if (adapters.size() != 1) {
- throw new IllegalStateException(
- "Untyped resource listing needs a merge strategy once more than one type exists");
+ List merged = new ArrayList<>();
+ long total = 0;
+ for (ResourceTypeAdapter adapter : targets) {
+ ResourceTypeAdapter.InventoryHead contribution =
+ adapter.inventoryHead(actor, workspaceId, (int) head);
+ merged.addAll(contribution.rows());
+ total += contribution.totalElements();
}
- return adapters.values().iterator().next();
+ // Newest first. The type name only settles ties across types; within a
+ // type the sort's stability preserves what the adapter returned.
+ merged.sort(Comparator.comparing(ResourceSummaryResponse::createdAt).reversed()
+ .thenComparing(row -> row.type().name()));
+
+ long from = Math.min((long) page * size, merged.size());
+ long to = Math.min(from + size, merged.size());
+ List content = merged.subList((int) from, (int) to);
+ int totalPages = size == 0 ? 0 : (int) Math.ceil((double) total / size);
+ return new PageResponse<>(List.copyOf(content), page, size, total, totalPages);
}
}
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
index 993d61ee..3efc6fb7 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/ResourceTypeAdapter.java
@@ -8,8 +8,6 @@
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse;
import kr.ac.pusan.pickle.security.AuthenticatedUser;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.Pageable;
/**
* What the resource-generic machinery needs to know about one kind of resource.
@@ -78,10 +76,37 @@ public interface ResourceTypeAdapter {
long countLiveInWorkspace(long workspaceId);
/**
- * This type's contribution to the inventory the requester may see, with the
- * same visibility rules the type's own list endpoint applies: a row the
- * requester holds no grant on comes back limited rather than omitted, so
- * they can see it exists and ask.
+ * The first {@code limit} rows of this type's contribution to the
+ * inventory, plus how many rows the requester may see in total.
+ *
+ * Visibility is the type's own: a row the requester holds no grant on
+ * comes back limited rather than omitted, so they can see it exists and
+ * ask. Count and rows must come from the same query, or a page can promise
+ * more rows than the masking rules would ever produce.
+ *
+ *
Ordering is stated here as meaning, not as a sort key. Newest
+ * first by creation time; rows created in the same instant follow this
+ * type's own stable order, descending by whatever it uses internally. The
+ * caller merges several types by creation time alone and relies on a stable
+ * sort to leave each type's own order intact, so the within-type tiebreak
+ * never has to cross into Java — which is what lets an implementation order
+ * by an internal id, or by a column named something else entirely.
+ *
+ *
The limit is a plain count rather than a {@code Pageable} on purpose:
+ * a {@code Pageable} carries a sort expressed in the caller's vocabulary,
+ * which would make every type name its columns the way the first one did.
*/
- Page page(AuthenticatedUser actor, UUID workspaceId, Pageable pageable);
+ InventoryHead inventoryHead(AuthenticatedUser actor, UUID workspaceId, int limit);
+
+ /**
+ * One type's answer to {@link #inventoryHead}: the head of its ordered
+ * rows, and the total it was taken from.
+ */
+ record InventoryHead(List rows, long totalElements) {
+
+ /** Nothing of this type is visible to the requester. */
+ public static InventoryHead empty() {
+ return new InventoryHead(List.of(), 0);
+ }
+ }
}
diff --git a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
index eefc34a7..531ec57d 100644
--- a/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
+++ b/src/main/java/kr/ac/pusan/pickle/resource/VmResourceAdapter.java
@@ -2,8 +2,8 @@
import java.util.List;
import java.util.Optional;
-import kr.ac.pusan.pickle.vm.Vm;
import java.util.UUID;
+import kr.ac.pusan.pickle.vm.Vm;
import kr.ac.pusan.pickle.access.ResourceAccessAudit;
import kr.ac.pusan.pickle.access.ResourceAccessMessages;
import kr.ac.pusan.pickle.access.ResourceType;
@@ -16,9 +16,8 @@
import kr.ac.pusan.pickle.vm.VmStatus;
import kr.ac.pusan.pickle.vm.dto.VmSummaryResponse;
import kr.ac.pusan.pickle.vmsettings.VmSettingsService;
-import org.springframework.data.domain.Page;
-import org.springframework.data.domain.PageImpl;
-import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
/** The VM's answers to {@link ResourceTypeAdapter}. */
@@ -102,14 +101,22 @@ public long countLiveInWorkspace(long workspaceId) {
}
@Override
- public Page page(AuthenticatedUser actor, UUID workspaceId,
- Pageable pageable) {
+ public InventoryHead inventoryHead(AuthenticatedUser actor, UUID workspaceId, int limit) {
// Reuses the VM list rather than re-deriving visibility: the masking
// rules live in one place, so the inventory cannot drift into showing
- // more than the VM list does.
- var page = vmQueryService.listPage(actor, workspaceId, pageable);
- return new PageImpl<>(page.getContent().stream().map(VmResourceAdapter::toSummary).toList(),
- pageable, page.getTotalElements());
+ // more than the VM list does. The count rides the same call for the
+ // same reason.
+ //
+ // The sort keys are named here and nowhere else. The inventory asks for
+ // "newest first, ties in this type's own order" and this is where that
+ // meaning becomes `createdAt` and `id` — property names of the Vm
+ // entity, which no other type should have to adopt.
+ var page = vmQueryService.listPage(actor, workspaceId,
+ PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")
+ .and(Sort.by(Sort.Direction.DESC, "id"))));
+ return new InventoryHead(
+ page.getContent().stream().map(VmResourceAdapter::toSummary).toList(),
+ page.getTotalElements());
}
private static ResourceSummaryResponse toSummary(VmSummaryResponse vm) {
diff --git a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
index 99ffff45..c8208d58 100644
--- a/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
+++ b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java
@@ -5,9 +5,7 @@
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
-import kr.ac.pusan.pickle.access.AccessGranteeType;
-import kr.ac.pusan.pickle.access.ResourceAccessGrant;
-import kr.ac.pusan.pickle.access.ResourceAccessGrantRepository;
+import kr.ac.pusan.pickle.access.ResourceAccessResolver;
import kr.ac.pusan.pickle.access.ResourceRole;
import kr.ac.pusan.pickle.access.ResourceType;
import kr.ac.pusan.pickle.access.VmAccess;
@@ -70,7 +68,7 @@ public class VmQueryService {
private final VmRepository vmRepository;
private final WorkspaceMemberRepository workspaceMemberRepository;
private final VmAccessService vmAccessService;
- private final ResourceAccessGrantRepository grantRepository;
+ private final ResourceAccessResolver resourceAccessResolver;
private final UserRepository userRepository;
private final WorkspaceRepository workspaceRepository;
private final OrgRepository orgRepository;
@@ -86,7 +84,7 @@ public class VmQueryService {
public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository workspaceMemberRepository,
VmAccessService vmAccessService,
- ResourceAccessGrantRepository grantRepository,
+ ResourceAccessResolver resourceAccessResolver,
UserRepository userRepository,
WorkspaceRepository workspaceRepository, OrgRepository orgRepository,
OsImageRepository osImageRepository, RequestRepository requestRepository,
@@ -99,7 +97,7 @@ public VmQueryService(VmRepository vmRepository, WorkspaceMemberRepository works
this.vmRepository = vmRepository;
this.workspaceMemberRepository = workspaceMemberRepository;
this.vmAccessService = vmAccessService;
- this.grantRepository = grantRepository;
+ this.resourceAccessResolver = resourceAccessResolver;
this.userRepository = userRepository;
this.workspaceRepository = workspaceRepository;
this.orgRepository = orgRepository;
@@ -155,7 +153,8 @@ public Page listPage(AuthenticatedUser actor, UUID workspaceI
.filter(m -> m.getRole() == WorkspaceMemberRole.OWNER)
.map(m -> m.getWorkspace().getId())
.collect(Collectors.toSet());
- VmListAccess access = listAccess(actor.id(), vms);
+ ResourceAccessResolver.ListAccess access = resourceAccessResolver.listAccess(
+ ResourceType.VM, vms.stream().map(Vm::getId).toList(), actor.id());
return new PageImpl<>(vms.stream()
.map(vm -> {
Workspace workspace = workspaces.get(vm.getWorkspaceId());
@@ -178,37 +177,6 @@ public Page listPage(AuthenticatedUser actor, UUID workspaceI
.toList(), pageable, result.getTotalElements());
}
- /** Which of these VMs the requester may see in full, and who to ask about the rest. */
- private record VmListAccess(Set reachable, Map> ownerNames) {
- }
-
- private VmListAccess listAccess(long userId, List vms) {
- if (vms.isEmpty()) {
- return new VmListAccess(Set.of(), Map.of());
- }
- List grants = grantRepository.findByResourceTypeAndResourceIdIn(
- ResourceType.VM, vms.stream().map(Vm::getId).toList());
- Set reachable = new java.util.HashSet<>();
- Map> ownerIds = new java.util.LinkedHashMap<>();
- for (ResourceAccessGrant grant : grants) {
- if (grant.getGranteeType() == AccessGranteeType.WORKSPACE
- || Long.valueOf(userId).equals(grant.getUserId())) {
- reachable.add(grant.getResourceId());
- }
- if (grant.getRole() == ResourceRole.OWNER && grant.getUserId() != null) {
- ownerIds.computeIfAbsent(grant.getResourceId(), key -> new java.util.ArrayList<>())
- .add(grant.getUserId());
- }
- }
- Map names = userRepository.findAllById(ownerIds.values().stream()
- .flatMap(List::stream).distinct().toList()).stream()
- .collect(Collectors.toMap(User::getId, User::getName));
- Map> ownerNames = new java.util.LinkedHashMap<>();
- ownerIds.forEach((vmId, ids) -> ownerNames.put(vmId, ids.stream()
- .map(names::get).filter(java.util.Objects::nonNull).toList()));
- return new VmListAccess(reachable, ownerNames);
- }
-
/**
* Batch request-reference join for the summary views. The summary reports
* which request produced the VM and had no join for it while the id was
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
index b4c899f7..c1c0a1fd 100644
--- a/src/main/resources/application.yml
+++ b/src/main/resources/application.yml
@@ -140,6 +140,28 @@ pickle:
# Peers confined to the relay sync surface: a relay's tunnel address must
# reach only /internal/relays/** (403 everywhere else, actuator included).
restricted-source-ips: ${PICKLE_RELAY_RESTRICTED_SOURCE_IPS:10.100.100.1}
+ llm-gateway:
+ # LLM gateway link (/internal/llm/**, own filter chain + static bearer).
+ # No token default outside dev/test: when blank the chain fails closed
+ # (rejects every request) instead of trusting an empty bearer.
+ # previous-token accepts the outgoing secret during a rotation so no
+ # coordinated restart is needed; clear it once the gateway is switched.
+ token: ${PICKLE_LLM_GATEWAY_TOKEN:}
+ previous-token: ${PICKLE_LLM_GATEWAY_PREVIOUS_TOKEN:}
+ allowed-source-ip: ${PICKLE_LLM_GATEWAY_SOURCE_IP:172.30.1.40}
+ # One bucket per sub-path: sync polls every 5 s, bodies flush every 2 s,
+ # usage bursts when a backlog drains. A single shared bucket would let the
+ # loud channels 429 the poll that carries authorization.
+ sync-rate-limit-per-minute: ${PICKLE_LLM_SYNC_RATE_LIMIT:60}
+ usage-rate-limit-per-minute: ${PICKLE_LLM_USAGE_RATE_LIMIT:120}
+ bodies-rate-limit-per-minute: ${PICKLE_LLM_BODIES_RATE_LIMIT:120}
+ # Body caps per sub-path (sync is a handful of gauges; usage is sized for
+ # a 500-event batch; bodies for the gateway's own 4 MiB batch plus JSON
+ # overhead). A refused bodies batch is NOT retried by the gateway, so a
+ # cap below what it sends silently discards captured text.
+ max-sync-body-bytes: ${PICKLE_LLM_MAX_SYNC_BODY_BYTES:65536}
+ max-usage-body-bytes: ${PICKLE_LLM_MAX_USAGE_BODY_BYTES:4194304}
+ max-bodies-body-bytes: ${PICKLE_LLM_MAX_BODIES_BODY_BYTES:8388608}
proxy-agent:
# Reverse-proxy control link to proxy-agent. pickle-api is the
# client; a JobRunr job pushes desired routing state to the agent on LXC 100.
diff --git a/src/main/resources/db/migration/V80__llm_api_key_resource_type.sql b/src/main/resources/db/migration/V80__llm_api_key_resource_type.sql
new file mode 100644
index 00000000..557afffe
--- /dev/null
+++ b/src/main/resources/db/migration/V80__llm_api_key_resource_type.sql
@@ -0,0 +1,6 @@
+-- The LLM API key joins the resource types.
+--
+-- Alone in its own file on purpose: PostgreSQL refuses to use an enum value in
+-- the same transaction that added it, and Flyway runs one file per
+-- transaction. Everything that references this value therefore starts at V81.
+alter type resource_type add value if not exists 'LLM_API_KEY';
diff --git a/src/main/resources/db/migration/V81__llm_api_keys.sql b/src/main/resources/db/migration/V81__llm_api_keys.sql
new file mode 100644
index 00000000..57a1c687
--- /dev/null
+++ b/src/main/resources/db/migration/V81__llm_api_keys.sql
@@ -0,0 +1,212 @@
+-- LLM API keys: the request detail, the keys themselves, the usage they
+-- produce, and the counter the gateway polls.
+--
+-- Schema only. Which models the service offers is state an operator maintains,
+-- so the catalogue rows are seeded by the ops tooling and never from here.
+
+-- PENDING is the state between approval and issue. The approver decides that
+-- somebody may have a key; only its owner may ever see the secret, and the
+-- approver is not in the room when they do. So approval creates the key and its
+-- access list, and the owner mints the plaintext themselves -- once.
+create type llm_api_key_status as enum ('PENDING', 'ACTIVE', 'SUSPENDED', 'REVOKED', 'EXPIRED');
+
+-- What this kind of request asks for, and what the reviewer granted of it. The
+-- granted period stays on request_reviews: every resource type has one.
+create table llm_key_request_details (
+ request_id bigint primary key references requests (id),
+ req_purpose text,
+ req_rpm int,
+ req_tpm int,
+ req_daily_tokens bigint,
+ granted_rpm int,
+ granted_tpm int,
+ granted_concurrency int,
+ granted_daily_tokens bigint
+);
+
+comment on table llm_key_request_details is
+ 'LLM API 키 신청의 종류별 항목. 공통 항목은 requests에, 부여 기간은 request_reviews에 있다.';
+
+create table llm_api_keys (
+ id bigint generated always as identity primary key,
+ public_id uuid not null default gen_random_uuid(),
+ workspace_id bigint not null references workspaces (id),
+ org_id bigint not null references orgs (id),
+ request_id bigint not null references requests (id),
+ name text not null,
+ purpose text,
+ -- The plaintext is shown once at issue and stored nowhere: this is the hex
+ -- sha256 of the whole bearer token, which is also what the gateway computes
+ -- from what a student presents. Losing it means reissuing, by design.
+ --
+ -- Null until the owner issues. A key with no hash authenticates nothing, so
+ -- it is simply absent from the document the gateway polls -- there is no
+ -- state to publish about a secret that does not exist yet.
+ token_hash char(64),
+ -- The first characters of the plaintext, so a list can tell two keys apart
+ -- without holding anything that authenticates.
+ token_prefix text,
+ status llm_api_key_status not null default 'PENDING',
+ expires_at timestamptz,
+ -- Reported by the gateway with the usage it ships, so it lags by a batch.
+ last_used_at timestamptz,
+ -- Enforced at the gateway, carried in the document it polls. Null means the
+ -- key sets no limit of its own and the gateway's default applies.
+ rpm int,
+ tpm int,
+ concurrency int,
+ -- Prompt and response capture. Off unless the owner turns it on, and the
+ -- storage behind it does not exist yet.
+ record_bodies boolean not null default false,
+ created_by bigint not null references users (id),
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ revoked_at timestamptz
+);
+
+create unique index llm_api_keys_public_id_key on llm_api_keys (public_id);
+-- The lookup the gateway's document is built from, and the guard against
+-- issuing the same secret twice. Partial: keys awaiting issue have no hash,
+-- and several of them at once is the ordinary state.
+create unique index llm_api_keys_token_hash_key on llm_api_keys (token_hash)
+ where token_hash is not null;
+create index llm_api_keys_workspace_idx on llm_api_keys (workspace_id, status);
+create index llm_api_keys_org_idx on llm_api_keys (org_id);
+
+comment on column llm_api_keys.token_hash is
+ '평문의 hex sha256. 평문은 발급 시 한 번만 표시하고 저장하지 않는다. 발급 전에는 null.';
+
+-- One usage record per request the gateway served.
+--
+-- event_id is the gateway's idempotency key and is deliberately text, not
+-- uuid: it is a UUIDv4 in the ordinary case, but the gateway falls back to a
+-- timestamp-derived id if the system's random source fails, and a uuid column
+-- would reject that row -- which would make the whole batch a permanent
+-- failure and stall every later event behind it.
+create table llm_usage_events (
+ id bigint generated always as identity primary key,
+ event_id text not null,
+ -- Null when the request never resolved to a key. Those rows are kept: they
+ -- are the only trace of a client looping on a bad key.
+ key_id bigint references llm_api_keys (id),
+ generation bigint,
+ public_model_name text,
+ -- Which upstream actually served it, and how many attempts it took. The
+ -- public model name hides the difference between a free local model and a
+ -- paid fallback; without these the accounting cannot separate them, and
+ -- nothing else records it.
+ upstream_ref text,
+ attempts int,
+ status text not null,
+ error_type text,
+ input_tokens int not null default 0,
+ output_tokens int not null default 0,
+ estimated boolean not null default false,
+ latency_ms bigint not null default 0,
+ ttft_ms bigint,
+ -- Order by this, never by arrival: a request that straddles UTC midnight
+ -- reaches the api after events that happened later.
+ requested_at timestamptz not null,
+ received_at timestamptz not null default now()
+);
+
+create unique index llm_usage_events_event_id_key on llm_usage_events (event_id);
+create index llm_usage_events_key_time_idx on llm_usage_events (key_id, requested_at desc);
+create index llm_usage_events_time_idx on llm_usage_events (requested_at desc);
+
+-- The generation the gateway polls against, in a single row.
+--
+-- A single row rather than a sequence on purpose: the writer takes a row lock
+-- with `insert ... on conflict do update ... returning` before it touches keys
+-- or models, which is what makes commit order and generation order agree. With
+-- a sequence, a transaction holding the lower number can commit second, and a
+-- poll at the higher generation reads a table that does not yet contain that
+-- change -- which then never reaches the gateway at all, because nothing bumps
+-- again.
+--
+-- The row is not seeded here. Migrations carry schema, not rows, so it comes
+-- into existence the first time something writes it: the sync handler stamps
+-- contact on every poll and the bump is an upsert, so both create it. Until
+-- then "no row" is the honest state -- nothing has been configured and no
+-- gateway has ever called.
+create table llm_gateway_state (
+ id boolean primary key default true,
+ generation bigint not null default 1,
+ service_enabled boolean not null default true,
+ -- What the gateway last told us about itself. Claims, not measurements.
+ applied_generation bigint,
+ supported_format int,
+ agent_version text,
+ started_at timestamptz,
+ in_flight int,
+ max_in_flight int,
+ upstream_refs text,
+ rejected_entries int,
+ reload_failures bigint,
+ last_error text,
+ last_contact_at timestamptz,
+ contact_lost_since timestamptz,
+ updated_at timestamptz not null default now(),
+ constraint llm_gateway_state_single_row check (id)
+);
+
+comment on table llm_gateway_state is
+ '게이트웨이가 폴링하는 세대 카운터와, 게이트웨이가 스스로 보고한 상태.';
+
+-- The approved-grant invariant, generalized past the VM.
+--
+-- V74 restored an invariant that had been lost once, and the loss was
+-- invisible: an approved request whose granted specification was never written
+-- simply sat there. The check it installed reads `resource_type = 'VM'`, so a
+-- request of any other type passes it without being looked at -- which puts
+-- every new type back in the state V74 was written to end.
+--
+-- The generalization keeps the VM's own rule and adds this type's: an approved
+-- LLM key request must carry its detail row. It has no per-field granted
+-- specification the way a VM does -- a key's limits may all be left at the
+-- gateway's defaults -- so the row's existence is the whole assertion.
+create or replace function assert_approved_request_is_granted() returns trigger
+language plpgsql as $$
+declare
+ offending_request bigint;
+begin
+ select rv.request_id into offending_request
+ from request_reviews rv
+ join requests r on r.id = rv.request_id
+ left join vm_request_details vd on vd.request_id = rv.request_id
+ left join llm_key_request_details ld on ld.request_id = rv.request_id
+ where rv.request_id = coalesce(new.request_id, old.request_id)
+ and rv.decision = 'APPROVE'
+ and case r.resource_type
+ when 'VM' then vd.request_id is null
+ or vd.granted_vcpu is null or vd.granted_memory_mb is null
+ or vd.granted_disk_gb is null or vd.granted_image_id is null
+ when 'LLM_API_KEY' then ld.request_id is null
+ -- A type added later lands here. Refusing what we cannot check
+ -- is what stops the next type from inheriting V74's silence.
+ else true
+ end;
+ if offending_request is not null then
+ raise exception 'approved request % has no complete granted specification', offending_request;
+ end if;
+ return null;
+end $$;
+
+drop trigger if exists trg_review_approve_needs_granted on request_reviews;
+create constraint trigger trg_review_approve_needs_granted
+ after insert or update on request_reviews
+ deferrable initially deferred
+ for each row execute function assert_approved_request_is_granted();
+
+drop trigger if exists trg_detail_granted_matches_decision on vm_request_details;
+create constraint trigger trg_detail_granted_matches_decision
+ after insert or update or delete on vm_request_details
+ deferrable initially deferred
+ for each row execute function assert_approved_request_is_granted();
+
+create constraint trigger trg_llm_detail_matches_decision
+ after insert or update or delete on llm_key_request_details
+ deferrable initially deferred
+ for each row execute function assert_approved_request_is_granted();
+
+drop function if exists assert_approved_vm_request_is_granted();
diff --git a/src/main/resources/db/migration/V82__llm_gateway_loss_counters.sql b/src/main/resources/db/migration/V82__llm_gateway_loss_counters.sql
new file mode 100644
index 00000000..5e94b53e
--- /dev/null
+++ b/src/main/resources/db/migration/V82__llm_gateway_loss_counters.sql
@@ -0,0 +1,17 @@
+-- Loss counters the gateway reports about itself: text and accounting it gave
+-- up on. These are the losses that are otherwise invisible to everyone --
+-- nothing downstream can observe an event that was never shipped.
+--
+-- spool_write_failures is the earliest of the three: those events never
+-- reached the gateway's outbox, so shipping never sees them and the stored
+-- usage simply comes out low. bodies_dropped counts captured text the bounded
+-- bodies queue discarded (text only -- the usage event still went to the
+-- durable spool); usage_ship_failures counts batches the gateway skipped its
+-- checkpoint past.
+--
+-- Nullable like the other self-report columns: the gateway omits a counter
+-- that is zero, and null records "not reported" honestly.
+alter table llm_gateway_state
+ add column bodies_dropped bigint,
+ add column usage_ship_failures bigint,
+ add column spool_write_failures bigint;
diff --git a/src/main/resources/db/migration/V83__llm_models.sql b/src/main/resources/db/migration/V83__llm_models.sql
new file mode 100644
index 00000000..95314771
--- /dev/null
+++ b/src/main/resources/db/migration/V83__llm_models.sql
@@ -0,0 +1,37 @@
+-- The catalogue of models the gateway may serve.
+--
+-- Schema only. Which models this deployment offers, and which upstream serves
+-- them, is state an operator maintains — it names an upstream that exists in
+-- one host's configuration and not another's — so the rows are written by the
+-- ops tooling, never from here.
+
+create table llm_models (
+ id bigint generated always as identity primary key,
+ public_id uuid not null default gen_random_uuid(),
+ -- What students see and send. Deliberately independent of the real model,
+ -- so an upstream swap is a row edit rather than a change every caller sees.
+ public_name text not null unique,
+ -- Selects an upstream block in the gateway's own configuration. The api
+ -- cannot check this name -- it lives on the gateway host -- so the gateway
+ -- reports the names it has on every poll and drops a model that names one
+ -- it does not.
+ upstream_ref text not null,
+ upstream_model text not null,
+ fallback_ref text,
+ -- PUBLIC (default) is reachable by any key with no allow list; RESTRICTED
+ -- only by a key that names it. Fail-safe: adding a model does not open it
+ -- to every existing key.
+ visibility text not null default 'PUBLIC',
+ max_input_tokens int not null default 0,
+ max_output_tokens int not null default 0,
+ -- Rows outlive their use so usage that names them stays interpretable.
+ enabled boolean not null default true,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ constraint llm_models_visibility_check check (visibility in ('PUBLIC', 'RESTRICTED'))
+);
+
+create unique index llm_models_public_id_key on llm_models (public_id);
+
+comment on table llm_models is
+ '게이트웨이가 서빙할 모델 카탈로그. 행은 운영 스크립트가 넣는다 — 어떤 업스트림이 있는지는 호스트마다 다르다.';
diff --git a/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java b/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java
index d736aa94..7a926f97 100644
--- a/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java
+++ b/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java
@@ -233,7 +233,17 @@ class ContractDriftTest {
// Usage monitoring, read live from the hypervisor (contract v0.35.0).
"GET /vms/{vmId}/metrics",
"GET /admin/nodes/{nodeId}/metrics",
- "GET /admin/capacity-trend");
+ "GET /admin/capacity-trend",
+ // LLM API keys: the read surface and the access list.
+ "GET /llm-keys",
+ "GET /llm-keys/{keyId}",
+ "PATCH /llm-keys/{keyId}",
+ "POST /llm-keys/{keyId}/token",
+ "POST /llm-keys/{keyId}/revoke",
+ "GET /llm-keys/{keyId}/access",
+ "POST /llm-keys/{keyId}/access",
+ "PATCH /llm-keys/{keyId}/access/{grantId}",
+ "DELETE /llm-keys/{keyId}/access/{grantId}");
/**
* Design-contract operations not implemented yet. Design contract =
diff --git a/src/test/java/kr/ac/pusan/pickle/llm/LlmGatewayEndpointTest.java b/src/test/java/kr/ac/pusan/pickle/llm/LlmGatewayEndpointTest.java
new file mode 100644
index 00000000..7f1ad82f
--- /dev/null
+++ b/src/test/java/kr/ac/pusan/pickle/llm/LlmGatewayEndpointTest.java
@@ -0,0 +1,564 @@
+package kr.ac.pusan.pickle.llm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig;
+import kr.ac.pusan.pickle.support.SeedFixtures;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * LLM gateway surface: the dedicated chain (static bearer with rotation
+ * overlap, source pin, per-sub-path buckets and caps) ordered ahead of the
+ * fail-closed {@code /internal/**} catch-all; sync semantics (unchanged
+ * answers are the bare generation with NO document members; a changed answer
+ * carries {@code models} and {@code keys} together; a reported generation
+ * above ours raises ours instead of being discarded); usage ingest (per-event
+ * rejection never a 4xx, event-id dedup, unattributed events kept); and the
+ * bodies channel's deliberate accept-and-discard.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import(EmbeddedPostgresConfig.class)
+class LlmGatewayEndpointTest {
+
+ /** The default allowed source (the LLM gateway LXC). */
+ private static final String SOURCE = "172.30.1.40";
+ private static final String TOKEN = "test-llm-gateway-token";
+ private static final String PREVIOUS_TOKEN = "old-llm-gateway-token";
+
+ @DynamicPropertySource
+ static void properties(DynamicPropertyRegistry registry) {
+ registry.add("pickle.llm-gateway.token", () -> TOKEN);
+ registry.add("pickle.llm-gateway.previous-token", () -> PREVIOUS_TOKEN);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @BeforeEach
+ void resetGatewayState() {
+ // The counter is a single shared row and the usage/key tables are this
+ // suite's own; all start empty so each test sees exactly the state it
+ // arranges. (audit_logs is append-only and stays.)
+ jdbcTemplate.update("delete from llm_usage_events");
+ jdbcTemplate.update("delete from llm_api_keys");
+ jdbcTemplate.update("delete from llm_gateway_state");
+ }
+
+ // ── chain: auth, ordering, caps ─────────────────────────────────────────
+
+ @Test
+ void wrongTokenAnswers401() throws Exception {
+ syncFrom(SOURCE, "not-the-token", poll(0))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value("AUTH_TOKEN_INVALID"));
+ }
+
+ @Test
+ void missingTokenAnswers401() throws Exception {
+ mockMvc.perform(post("/internal/llm/sync").with(remoteAddr(SOURCE))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(poll(0))))
+ .andExpect(status().isUnauthorized());
+ }
+
+ @Test
+ void previousTokenIsAcceptedDuringRotationOverlap() throws Exception {
+ syncFrom(SOURCE, PREVIOUS_TOKEN, poll(0)).andExpect(status().isOk());
+ }
+
+ @Test
+ void wrongSourceAnswers403EvenWithTheRightToken() throws Exception {
+ syncFrom("203.0.113.99", TOKEN, poll(0))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value("ACCESS_DENIED"));
+ }
+
+ @Test
+ void llmChainIsReachedAheadOfTheInternalCatchAll() throws Exception {
+ // The broad /internal/** chain pins the sshgw LXC (172.30.1.30) and
+ // its own token, so a 200 from the gateway's source with the gateway's
+ // token proves the dedicated chain claimed the path first.
+ syncFrom(SOURCE, TOKEN, poll(0)).andExpect(status().isOk());
+ // Every other /internal path still lands in the fail-closed catch-all:
+ // the gateway's source and token open nothing there.
+ mockMvc.perform(post("/internal/llm-other").with(remoteAddr(SOURCE))
+ .header("Authorization", "Bearer " + TOKEN)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isForbidden());
+ mockMvc.perform(post("/internal/sshgw/route").with(remoteAddr(SOURCE))
+ .header("Authorization", "Bearer " + TOKEN)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isForbidden());
+ // Unknown sub-paths of the LLM surface itself are refused in-chain.
+ mockMvc.perform(post("/internal/llm/other").with(remoteAddr(SOURCE))
+ .header("Authorization", "Bearer " + TOKEN)
+ .contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void syncBodyOverTheCapAnswers413() throws Exception {
+ Map body = poll(0);
+ body.put("agentVersion", "x".repeat(70_000)); // sync cap is 64 KiB
+ syncFrom(SOURCE, TOKEN, body).andExpect(status().isPayloadTooLarge());
+ }
+
+ @Test
+ void rateBucketsAreOnePerSubPath() throws Exception {
+ syncFrom(SOURCE, TOKEN, poll(0)).andExpect(status().isOk());
+ usage(Map.of("events", List.of())).andExpect(status().isOk());
+ bodies(Map.of("records", List.of())).andExpect(status().isNoContent());
+ List scopes = jdbcTemplate.queryForList("""
+ select distinct scope from auth_rate_limits
+ where subject = 'gateway' and scope like 'llm_%'
+ """, String.class);
+ assertThat(scopes).contains("llm_sync", "llm_usage", "llm_bodies");
+ }
+
+ // ── sync semantics ──────────────────────────────────────────────────────
+
+ @Test
+ void firstPollCreatesTheStateRowAndServesTheFullDocument() throws Exception {
+ assertThat(stateRowCount()).isZero(); // no migration seeds the row
+ syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.formatVersion").value(1))
+ .andExpect(jsonPath("$.generation").value(1))
+ .andExpect(jsonPath("$.serviceEnabled").value(true))
+ .andExpect(jsonPath("$.models").isArray())
+ .andExpect(jsonPath("$.keys").isArray());
+ assertThat(stateRowCount()).isEqualTo(1);
+ Map row = jdbcTemplate.queryForMap("""
+ select applied_generation, supported_format, in_flight, last_contact_at
+ from llm_gateway_state
+ """);
+ assertThat(((Number) row.get("applied_generation")).longValue()).isZero();
+ assertThat(((Number) row.get("supported_format")).intValue()).isEqualTo(1);
+ assertThat(row.get("last_contact_at")).isNotNull();
+ }
+
+ @Test
+ void unchangedPollAnswersTheBareGenerationAndNothingElse() throws Exception {
+ syncFrom(SOURCE, TOKEN, poll(0)).andExpect(status().isOk()); // creates gen 1
+ String body = syncFrom(SOURCE, TOKEN, poll(1))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ // Raw-JSON assertion on purpose: models/keys must be ABSENT together
+ // (one alone is a violation, an empty array is a real state) and
+ // serviceEnabled must not leak into the unchanged shape.
+ assertThat(body).isEqualTo("{\"generation\":1}");
+ }
+
+ @Test
+ void changedPollCarriesModelsAndKeysTogether() throws Exception {
+ KeyFixture key = newKey("doc");
+ syncFrom(SOURCE, TOKEN, poll(0)).andExpect(status().isOk()); // creates gen 1
+ String body = syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.generation").value(1))
+ .andExpect(jsonPath("$.serviceEnabled").value(true))
+ .andExpect(jsonPath("$.keys[0].keyId").value(key.publicId().toString()))
+ .andExpect(jsonPath("$.keys[0].tokenHash").value(key.tokenHash()))
+ .andExpect(jsonPath("$.keys[0].status").value("ACTIVE"))
+ .andExpect(jsonPath("$.keys[0].quotaExhausted").value(false))
+ .andExpect(jsonPath("$.keys[0].recordBodies").value(false))
+ .andExpect(jsonPath("$.keys[0].allowedModels").isArray())
+ .andReturn().getResponse().getContentAsString();
+ // Both members present — models as an EMPTY ARRAY (a real state: no
+ // catalogue rows exist yet), never omitted alongside a present keys.
+ assertThat(body).contains("\"models\":[]").contains("\"keys\":[");
+ // No limits configured on the key: the member drops out entirely.
+ assertThat(body).doesNotContain("\"limits\"");
+ }
+
+ @Test
+ void configuredLimitsAndExpiryAppearOnTheKeyEntry() throws Exception {
+ KeyFixture key = newKey("limits");
+ jdbcTemplate.update("""
+ update llm_api_keys set rpm = 20, tpm = 20000,
+ expires_at = now() + interval '30 days' where id = ?
+ """, key.id());
+ syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.keys[0].limits.rpm").value(20))
+ .andExpect(jsonPath("$.keys[0].limits.tpm").value(20000))
+ .andExpect(jsonPath("$.keys[0].limits.concurrency").doesNotExist())
+ .andExpect(jsonPath("$.keys[0].expiresAt").exists());
+ }
+
+ @Test
+ void reportedGenerationAboveOursRaisesOursAndServesTheDocument() throws Exception {
+ syncFrom(SOURCE, TOKEN, poll(0)).andExpect(status().isOk()); // creates gen 1
+ // A restored backup: the gateway's persisted high-water (42) is above
+ // our counter. Discarding (the relay link's answer) would wedge the
+ // link forever — instead ours is raised above it and the full
+ // document served.
+ syncFrom(SOURCE, TOKEN, poll(42))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.generation").value(43))
+ .andExpect(jsonPath("$.keys").isArray());
+ Long stored = jdbcTemplate.queryForObject(
+ "select generation from llm_gateway_state", Long.class);
+ assertThat(stored).isEqualTo(43);
+ assertThat(raiseAudits()).isEqualTo(1);
+ // The raise is once, not per poll: the follow-up is an ordinary
+ // unchanged answer and no second audit lands.
+ String body = syncFrom(SOURCE, TOKEN, poll(43))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).isEqualTo("{\"generation\":43}");
+ assertThat(raiseAudits()).isEqualTo(1);
+ }
+
+ @Test
+ void pendingKeysAreAbsentFromTheDocumentEntirely() throws Exception {
+ // A key between approval and mint has no secret: it authenticates
+ // nothing, so it is ABSENT from the document, not present-and-refused
+ // (a null tokenHash would be an entry the gateway rejects). The issued
+ // key beside it is served normally.
+ KeyFixture pending = newPendingKey("pending");
+ KeyFixture issued = newKey("issued");
+ String body = syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.keys.length()").value(1))
+ .andExpect(jsonPath("$.keys[0].keyId").value(issued.publicId().toString()))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).doesNotContain(pending.publicId().toString());
+ }
+
+ @Test
+ void revokedKeysStayThroughTheGracePeriodThenDrop() throws Exception {
+ KeyFixture fresh = newKey("revoked-fresh");
+ KeyFixture stale = newKey("revoked-stale");
+ jdbcTemplate.update("""
+ update llm_api_keys set status = 'REVOKED', revoked_at = now() where id = ?
+ """, fresh.id());
+ jdbcTemplate.update("""
+ update llm_api_keys set status = 'REVOKED',
+ revoked_at = now() - interval '40 days' where id = ?
+ """, stale.id());
+ String body = syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ // In grace: present WITH its status, so the gateway can answer
+ // "api_key_revoked" instead of "invalid_api_key".
+ assertThat(body).contains(fresh.publicId().toString()).contains("REVOKED");
+ // Past grace: dropped — "invalid_api_key" is the correct answer now.
+ assertThat(body).doesNotContain(stale.publicId().toString());
+ }
+
+ @Test
+ void expiredKeysServeActiveWithTheirExpiryThenDrop() throws Exception {
+ KeyFixture recent = newKey("expired-recent");
+ KeyFixture stale = newKey("expired-stale");
+ jdbcTemplate.update("""
+ update llm_api_keys set status = 'EXPIRED',
+ expires_at = now() - interval '1 hour' where id = ?
+ """, recent.id());
+ jdbcTemplate.update("""
+ update llm_api_keys set status = 'EXPIRED',
+ expires_at = now() - interval '40 days' where id = ?
+ """, stale.id());
+ String body = syncFrom(SOURCE, TOKEN, poll(0))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.keys[0].status").value("ACTIVE"))
+ .andExpect(jsonPath("$.keys[0].expiresAt").exists())
+ .andReturn().getResponse().getContentAsString();
+ // EXPIRED is not in the gateway's vocabulary (an unknown status drops
+ // the entry, an outage for its owner): the gateway enforces expiresAt
+ // itself, so the row is served ACTIVE with its real expiry.
+ assertThat(body).contains(recent.publicId().toString());
+ assertThat(body).doesNotContain(stale.publicId().toString());
+ }
+
+ // ── usage ingest ────────────────────────────────────────────────────────
+
+ @Test
+ void duplicateEventsAreCountedAndInsertedOnce() throws Exception {
+ KeyFixture key = newKey("usage");
+ Map event = event("evt-1", key.publicId().toString(),
+ "2026-08-10T20:03:57.974941509Z");
+ usage(Map.of("events", List.of(event)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accepted").value(1))
+ .andExpect(jsonPath("$.duplicates").value(0))
+ .andExpect(jsonPath("$.rejected").value(0));
+ // At-least-once redelivery: the re-sent event is a duplicate, the new
+ // one accepted, and the id lands exactly once.
+ usage(Map.of("events", List.of(event,
+ event("evt-2", key.publicId().toString(), "2026-08-10T20:04:01Z"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accepted").value(1))
+ .andExpect(jsonPath("$.duplicates").value(1));
+ Long rows = jdbcTemplate.queryForObject(
+ "select count(*) from llm_usage_events where event_id = 'evt-1'", Long.class);
+ assertThat(rows).isEqualTo(1);
+ // last_used_at moved to the newest accepted requestedAt for the key.
+ String lastUsed = jdbcTemplate.queryForObject("""
+ select to_char(last_used_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS')
+ from llm_api_keys where id = ?
+ """, String.class, key.id());
+ assertThat(lastUsed).isEqualTo("2026-08-10T20:04:01");
+ }
+
+ @Test
+ void aBatchWithBadEventsStillAnswers2xxAndKeepsTheRest() throws Exception {
+ // One event per defect class — missing id, missing status, unparseable
+ // timestamp — plus a good one. A 4xx here would make the gateway skip
+ // the batch and move its checkpoint past it: the good event would be
+ // gone for good.
+ Map noId = new HashMap<>(
+ event(null, null, "2026-08-10T20:03:57Z"));
+ noId.remove("eventUuid");
+ Map noStatus = new HashMap<>(
+ event("evt-bad-status", null, "2026-08-10T20:03:57Z"));
+ noStatus.remove("status");
+ Map badTime = event("evt-bad-time", null, "not-a-timestamp");
+ Map good = event("evt-good", null, "2026-08-10T20:03:57Z");
+ usage(Map.of("events", List.of(noId, noStatus, badTime, good)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accepted").value(1))
+ .andExpect(jsonPath("$.duplicates").value(0))
+ .andExpect(jsonPath("$.rejected").value(3));
+ Long rows = jdbcTemplate.queryForObject(
+ "select count(*) from llm_usage_events", Long.class);
+ assertThat(rows).isEqualTo(1);
+ }
+
+ @Test
+ void unattributedEventsAreKeptWithANullKey() throws Exception {
+ // No keyId at all, and a keyId that resolves to nothing: both are
+ // kept — they are the only trace of a client looping on a bad key.
+ usage(Map.of("events", List.of(
+ event("evt-nokey", null, "2026-08-10T20:03:57Z"),
+ event("evt-unknownkey", SeedFixtures.UNKNOWN_ID.toString(),
+ "2026-08-10T20:03:58Z"))))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.accepted").value(2));
+ Long nullKeyRows = jdbcTemplate.queryForObject(
+ "select count(*) from llm_usage_events where key_id is null", Long.class);
+ assertThat(nullKeyRows).isEqualTo(2);
+ }
+
+ @Test
+ void eventFieldsSurviveVerbatimIncludingTheUpstreamSplit() throws Exception {
+ // upstreamRef/attempts are the only record of which upstream actually
+ // served a request behind the shared public name.
+ Map event = event("evt-full", null, "2026-08-10T20:03:57Z");
+ event.put("upstreamRef", "backup");
+ event.put("attempts", 2);
+ event.put("generation", 43);
+ event.put("publicModelName", "pnu-general");
+ event.put("inputTokens", 18);
+ event.put("outputTokens", 694);
+ event.put("estimated", false);
+ event.put("ttftMs", 6419);
+ usage(Map.of("events", List.of(event))).andExpect(status().isOk());
+ Map row = jdbcTemplate.queryForMap("""
+ select upstream_ref, attempts, generation, public_model_name,
+ input_tokens, output_tokens, ttft_ms
+ from llm_usage_events where event_id = 'evt-full'
+ """);
+ assertThat(row.get("upstream_ref")).isEqualTo("backup");
+ assertThat(((Number) row.get("attempts")).intValue()).isEqualTo(2);
+ assertThat(((Number) row.get("generation")).longValue()).isEqualTo(43);
+ assertThat(row.get("public_model_name")).isEqualTo("pnu-general");
+ assertThat(((Number) row.get("input_tokens")).intValue()).isEqualTo(18);
+ assertThat(((Number) row.get("output_tokens")).intValue()).isEqualTo(694);
+ assertThat(((Number) row.get("ttft_ms")).longValue()).isEqualTo(6419);
+ }
+
+ // ── bodies channel ──────────────────────────────────────────────────────
+
+ @Test
+ void bodiesAreAcceptedAndDeliberatelyNotStored() throws Exception {
+ // 2xx keeps the gateway's bounded queue draining; storage is a later
+ // round gated on the privacy-policy decision, so acceptance leaves no
+ // row anywhere.
+ bodies(Map.of("records", List.of(Map.of(
+ "eventUuid", "evt-b1",
+ "requestedAt", "2026-08-10T20:03:57Z",
+ "request", List.of(Map.of("role", "user", "content", "hello")),
+ "response", "hi",
+ "requestTruncated", false,
+ "responseTruncated", false))))
+ .andExpect(status().isNoContent());
+ Long usageRows = jdbcTemplate.queryForObject(
+ "select count(*) from llm_usage_events", Long.class);
+ assertThat(usageRows).isZero();
+ }
+
+ // ── self-report persistence ─────────────────────────────────────────────
+
+ @Test
+ void selfReportFieldsAreSanitizedAndStored() throws Exception {
+ Map body = poll(0);
+ body.put("agentVersion", "7eb7a60\u001b[31mRED\u001b[0m");
+ body.put("startedAt", "2026-08-11T04:10:22Z");
+ body.put("maxInFlight", 16);
+ body.put("upstreamRefs", List.of("main", "backup"));
+ body.put("rejectedEntries", 3);
+ body.put("lastError", "boom\r\nline");
+ body.put("bodiesDropped", 7);
+ body.put("usageShipFailures", 1);
+ body.put("spoolWriteFailures", 2);
+ syncFrom(SOURCE, TOKEN, body).andExpect(status().isOk());
+ Map row = jdbcTemplate.queryForMap("""
+ select agent_version, max_in_flight, upstream_refs, rejected_entries,
+ last_error, bodies_dropped, usage_ship_failures, spool_write_failures
+ from llm_gateway_state
+ """);
+ assertThat(row.get("agent_version")).isEqualTo("7eb7a60[31mRED[0m"); // ESC gone
+ assertThat(((Number) row.get("max_in_flight")).intValue()).isEqualTo(16);
+ assertThat((String) row.get("upstream_refs")).contains("main").contains("backup");
+ assertThat(((Number) row.get("rejected_entries")).intValue()).isEqualTo(3);
+ assertThat(row.get("last_error")).isEqualTo("boomline"); // CR/LF gone
+ assertThat(((Number) row.get("bodies_dropped")).longValue()).isEqualTo(7);
+ assertThat(((Number) row.get("usage_ship_failures")).longValue()).isEqualTo(1);
+ assertThat(((Number) row.get("spool_write_failures")).longValue()).isEqualTo(2);
+ }
+
+ // ── helpers ─────────────────────────────────────────────────────────────
+
+ /** A minimal poll body: the three always-present members. */
+ private static Map poll(long appliedGeneration) {
+ Map body = new HashMap<>();
+ body.put("appliedGeneration", appliedGeneration);
+ body.put("supportedFormat", 1);
+ body.put("inFlight", 0);
+ return body;
+ }
+
+ /** A minimal valid usage event (the always-present members only). */
+ private static Map event(String eventUuid, String keyId,
+ String requestedAt) {
+ Map event = new HashMap<>();
+ if (eventUuid != null) {
+ event.put("eventUuid", eventUuid);
+ }
+ if (keyId != null) {
+ event.put("keyId", keyId);
+ }
+ event.put("status", "OK");
+ event.put("inputTokens", 1);
+ event.put("outputTokens", 1);
+ event.put("latencyMs", 10);
+ event.put("requestedAt", requestedAt);
+ return event;
+ }
+
+ private ResultActions syncFrom(String source, String token, Map body)
+ throws Exception {
+ return mockMvc.perform(post("/internal/llm/sync")
+ .with(remoteAddr(source))
+ .header("Authorization", "Bearer " + token)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)));
+ }
+
+ private ResultActions usage(Map body) throws Exception {
+ return mockMvc.perform(post("/internal/llm/usage")
+ .with(remoteAddr(SOURCE))
+ .header("Authorization", "Bearer " + TOKEN)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)));
+ }
+
+ private ResultActions bodies(Map body) throws Exception {
+ return mockMvc.perform(post("/internal/llm/bodies")
+ .with(remoteAddr(SOURCE))
+ .header("Authorization", "Bearer " + TOKEN)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(body)));
+ }
+
+ private long stateRowCount() {
+ return jdbcTemplate.queryForObject("select count(*) from llm_gateway_state", Long.class);
+ }
+
+ private long raiseAudits() {
+ return jdbcTemplate.queryForObject("""
+ select count(*) from audit_logs where action = 'llm_gateway.generation_raise'
+ """, Long.class);
+ }
+
+ private record KeyFixture(long id, UUID publicId, String tokenHash) {
+ }
+
+ /** An issued (ACTIVE, minted-secret) key in its own workspace. */
+ private KeyFixture newKey(String slug) {
+ String plaintext = LlmApiKeyTokens.newToken();
+ String tokenHash = LlmApiKeyTokens.hash(plaintext);
+ return insertKey(slug, tokenHash, LlmApiKeyTokens.visiblePrefix(plaintext), "ACTIVE");
+ }
+
+ /** A key the approval created but the owner has not minted yet. */
+ private KeyFixture newPendingKey(String slug) {
+ return insertKey(slug, null, null, "PENDING");
+ }
+
+ private KeyFixture insertKey(String slug, String tokenHash, String tokenPrefix,
+ String status) {
+ long orgId = SeedFixtures.seedOrgId(jdbcTemplate);
+ long ownerId = SeedFixtures.orgadminId(jdbcTemplate);
+ String unique = UUID.randomUUID().toString().substring(0, 8);
+ long workspaceId = jdbcTemplate.queryForObject("""
+ insert into workspaces (kind, name) values ('TEAM'::workspace_kind, ?)
+ returning id
+ """, Long.class, "LLM 키 테스트 " + slug + "-" + unique);
+ jdbcTemplate.update("""
+ insert into workspace_members (workspace_id, user_id, role)
+ values (?, ?, 'OWNER'::workspace_member_role)
+ """, workspaceId, ownerId);
+ long requestId = jdbcTemplate.queryForObject("""
+ insert into requests (resource_type, workspace_id, org_id, requester_id,
+ purpose, display_name)
+ values ('LLM_API_KEY', ?, ?, ?, ?, ?)
+ returning id
+ """, Long.class, workspaceId, orgId, ownerId, "LLM 키 테스트", "llm-" + slug);
+ long id = jdbcTemplate.queryForObject("""
+ insert into llm_api_keys (workspace_id, org_id, request_id, name,
+ token_hash, token_prefix, status, created_by)
+ values (?, ?, ?, ?, ?, ?, ?::llm_api_key_status, ?)
+ returning id
+ """, Long.class, workspaceId, orgId, requestId, "key-" + slug,
+ tokenHash, tokenPrefix, status, ownerId);
+ UUID publicId = jdbcTemplate.queryForObject(
+ "select public_id from llm_api_keys where id = ?", UUID.class, id);
+ return new KeyFixture(id, publicId, tokenHash);
+ }
+
+ private static org.springframework.test.web.servlet.request.RequestPostProcessor remoteAddr(
+ String ip) {
+ return request -> {
+ request.setRemoteAddr(ip);
+ return request;
+ };
+ }
+}
diff --git a/src/test/java/kr/ac/pusan/pickle/llm/LlmKeyQueryTest.java b/src/test/java/kr/ac/pusan/pickle/llm/LlmKeyQueryTest.java
new file mode 100644
index 00000000..10eed768
--- /dev/null
+++ b/src/test/java/kr/ac/pusan/pickle/llm/LlmKeyQueryTest.java
@@ -0,0 +1,298 @@
+package kr.ac.pusan.pickle.llm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+import kr.ac.pusan.pickle.security.JwtService;
+import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig;
+import kr.ac.pusan.pickle.support.ReauthTestSupport;
+import kr.ac.pusan.pickle.support.SeedFixtures;
+import kr.ac.pusan.pickle.user.User;
+import kr.ac.pusan.pickle.user.UserRepository;
+import kr.ac.pusan.pickle.user.UserStatus;
+import org.hamcrest.Matchers;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * The LLM API key read surface: the list, the detail, and what each standing
+ * is allowed to learn from them.
+ *
+ * The sharp assertions are the negative ones. A key's row must never carry
+ * the token hash — not to anyone, in any field — and its prefix only to a
+ * grant holder; a non-member must not be able to tell an existing key from a
+ * missing one. The positive cases exist mostly so the negatives are proven
+ * against responses that demonstrably do carry data when allowed.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import(EmbeddedPostgresConfig.class)
+class LlmKeyQueryTest {
+
+ /**
+ * What the fixture key's plaintext would hash to — must appear nowhere.
+ * Fresh per key: the stored hash is globally unique by index, exactly
+ * because two keys must never share a secret.
+ */
+ private String tokenHash;
+ /** The visible prefix — shown to grant holders, absent from restricted rows. */
+ private String tokenPrefix;
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private JwtService jwtService;
+
+ @Autowired
+ private UserRepository userRepository;
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private User wsOwner;
+ private User keyOwner;
+ private User bystander;
+ private User outsider;
+ private String wsOwnerToken;
+ private String keyOwnerToken;
+ private String bystanderToken;
+ private String outsiderToken;
+ private long orgId;
+ private long workspaceId;
+ private String workspaceName;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ wsOwner = ensureUser("llmread.wsowner@pusan.ac.kr", "키워크스페이스소유자");
+ keyOwner = ensureUser("llmread.keyowner@pusan.ac.kr", "키소유자");
+ bystander = ensureUser("llmread.bystander@pusan.ac.kr", "키구경꾼");
+ outsider = ensureUser("llmread.outsider@pusan.ac.kr", "키외부인");
+ wsOwnerToken = jwtService.createAccessToken(wsOwner);
+ keyOwnerToken = jwtService.createAccessToken(keyOwner);
+ bystanderToken = jwtService.createAccessToken(bystander);
+ outsiderToken = jwtService.createAccessToken(outsider);
+ orgId = SeedFixtures.seedOrgId(jdbcTemplate);
+ String slug = "llmread-" + UUID.randomUUID().toString().substring(0, 8);
+ workspaceName = "LLM 키 조회 테스트 " + slug;
+ workspaceId = createTeam(workspaceName);
+ addMember(keyOwner.getEmail());
+ addMember(bystander.getEmail());
+ }
+
+ @Test
+ void aNonMemberCannotTellTheKeyFromAMissingOne() throws Exception {
+ long keyId = createIssuedKey("외부인차단 키");
+
+ // Absent from the list, whether filtered to the workspace or not — and
+ // the workspace filter answers the same empty page as an unknown id.
+ mockMvc.perform(get("/api/v1/llm-keys")
+ .header("Authorization", "Bearer " + outsiderToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.totalElements").value(0));
+ mockMvc.perform(get("/api/v1/llm-keys?workspaceId=" + pub("workspaces", workspaceId))
+ .header("Authorization", "Bearer " + outsiderToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.totalElements").value(0));
+
+ // The detail masks existence: 404, exactly what an unknown id answers.
+ mockMvc.perform(get("/api/v1/llm-keys/" + pub("llm_api_keys", keyId))
+ .header("Authorization", "Bearer " + outsiderToken))
+ .andExpect(status().isNotFound());
+ mockMvc.perform(get("/api/v1/llm-keys/" + SeedFixtures.UNKNOWN_ID)
+ .header("Authorization", "Bearer " + outsiderToken))
+ .andExpect(status().isNotFound());
+ }
+
+ @Test
+ void aMemberWithoutAGrantGetsTheRestrictedRowAndNoTokenPrefix() throws Exception {
+ long keyId = createIssuedKey("제한행 키");
+ String row = "$.content[?(@.id=='" + pub("llm_api_keys", keyId) + "')]";
+
+ String body = mockMvc.perform(get("/api/v1/llm-keys?workspaceId="
+ + pub("workspaces", workspaceId))
+ .header("Authorization", "Bearer " + bystanderToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath(row + ".accessLimited").value(Matchers.contains(true)))
+ .andExpect(jsonPath(row + ".name").value(Matchers.contains("제한행 키")))
+ .andExpect(jsonPath(row + ".status").value(Matchers.contains("ACTIVE")))
+ .andExpect(jsonPath(row + ".ownerNames[0]")
+ .value(Matchers.contains(keyOwner.getName())))
+ .andExpect(jsonPath(row + ".accessManageAllowed").value(Matchers.contains(false)))
+ .andReturn().getResponse().getContentAsString();
+ // Asserted against the whole body, not one field: nothing derived from
+ // the secret, and nothing about the key's configuration, may appear in
+ // a restricted row wherever it might be carried.
+ assertThat(body).doesNotContain(tokenPrefix);
+ assertThat(body).doesNotContain(tokenHash);
+ assertThat(body).doesNotContain("제한행 키 용도");
+
+ // The detail is an honest 403: the list already told them it exists.
+ mockMvc.perform(get("/api/v1/llm-keys/" + pub("llm_api_keys", keyId))
+ .header("Authorization", "Bearer " + bystanderToken))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void aGrantHolderSeesTheFullRowAndDetail() throws Exception {
+ long keyId = createIssuedKey("공개행 키");
+ String row = "$.content[?(@.id=='" + pub("llm_api_keys", keyId) + "')]";
+
+ mockMvc.perform(get("/api/v1/llm-keys?workspaceId=" + pub("workspaces", workspaceId))
+ .header("Authorization", "Bearer " + keyOwnerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath(row + ".accessLimited").value(Matchers.contains(false)))
+ .andExpect(jsonPath(row + ".tokenPrefix").value(Matchers.contains(tokenPrefix)))
+ .andExpect(jsonPath(row + ".purpose").value(Matchers.contains("공개행 키 용도")))
+ .andExpect(jsonPath(row + ".rpm").value(Matchers.contains(60)))
+ .andExpect(jsonPath(row + ".workspaceName").value(Matchers.contains(workspaceName)));
+
+ mockMvc.perform(get("/api/v1/llm-keys/" + pub("llm_api_keys", keyId))
+ .header("Authorization", "Bearer " + keyOwnerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.name").value("공개행 키"))
+ .andExpect(jsonPath("$.tokenPrefix").value(tokenPrefix))
+ .andExpect(jsonPath("$.rpm").value(60))
+ .andExpect(jsonPath("$.tpm").value(100000))
+ .andExpect(jsonPath("$.concurrency").value(4))
+ .andExpect(jsonPath("$.recordBodies").value(false))
+ .andExpect(jsonPath("$.myResourceRole").value("OWNER"))
+ .andExpect(jsonPath("$.accessManageAllowed").value(true))
+ .andExpect(jsonPath("$.workspaceId").value(
+ pub("workspaces", workspaceId).toString()));
+ }
+
+ @Test
+ void aWorkspaceOwnerWithoutAGrantGetsTheRestrictedRowWithTheManageFlag() throws Exception {
+ long keyId = createIssuedKey("소유자복구 키");
+ String row = "$.content[?(@.id=='" + pub("llm_api_keys", keyId) + "')]";
+
+ // The way back in for a key whose own owner is gone: the row stays
+ // restricted, but the console may offer the access list.
+ String body = mockMvc.perform(get("/api/v1/llm-keys?workspaceId="
+ + pub("workspaces", workspaceId))
+ .header("Authorization", "Bearer " + wsOwnerToken))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath(row + ".accessLimited").value(Matchers.contains(true)))
+ .andExpect(jsonPath(row + ".accessManageAllowed").value(Matchers.contains(true)))
+ .andReturn().getResponse().getContentAsString();
+ assertThat(body).doesNotContain(tokenPrefix);
+
+ // Standing rights open the access list, not the key: the detail stays
+ // an honest 403 until they put themselves on the list.
+ mockMvc.perform(get("/api/v1/llm-keys/" + pub("llm_api_keys", keyId))
+ .header("Authorization", "Bearer " + wsOwnerToken))
+ .andExpect(status().isForbidden());
+ }
+
+ @Test
+ void theTokenHashAppearsInNoResponseSurface() throws Exception {
+ long keyId = createIssuedKey("해시부재 키");
+
+ // Every reader, both surfaces: the stored hash is what authenticates at
+ // the gateway, and no standing — not even the key's own owner — is ever
+ // handed it back.
+ for (String token : new String[] {keyOwnerToken, bystanderToken, wsOwnerToken}) {
+ String list = mockMvc.perform(get("/api/v1/llm-keys?workspaceId="
+ + pub("workspaces", workspaceId))
+ .header("Authorization", "Bearer " + token))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ assertThat(list).doesNotContain(tokenHash);
+ }
+ String detail = mockMvc.perform(get("/api/v1/llm-keys/" + pub("llm_api_keys", keyId))
+ .header("Authorization", "Bearer " + keyOwnerToken))
+ .andExpect(status().isOk())
+ .andReturn().getResponse().getContentAsString();
+ assertThat(detail).doesNotContain(tokenHash);
+ }
+
+ // ── helpers ────────────────────────────────────────────────────────────
+
+ /**
+ * An issued, active key with its secret's hash and prefix in place, owned
+ * (in the access-list sense) by {@code keyOwner} alone — the shape approval
+ * plus issue produces. Written straight to the tables because this test's
+ * subject is downstream of the request flow.
+ */
+ private long createIssuedKey(String name) {
+ tokenHash = (UUID.randomUUID() + "" + UUID.randomUUID()).replace("-", "");
+ tokenPrefix = "pickle-" + tokenHash.substring(0, 6);
+ long requestId = jdbcTemplate.queryForObject("""
+ insert into requests (resource_type, workspace_id, org_id, requester_id, purpose,
+ display_name)
+ values ('LLM_API_KEY', ?, ?, ?, ?, left(?, 100))
+ returning id
+ """, Long.class, workspaceId, orgId, keyOwner.getId(), name + " 용도", name);
+ long keyId = jdbcTemplate.queryForObject("""
+ insert into llm_api_keys (workspace_id, org_id, request_id, name, purpose,
+ token_hash, token_prefix, status, rpm, tpm, concurrency,
+ created_by)
+ values (?, ?, ?, ?, ?, ?, ?, 'ACTIVE', 60, 100000, 4, ?)
+ returning id
+ """, Long.class, workspaceId, orgId, requestId, name, name + " 용도", tokenHash,
+ tokenPrefix, keyOwner.getId());
+ jdbcTemplate.update("""
+ insert into resource_access_grants
+ (resource_type, resource_id, grantee_type, user_id, role)
+ values ('LLM_API_KEY', ?, 'USER', ?, 'OWNER'::resource_role)
+ """, keyId, keyOwner.getId());
+ return keyId;
+ }
+
+ private long createTeam(String name) throws Exception {
+ String body = mockMvc.perform(post("/api/v1/workspaces")
+ .header("Authorization", "Bearer " + wsOwnerToken)
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(
+ Map.of("kind", "TEAM", "name", name))))
+ .andExpect(status().isCreated())
+ .andReturn().getResponse().getContentAsString();
+ return SeedFixtures.internalId(jdbcTemplate, "workspaces",
+ UUID.fromString(objectMapper.readTree(body).get("id").asString()));
+ }
+
+ private void addMember(String email) throws Exception {
+ mockMvc.perform(post("/api/v1/workspaces/" + pub("workspaces", workspaceId) + "/members")
+ .header("Authorization", "Bearer " + wsOwnerToken)
+ .header(ReauthTestSupport.HEADER, ReauthTestSupport.seededReauthFor(
+ jdbcTemplate, jwtService, wsOwnerToken))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(objectMapper.writeValueAsString(
+ Map.of("email", email, "role", "MEMBER"))))
+ .andExpect(status().isCreated());
+ }
+
+ private User ensureUser(String email, String name) {
+ return userRepository.findByEmail(email).orElseGet(() -> {
+ User user = new User(email, "{test-no-login}", name);
+ user.setStatus(UserStatus.ACTIVE);
+ user.setEmailVerifiedAt(Instant.now());
+ return userRepository.save(user);
+ });
+ }
+
+ /** The public identifier of a row this test set up through direct SQL. */
+ private UUID pub(String table, long id) {
+ return SeedFixtures.publicId(jdbcTemplate, table, id);
+ }
+}
diff --git a/src/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java b/src/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java
index df22d6c4..7a7e23c8 100644
--- a/src/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java
+++ b/src/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java
@@ -5,6 +5,7 @@
import java.io.InputStream;
import kr.ac.pusan.pickle.common.error.GlobalExceptionHandler;
+import kr.ac.pusan.pickle.common.web.BodyCappingRequest;
import kr.ac.pusan.pickle.common.web.RequestBodyCapExceededException;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpStatus;
@@ -25,8 +26,8 @@ class RelayBodyCapTest {
void cappedStreamAbortsPastTheCap() throws Exception {
MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/internal/relays/1/sync");
raw.setContent(new byte[100]);
- RelayAuthFilter.BodyCappingRequest capped =
- new RelayAuthFilter.BodyCappingRequest(raw, 32);
+ BodyCappingRequest capped =
+ new BodyCappingRequest(raw, 32);
try (InputStream in = capped.getInputStream()) {
assertThatThrownBy(() -> in.readAllBytes())
.isInstanceOf(RequestBodyCapExceededException.class);
@@ -37,8 +38,8 @@ void cappedStreamAbortsPastTheCap() throws Exception {
void underCapBodyReadsCompletely() throws Exception {
MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/internal/relays/1/sync");
raw.setContent("{\"appliedGeneration\":0}".getBytes());
- RelayAuthFilter.BodyCappingRequest capped =
- new RelayAuthFilter.BodyCappingRequest(raw, 1024);
+ BodyCappingRequest capped =
+ new BodyCappingRequest(raw, 1024);
try (InputStream in = capped.getInputStream()) {
assertThat(in.readAllBytes()).hasSize(23);
}
@@ -48,8 +49,8 @@ void underCapBodyReadsCompletely() throws Exception {
void readerRouteIsCappedToo() throws Exception {
MockHttpServletRequest raw = new MockHttpServletRequest("POST", "/internal/relays/1/sync");
raw.setContent(new byte[100]);
- RelayAuthFilter.BodyCappingRequest capped =
- new RelayAuthFilter.BodyCappingRequest(raw, 32);
+ BodyCappingRequest capped =
+ new BodyCappingRequest(raw, 32);
assertThatThrownBy(() -> {
var reader = capped.getReader();
while (reader.read() >= 0) {
diff --git a/src/test/java/kr/ac/pusan/pickle/request/ApprovedRequestGrantInvariantTest.java b/src/test/java/kr/ac/pusan/pickle/request/ApprovedRequestGrantInvariantTest.java
new file mode 100644
index 00000000..2b243a8e
--- /dev/null
+++ b/src/test/java/kr/ac/pusan/pickle/request/ApprovedRequestGrantInvariantTest.java
@@ -0,0 +1,104 @@
+package kr.ac.pusan.pickle.request;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.time.LocalDate;
+import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig;
+import kr.ac.pusan.pickle.support.SeedFixtures;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.transaction.support.TransactionTemplate;
+
+/**
+ * The database's own guard on an approved request: it must carry what the
+ * reviewer granted.
+ *
+ *
The invariant was lost once before and the loss was invisible — an
+ * approved request whose specification was never written simply sat there,
+ * looking decided. The check that restored it named the VM explicitly, so
+ * every later type would have inherited that same silence. These tests are
+ * about the generalization: each type is checked, and a type nobody has taught
+ * it about is refused rather than waved through.
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@Import(EmbeddedPostgresConfig.class)
+class ApprovedRequestGrantInvariantTest {
+
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ @Autowired
+ private TransactionTemplate transactionTemplate;
+
+ /** A submitted request of one type, returning its id. */
+ private long submitRequest(String resourceType) {
+ long orgId = SeedFixtures.seedOrgId(jdbcTemplate);
+ Long userId = jdbcTemplate.queryForObject(
+ "select id from users where email = ?", Long.class, SeedFixtures.SYSADMIN_EMAIL);
+ Long workspaceId = jdbcTemplate.queryForObject(
+ "select id from workspaces order by id limit 1", Long.class);
+ Long id = jdbcTemplate.queryForObject("""
+ insert into requests (workspace_id, org_id, requester_id, resource_type,
+ purpose, display_name, status)
+ values (?, ?, ?, ?::resource_type, '테스트', '테스트 리소스', 'SUBMITTED')
+ returning id
+ """, Long.class, workspaceId, orgId, userId, resourceType);
+ return id;
+ }
+
+ private void approve(long requestId) {
+ Long reviewerId = jdbcTemplate.queryForObject(
+ "select id from users where email = ?", Long.class, SeedFixtures.SYSADMIN_EMAIL);
+ jdbcTemplate.update("""
+ insert into request_reviews (request_id, reviewer_id, decision,
+ granted_start_date, granted_end_date)
+ values (?, ?, 'APPROVE', ?, ?)
+ """, requestId, reviewerId, LocalDate.now(), LocalDate.now().plusDays(30));
+ jdbcTemplate.update("update requests set status = 'APPROVED' where id = ?", requestId);
+ }
+
+ @Test
+ void anApprovedKeyRequestWithoutItsDetailIsRefused() {
+ assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> {
+ long requestId = submitRequest("LLM_API_KEY");
+ approve(requestId);
+ })).hasStackTraceContaining("no complete granted specification");
+ }
+
+ @Test
+ void anApprovedKeyRequestWithItsDetailCommits() {
+ Long committed = transactionTemplate.execute(status -> {
+ long requestId = submitRequest("LLM_API_KEY");
+ jdbcTemplate.update(
+ "insert into llm_key_request_details (request_id, req_purpose) values (?, ?)",
+ requestId, "수업 과제");
+ approve(requestId);
+ return requestId;
+ });
+ assertThat(jdbcTemplate.queryForObject(
+ "select status::text from requests where id = ?", String.class, committed))
+ .isEqualTo("APPROVED");
+ }
+
+ @Test
+ void theVmRuleStillHolds() {
+ // The generalization must not have loosened the type it was written for.
+ assertThatThrownBy(() -> transactionTemplate.executeWithoutResult(status -> {
+ long requestId = submitRequest("VM");
+ Long imageId = jdbcTemplate.queryForObject(
+ "select id from os_images order by id limit 1", Long.class);
+ jdbcTemplate.update("""
+ insert into vm_request_details (request_id, image_id, req_vcpu,
+ req_memory_mb, req_disk_gb)
+ values (?, ?, 2, 2048, 20)
+ """, requestId, imageId);
+ approve(requestId); // granted_* left null
+ })).hasStackTraceContaining("no complete granted specification");
+ }
+}
diff --git a/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexMergeTest.java b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexMergeTest.java
new file mode 100644
index 00000000..b99c601c
--- /dev/null
+++ b/src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexMergeTest.java
@@ -0,0 +1,208 @@
+package kr.ac.pusan.pickle.resource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import kr.ac.pusan.pickle.access.ResourceAccessAudit;
+import kr.ac.pusan.pickle.access.ResourceAccessMessages;
+import kr.ac.pusan.pickle.access.ResourceType;
+import kr.ac.pusan.pickle.resource.dto.ResourceSummaryResponse;
+import kr.ac.pusan.pickle.security.AuthenticatedUser;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The merge itself, against fake adapters. No database: what is being pinned is
+ * that a page assembled from several types is the same page a single ordered
+ * list would have produced — and the ways that quietly stops being true are
+ * arithmetic, not SQL.
+ */
+class ResourceIndexMergeTest {
+
+ /** An adapter that serves a fixed, already-ordered list. */
+ private static final class FakeAdapter implements ResourceTypeAdapter {
+ private final ResourceType type;
+ private final List rows;
+ private int lastLimit = -1;
+
+ FakeAdapter(ResourceType type, List rows) {
+ this.type = type;
+ this.rows = rows;
+ }
+
+ @Override
+ public ResourceType type() {
+ return type;
+ }
+
+ @Override
+ public InventoryHead inventoryHead(AuthenticatedUser actor, UUID workspaceId, int limit) {
+ lastLimit = limit;
+ return new InventoryHead(rows.subList(0, Math.min(limit, rows.size())), rows.size());
+ }
+
+ @Override
+ public Optional identify(long resourceId) {
+ return Optional.empty();
+ }
+
+ @Override
+ public Optional identifyByPublicId(UUID publicId) {
+ return Optional.empty();
+ }
+
+ @Override
+ public ResourceAccessMessages accessMessages() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public ResourceAccessAudit accessAudit() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List idsOwnedByWorkspace(long workspaceId) {
+ return List.of();
+ }
+
+ @Override
+ public long countLiveInWorkspace(long workspaceId) {
+ return 0;
+ }
+ }
+
+ private static ResourceSummaryResponse row(ResourceType type, String name, Instant createdAt) {
+ return new ResourceSummaryResponse(UUID.randomUUID(), type, name, null, "ACTIVE",
+ UUID.randomUUID(), "ws", false, List.of(), false, createdAt);
+ }
+
+ private static Instant at(int minute) {
+ return Instant.parse("2026-08-11T00:00:00Z").plusSeconds(minute * 60L);
+ }
+
+ private static List namesOf(List rows) {
+ return rows.stream().map(ResourceSummaryResponse::name).toList();
+ }
+
+ @Test
+ void mergedPageInterleavesTypesNewestFirst() {
+ // Two types whose rows interleave in time. Asking each for "page 0" and
+ // concatenating would put all of one type first; the answer has to be
+ // the order a single list would have had.
+ var vms = new FakeAdapter(ResourceType.VM,
+ List.of(row(ResourceType.VM, "vm-5", at(5)), row(ResourceType.VM, "vm-1", at(1))));
+ var keys = new FakeAdapter(ResourceType.LLM_API_KEY,
+ List.of(row(ResourceType.LLM_API_KEY, "key-4", at(4)),
+ row(ResourceType.LLM_API_KEY, "key-2", at(2))));
+ var service = new ResourceIndexService(List.of(vms, keys));
+
+ var page = service.list(null, null, null, 0, 10);
+ assertThat(namesOf(page.content()))
+ .containsExactly("vm-5", "key-4", "key-2", "vm-1");
+ assertThat(page.totalElements()).isEqualTo(4);
+ }
+
+ @Test
+ void noRowRepeatsOrSkipsAcrossConsecutivePages() {
+ // The failure this whole design exists for: a per-type page N walk
+ // produces a first page that looks right and later pages that drop rows.
+ List a = new ArrayList<>();
+ List b = new ArrayList<>();
+ for (int i = 24; i >= 0; i--) {
+ boolean vm = i % 3 == 0;
+ (vm ? a : b).add(row(vm ? ResourceType.VM : ResourceType.LLM_API_KEY, "r-" + i, at(i)));
+ }
+ var service = new ResourceIndexService(List.of(new FakeAdapter(ResourceType.VM, a),
+ new FakeAdapter(ResourceType.LLM_API_KEY, b)));
+
+ List walked = new ArrayList<>();
+ for (int p = 0; p < 3; p++) {
+ walked.addAll(namesOf(service.list(null, null, null, p, 10).content()));
+ }
+ List expected = new ArrayList<>();
+ for (int i = 24; i >= 0; i--) {
+ expected.add("r-" + i);
+ }
+ assertThat(walked).containsExactlyElementsOf(expected);
+ assertThat(walked).doesNotHaveDuplicates();
+ }
+
+ @Test
+ void equalTimestampsPageDeterministically() {
+ // Rows created in the same instant are what batch creation produces, and
+ // they are exactly where an unstable or id-based tiebreak starts
+ // disagreeing with the database.
+ Instant same = at(7);
+ var a = new FakeAdapter(ResourceType.VM,
+ List.of(row(ResourceType.VM, "a-2", same), row(ResourceType.VM, "a-1", same)));
+ var b = new FakeAdapter(ResourceType.LLM_API_KEY,
+ List.of(row(ResourceType.LLM_API_KEY, "b-2", same),
+ row(ResourceType.LLM_API_KEY, "b-1", same)));
+ var service = new ResourceIndexService(List.of(a, b));
+
+ var first = namesOf(service.list(null, null, null, 0, 2).content());
+ var firstAgain = namesOf(service.list(null, null, null, 0, 2).content());
+ var second = namesOf(service.list(null, null, null, 1, 2).content());
+
+ assertThat(first).isEqualTo(firstAgain);
+ assertThat(first).doesNotContainAnyElementsOf(second);
+ var walked = new ArrayList<>(first);
+ walked.addAll(second);
+ assertThat(walked).containsExactlyInAnyOrder("a-1", "a-2", "b-1", "b-2");
+ // Within one type the adapter's own order survives the merge.
+ assertThat(walked.indexOf("a-2")).isLessThan(walked.indexOf("a-1"));
+ }
+
+ @Test
+ void totalsAreTheSumOfTypeTotals() {
+ var a = new FakeAdapter(ResourceType.VM, List.of(row(ResourceType.VM, "a", at(2))));
+ var b = new FakeAdapter(ResourceType.LLM_API_KEY,
+ List.of(row(ResourceType.LLM_API_KEY, "b", at(3)),
+ row(ResourceType.LLM_API_KEY, "c", at(1))));
+ var service = new ResourceIndexService(List.of(a, b));
+
+ var page = service.list(null, null, null, 0, 2);
+ assertThat(page.totalElements()).isEqualTo(3);
+ assertThat(page.totalPages()).isEqualTo(2);
+ assertThat(page.content()).hasSize(2);
+ }
+
+ @Test
+ void aTypeWithNoRowsDoesNotBreakThePage() {
+ var empty = new FakeAdapter(ResourceType.LLM_API_KEY, List.of());
+ var full = new FakeAdapter(ResourceType.VM, List.of(row(ResourceType.VM, "only", at(1))));
+ var service = new ResourceIndexService(List.of(empty, full));
+
+ var page = service.list(null, null, null, 0, 10);
+ assertThat(namesOf(page.content())).containsExactly("only");
+ assertThat(page.totalElements()).isEqualTo(1);
+ }
+
+ @Test
+ void aDeepPageIsEmptyButKeepsItsTotals() {
+ // Guards the head-size arithmetic: a far page must answer an empty
+ // content with honest totals, never a short page that reads as the end.
+ var a = new FakeAdapter(ResourceType.VM, List.of(row(ResourceType.VM, "a", at(1))));
+ var service = new ResourceIndexService(List.of(a));
+
+ var page = service.list(null, null, null, 40, 10);
+ assertThat(page.content()).isEmpty();
+ assertThat(page.totalElements()).isEqualTo(1);
+ assertThat(page.totalPages()).isEqualTo(1);
+ }
+
+ @Test
+ void eachTypeIsAskedForEnoughRowsToCoverThePage() {
+ // The head has to cover the whole offset, or a later page silently
+ // loses the rows that would have sorted into it.
+ var a = new FakeAdapter(ResourceType.VM, List.of(row(ResourceType.VM, "a", at(1))));
+ var service = new ResourceIndexService(List.of(a));
+
+ service.list(null, null, null, 3, 10);
+ assertThat(a.lastLimit).isEqualTo(40);
+ }
+}
diff --git a/src/test/java/kr/ac/pusan/pickle/security/LlmKeyAccessScopingTest.java b/src/test/java/kr/ac/pusan/pickle/security/LlmKeyAccessScopingTest.java
new file mode 100644
index 00000000..2028c4ef
--- /dev/null
+++ b/src/test/java/kr/ac/pusan/pickle/security/LlmKeyAccessScopingTest.java
@@ -0,0 +1,622 @@
+package kr.ac.pusan.pickle.security;
+
+import static kr.ac.pusan.pickle.support.AccessGrantFixtures.grantLlmKeyToOwningWorkspace;
+import static kr.ac.pusan.pickle.support.AccessGrantFixtures.grantLlmKeyToUser;
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.net.URI;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Stream;
+import kr.ac.pusan.pickle.access.ResourceRole;
+import kr.ac.pusan.pickle.orgs.Org;
+import kr.ac.pusan.pickle.orgs.OrgRepository;
+import kr.ac.pusan.pickle.support.EmbeddedPostgresConfig;
+import kr.ac.pusan.pickle.support.ReauthTestSupport;
+import kr.ac.pusan.pickle.support.RequestFixtures;
+import kr.ac.pusan.pickle.support.SeedFixtures;
+import kr.ac.pusan.pickle.user.User;
+import kr.ac.pusan.pickle.user.UserRepository;
+import kr.ac.pusan.pickle.user.UserRole;
+import kr.ac.pusan.pickle.user.UserStatus;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import tools.jackson.databind.ObjectMapper;
+
+/**
+ * Runtime enforcement of every resource-scoped LLM API key operation — the
+ * key's row in the table-driven suite {@link VmAccessScopingTest} pioneered.
+ *
+ * The matrix marks these ops {@code allow_resource_scoped:}, which the
+ * annotation-reading {@link PermissionMatrixTest} cannot verify: the access
+ * list is consulted in the service layer and there is no annotation to read.
+ * So each op is driven over the real HTTP surface with a requester whose
+ * standing is set precisely one rung at a time, and the answer asserted — 404
+ * {@code RESOURCE_NOT_FOUND} for the outsider the key's existence is masked
+ * from, 403 {@code WORKSPACE_ROLE_INSUFFICIENT} below the rung, neither denial
+ * status at or above it.
+ *
+ * Two of the key's ops sit at the OWNER rung for different reasons, and the
+ * difference is the point rather than an accident:
+ *
+ *
+ * - issue is content access. Minting the secret requires an OWNER
+ * grant; a workspace owner's standing rights must not satisfy
+ * it, and neither must an administrator role — nobody reads a key's
+ * plaintext through standing alone;
+ * - revoke is a standing right. A workspace owner with no grant may
+ * always take a key of their workspace away, as may an ORG_ADMIN of the
+ * owning org and a SYS_ADMIN — while an ORG_ADMIN of a different org
+ * still gets the masking 404.
+ *
+ *
+ * The fixture key is deliberately left {@code PENDING} with a null
+ * {@code token_hash} — the state between approval and the owner's first mint.
+ * Issue on it succeeds (that is the mint), and revoke must reach it too: a key
+ * nobody ever issued still represents a right that can be taken away.
+ *
+ *
"Allowed" is asserted as neither 403 nor 404, as in the VM
+ * suite; every expected 403 additionally asserts the
+ * {@code WORKSPACE_ROLE_INSUFFICIENT} code so a {@code REAUTH_REQUIRED} can
+ * never be mistaken for a rung refusal, and the sudo-gated ops always carry a
+ * live reauth token. Every (op, scenario) pair builds its own key: several of
+ * these ops change what they touch, and a shared fixture would make the
+ * outcome depend on execution order.
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles("test")
+@Import(EmbeddedPostgresConfig.class)
+class LlmKeyAccessScopingTest {
+
+ /**
+ * The ops a workspace owner reaches through their standing rights rather
+ * than through a rung: the access list, and taking the key away. Issue is
+ * deliberately absent — minting the secret is content access, and standing
+ * rights open nothing inside a resource.
+ */
+ private static final Set MANAGED_OPS = Set.of("revokeLlmKey",
+ "listLlmKeyAccessGrants", "addLlmKeyAccessGrant", "updateLlmKeyAccessGrant",
+ "removeLlmKeyAccessGrant");
+
+ /**
+ * One resource-scoped operation as the permission matrix declares it.
+ *
+ * @param id the contract operation id
+ * @param method HTTP method
+ * @param path path under {@code /api/v1}, with fixture placeholders
+ * @param required the rung the matrix says this op needs
+ * @param reauth whether the endpoint is sudo-mode gated
+ * @param body request body template, or null for a bodiless request
+ */
+ private record ScopedOp(String id, HttpMethod method, String path, ResourceRole required,
+ boolean reauth, String body) {
+ }
+
+ /** The five standings each op is driven with. */
+ private enum Scenario {
+ NON_MEMBER,
+ MEMBER_WITHOUT_GRANT,
+ GRANT_BELOW_RUNG,
+ GRANT_AT_RUNG,
+ WORKSPACE_OWNER_WITHOUT_GRANT
+ }
+
+ /** The throwaway key one (op, scenario) pair acts on, and a spare grant on it. */
+ private record Fixture(long keyId, long grantId) {
+ }
+
+ /** Who is driving one case, and the reauth token they would need. */
+ private record Requester(long userId, String token) {
+ }
+
+ /**
+ * The eight resource-scoped LLM key operations, transcribed from
+ * {@code permission-matrix.yaml}. Adding an op to the product means adding
+ * one row here.
+ */
+ private static final List OPS = List.of(
+ op("getLlmKey", HttpMethod.GET, "/llm-keys/{keyId}", ResourceRole.VIEWER),
+
+ bodyOp("updateLlmKey", HttpMethod.PATCH, "/llm-keys/{keyId}", ResourceRole.EDITOR,
+ "{\"name\":\"스코프\"}"),
+
+ reauthOp("issueLlmKeyToken", HttpMethod.POST, "/llm-keys/{keyId}/token",
+ ResourceRole.OWNER, null),
+ reauthOp("revokeLlmKey", HttpMethod.POST, "/llm-keys/{keyId}/revoke",
+ ResourceRole.OWNER, null),
+ op("listLlmKeyAccessGrants", HttpMethod.GET, "/llm-keys/{keyId}/access",
+ ResourceRole.OWNER),
+ reauthOp("addLlmKeyAccessGrant", HttpMethod.POST, "/llm-keys/{keyId}/access",
+ ResourceRole.OWNER,
+ "{\"granteeType\":\"USER\",\"userId\":\"{spareUserId}\",\"role\":\"VIEWER\"}"),
+ reauthOp("updateLlmKeyAccessGrant", HttpMethod.PATCH,
+ "/llm-keys/{keyId}/access/{grantId}", ResourceRole.OWNER,
+ "{\"role\":\"VIEWER\"}"),
+ reauthOp("removeLlmKeyAccessGrant", HttpMethod.DELETE,
+ "/llm-keys/{keyId}/access/{grantId}", ResourceRole.OWNER, null));
+
+ private static ScopedOp op(String id, HttpMethod method, String path, ResourceRole required) {
+ return new ScopedOp(id, method, path, required, false, null);
+ }
+
+ private static ScopedOp bodyOp(String id, HttpMethod method, String path,
+ ResourceRole required, String body) {
+ return new ScopedOp(id, method, path, required, false, body);
+ }
+
+ private static ScopedOp reauthOp(String id, HttpMethod method, String path,
+ ResourceRole required, String body) {
+ return new ScopedOp(id, method, path, required, true, body);
+ }
+
+ /** Every (op, scenario) pair; the below-rung case has no meaning at the floor. */
+ private static Stream cases() {
+ List arguments = new ArrayList<>();
+ for (ScopedOp scopedOp : OPS) {
+ for (Scenario scenario : Scenario.values()) {
+ if (scenario == Scenario.GRANT_BELOW_RUNG
+ && scopedOp.required() == ResourceRole.VIEWER) {
+ continue;
+ }
+ arguments.add(Arguments.of(Named.of(scopedOp.id(), scopedOp), scenario));
+ }
+ }
+ return arguments.stream();
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+ @Autowired
+ private ObjectMapper objectMapper;
+ @Autowired
+ private JwtService jwtService;
+ @Autowired
+ private UserRepository userRepository;
+ @Autowired
+ private OrgRepository orgRepository;
+ @Autowired
+ private JdbcTemplate jdbcTemplate;
+
+ private User workspaceOwner;
+ private User member;
+ private User listedBystander;
+ private User spareBystander;
+ private User outsider;
+ private User orgAdminSameOrg;
+ private User orgAdminOtherOrg;
+ private User sysAdmin;
+ private String workspaceOwnerToken;
+ private String memberToken;
+ private String outsiderToken;
+ private long orgId;
+ private long workspaceId;
+
+ @BeforeEach
+ void setUp() {
+ orgId = SeedFixtures.seedOrgId(jdbcTemplate);
+ Org otherOrg = orgRepository.findFirstByNameOrderByIdAsc("키 범위 테스트 기관 B")
+ .orElseGet(() -> orgRepository.save(new Org("키 범위 테스트 기관 B", null)));
+ workspaceOwner = ensureUser("llmscope.owner@pusan.ac.kr", "키범위워크스페이스소유자",
+ UserRole.USER, null);
+ member = ensureUser("llmscope.member@pusan.ac.kr", "키범위구성원", UserRole.USER, null);
+ listedBystander = ensureUser("llmscope.listed@pusan.ac.kr", "키범위등재자", UserRole.USER,
+ null);
+ spareBystander = ensureUser("llmscope.spare@pusan.ac.kr", "키범위예비자", UserRole.USER,
+ null);
+ outsider = ensureUser("llmscope.outsider@pusan.ac.kr", "키범위외부인", UserRole.USER, null);
+ orgAdminSameOrg = ensureUser("llmscope.orgadmin.a@pusan.ac.kr", "키범위기관관리자",
+ UserRole.ORG_ADMIN, orgId);
+ orgAdminOtherOrg = ensureUser("llmscope.orgadmin.b@pusan.ac.kr", "키범위타기관관리자",
+ UserRole.ORG_ADMIN, otherOrg.getId());
+ sysAdmin = ensureUser("llmscope.sysadmin@pusan.ac.kr", "키범위시스템관리자",
+ UserRole.SYS_ADMIN, null);
+ workspaceOwnerToken = jwtService.createAccessToken(workspaceOwner);
+ memberToken = jwtService.createAccessToken(member);
+ outsiderToken = jwtService.createAccessToken(outsider);
+ workspaceId = ensureWorkspace();
+ addMember(workspaceOwner.getId(), "OWNER");
+ addMember(member.getId(), "MEMBER");
+ addMember(listedBystander.getId(), "MEMBER");
+ addMember(spareBystander.getId(), "MEMBER");
+ // The outsider must stay out of the workspace: their whole purpose is the
+ // 404 mask, which a stray membership row would turn into a 403. The admin
+ // accounts stay out for the same reason — their reach, where they have
+ // one, must come from their role and not from a membership.
+ for (long userId : new long[] {outsider.getId(), orgAdminSameOrg.getId(),
+ orgAdminOtherOrg.getId(), sysAdmin.getId()}) {
+ jdbcTemplate.update(
+ "delete from workspace_members where workspace_id = ? and user_id = ?",
+ workspaceId, userId);
+ }
+ }
+
+ @ParameterizedTest(name = "[{index}] {0} — {1}")
+ @MethodSource("cases")
+ void resourceScopedOpHonoursItsDeclaredRung(ScopedOp scopedOp, Scenario scenario)
+ throws Exception {
+ Fixture fixture = newFixture();
+ Requester requester = standingFor(scopedOp, scenario, fixture);
+
+ MockHttpServletResponse response = call(scopedOp, fixture, requester);
+ String where = scopedOp.id() + " / " + scenario;
+
+ if (scenario == Scenario.NON_MEMBER) {
+ assertThat(response.getStatus()).as("%s: an outsider must not learn the key exists",
+ where).isEqualTo(404);
+ assertThat(errorCode(response)).as("%s: 404 error code", where)
+ .isEqualTo("RESOURCE_NOT_FOUND");
+ } else if (allowed(scopedOp, scenario)) {
+ // Neither denial status: what comes back instead (200, 201, 204,
+ // 409, …) belongs to the op's own state machine, not to authz.
+ assertThat(response.getStatus())
+ .as("%s: authorization must pass at the declared rung (body: %s)", where,
+ body(response))
+ .isNotIn(403, 404);
+ assertThat(response.getStatus()).as("%s: passed authz but failed unexpectedly (%s)",
+ where, body(response)).isLessThan(500);
+ } else {
+ assertThat(response.getStatus()).as("%s: below the declared rung must be refused",
+ where).isEqualTo(403);
+ // Pins the reason: a sudo-mode or generic denial answers 403 too,
+ // and would otherwise let this case pass without the rung being
+ // consulted at all.
+ assertThat(errorCode(response)).as("%s: 403 error code", where)
+ .isEqualTo("WORKSPACE_ROLE_INSUFFICIENT");
+ }
+ }
+
+ /**
+ * Revoking is a standing right past the workspace too: an ORG_ADMIN of the
+ * owning org and a SYS_ADMIN may take a key away holding no grant and no
+ * workspace membership, while an ORG_ADMIN of a different org is answered
+ * with the same masking 404 as any outsider.
+ */
+ @Test
+ void adminStandingRevokesWithoutAnyGrantButOnlyInsideItsOrg() throws Exception {
+ long keyForOrgAdmin = insertKey();
+ MockHttpServletResponse sameOrg = call(revokeOp(), new Fixture(keyForOrgAdmin, 0),
+ requester(orgAdminSameOrg));
+ assertThat(sameOrg.getStatus()).as("own-org ORG_ADMIN revokes with no grant (body: %s)",
+ body(sameOrg)).isEqualTo(204);
+ assertThat(status(keyForOrgAdmin)).isEqualTo("REVOKED");
+
+ long keyForSysAdmin = insertKey();
+ MockHttpServletResponse platform = call(revokeOp(), new Fixture(keyForSysAdmin, 0),
+ requester(sysAdmin));
+ assertThat(platform.getStatus()).as("SYS_ADMIN revokes with no grant (body: %s)",
+ body(platform)).isEqualTo(204);
+ assertThat(status(keyForSysAdmin)).isEqualTo("REVOKED");
+
+ long keyKeptFromForeignAdmin = insertKey();
+ MockHttpServletResponse foreign = call(revokeOp(),
+ new Fixture(keyKeptFromForeignAdmin, 0), requester(orgAdminOtherOrg));
+ assertThat(foreign.getStatus())
+ .as("a foreign-org ORG_ADMIN must not learn the key exists").isEqualTo(404);
+ assertThat(errorCode(foreign)).isEqualTo("RESOURCE_NOT_FOUND");
+ assertThat(status(keyKeptFromForeignAdmin)).isEqualTo("PENDING");
+ }
+
+ /**
+ * The other half of the issue/revoke asymmetry: minting the secret is
+ * content access, and no standing satisfies it. The workspace owner is
+ * refused in the open; the administrators, not being members, are masked —
+ * the admin override that lets them revoke deliberately stops there.
+ */
+ @Test
+ void noStandingMintsASecretWithoutAnOwnerGrant() throws Exception {
+ long keyId = insertKey();
+
+ MockHttpServletResponse asWorkspaceOwner = call(issueOp(), new Fixture(keyId, 0),
+ new Requester(workspaceOwner.getId(), workspaceOwnerToken));
+ assertThat(asWorkspaceOwner.getStatus())
+ .as("a workspace owner's standing rights must not mint a secret").isEqualTo(403);
+ assertThat(errorCode(asWorkspaceOwner)).isEqualTo("WORKSPACE_ROLE_INSUFFICIENT");
+
+ for (User admin : List.of(orgAdminSameOrg, sysAdmin)) {
+ MockHttpServletResponse asAdmin = call(issueOp(), new Fixture(keyId, 0),
+ requester(admin));
+ assertThat(asAdmin.getStatus())
+ .as("%s: an administrator holds no path to the plaintext", admin.getEmail())
+ .isEqualTo(404);
+ assertThat(errorCode(asAdmin)).isEqualTo("RESOURCE_NOT_FOUND");
+ }
+ assertThat(status(keyId)).isEqualTo("PENDING");
+ assertThat(tokenHash(keyId)).as("nothing was minted").isNull();
+ }
+
+ /**
+ * Issuing again is rotation: the new plaintext hashes to what is stored,
+ * and the old hash is gone — which is what makes "the old value stops
+ * working" a property of the table rather than a promise.
+ */
+ @Test
+ void rotationReplacesTheHashAndTheOldSecretDies() throws Exception {
+ long keyId = insertKey();
+ grantLlmKeyToUser(jdbcTemplate, keyId, member.getId(), "OWNER");
+
+ MockHttpServletResponse first = call(issueOp(), new Fixture(keyId, 0), requester(member));
+ assertThat(first.getStatus()).as("first mint (body: %s)", body(first)).isEqualTo(200);
+ String firstToken = objectMapper.readTree(body(first)).get("token").asString();
+ assertThat(tokenHash(keyId)).isEqualTo(ReauthTestSupport.sha256Hex(firstToken));
+ assertThat(status(keyId)).isEqualTo("ACTIVE");
+
+ MockHttpServletResponse second = call(issueOp(), new Fixture(keyId, 0), requester(member));
+ assertThat(second.getStatus()).as("rotation (body: %s)", body(second)).isEqualTo(200);
+ String secondToken = objectMapper.readTree(body(second)).get("token").asString();
+ assertThat(secondToken).isNotEqualTo(firstToken);
+ assertThat(tokenHash(keyId)).isEqualTo(ReauthTestSupport.sha256Hex(secondToken));
+ // The old secret authenticates nothing anywhere: its hash is not merely
+ // replaced on this row, it exists on no row at all.
+ assertThat(jdbcTemplate.queryForObject(
+ "select count(*) from llm_api_keys where token_hash = ?", Long.class,
+ ReauthTestSupport.sha256Hex(firstToken))).isZero();
+ assertThat(status(keyId)).isEqualTo("ACTIVE");
+ }
+
+ /** A revoked key is dead: issuing it again is refused as a conflict, not obeyed. */
+ @Test
+ void revokedKeyCannotBeIssuedAgain() throws Exception {
+ long keyId = insertKey();
+ grantLlmKeyToUser(jdbcTemplate, keyId, member.getId(), "OWNER");
+ assertThat(call(revokeOp(), new Fixture(keyId, 0), requester(member)).getStatus())
+ .isEqualTo(204);
+
+ MockHttpServletResponse issued = call(issueOp(), new Fixture(keyId, 0), requester(member));
+ assertThat(issued.getStatus()).as("issue after revoke (body: %s)", body(issued))
+ .isEqualTo(409);
+ assertThat(errorCode(issued)).isEqualTo("LLM_KEY_REVOKED");
+ assertThat(status(keyId)).isEqualTo("REVOKED");
+ assertThat(tokenHash(keyId)).as("no secret came into being").isNull();
+ }
+
+ /**
+ * Revoke reaches a key that was never issued — {@code PENDING}, null hash —
+ * and is idempotent: a retried click must not move the timestamp that says
+ * when access actually ended.
+ */
+ @Test
+ void revokeReachesAPendingKeyAndASecondCallMovesNothing() throws Exception {
+ long keyId = insertKey();
+ grantLlmKeyToUser(jdbcTemplate, keyId, member.getId(), "OWNER");
+ assertThat(tokenHash(keyId)).as("the fixture key was never issued").isNull();
+
+ assertThat(call(revokeOp(), new Fixture(keyId, 0), requester(member)).getStatus())
+ .isEqualTo(204);
+ assertThat(status(keyId)).isEqualTo("REVOKED");
+ OffsetDateTime revokedAt = jdbcTemplate.queryForObject(
+ "select revoked_at from llm_api_keys where id = ?", OffsetDateTime.class, keyId);
+ assertThat(revokedAt).isNotNull();
+
+ assertThat(call(revokeOp(), new Fixture(keyId, 0), requester(member)).getStatus())
+ .isEqualTo(204);
+ assertThat(jdbcTemplate.queryForObject(
+ "select revoked_at from llm_api_keys where id = ?", OffsetDateTime.class, keyId))
+ .isEqualTo(revokedAt);
+ }
+
+ /**
+ * A workspace-wide grant applies to a key the way it does to a VM: one row
+ * naming the whole workspace opens the detail to a member the list never
+ * names personally.
+ */
+ @Test
+ void workspaceWideGrantOpensTheKeyToEveryMember() throws Exception {
+ long keyId = insertKey();
+ ScopedOp read = OPS.getFirst();
+
+ MockHttpServletResponse refused = call(read, new Fixture(keyId, 0), requester(member));
+ assertThat(refused.getStatus()).isEqualTo(403);
+
+ grantLlmKeyToOwningWorkspace(jdbcTemplate, keyId, "VIEWER");
+ MockHttpServletResponse opened = call(read, new Fixture(keyId, 0), requester(member));
+ assertThat(opened.getStatus()).as("the workspace-wide grant decides (body: %s)",
+ body(opened)).isEqualTo(200);
+ }
+
+ // ── driving one case ─────────────────────────────────────────────────────
+
+ /** True when this standing should get past the access check for this op. */
+ private static boolean allowed(ScopedOp scopedOp, Scenario scenario) {
+ return switch (scenario) {
+ case NON_MEMBER, MEMBER_WITHOUT_GRANT, GRANT_BELOW_RUNG -> false;
+ case GRANT_AT_RUNG -> true;
+ // A workspace owner's standing is exactly: manage the list and take
+ // the key away. It is deliberately not a rung, so it carries neither
+ // the detail read nor — above all — the mint: the plaintext is what
+ // is inside this resource, and inside needs a grant.
+ case WORKSPACE_OWNER_WITHOUT_GRANT -> MANAGED_OPS.contains(scopedOp.id());
+ };
+ }
+
+ /** Writes the scenario's standing onto the fixture key and names the caller. */
+ private Requester standingFor(ScopedOp scopedOp, Scenario scenario, Fixture fixture) {
+ switch (scenario) {
+ case GRANT_BELOW_RUNG -> grantLlmKeyToUser(jdbcTemplate, fixture.keyId(),
+ member.getId(), oneRungBelow(scopedOp.required()).name());
+ case GRANT_AT_RUNG -> grantLlmKeyToUser(jdbcTemplate, fixture.keyId(), member.getId(),
+ scopedOp.required().name());
+ default -> {
+ // The other three standings are the absence of a grant.
+ }
+ }
+ return switch (scenario) {
+ case NON_MEMBER -> new Requester(outsider.getId(), outsiderToken);
+ case WORKSPACE_OWNER_WITHOUT_GRANT ->
+ new Requester(workspaceOwner.getId(), workspaceOwnerToken);
+ default -> new Requester(member.getId(), memberToken);
+ };
+ }
+
+ /** OWNER → EDITOR → MEMBER → VIEWER; never called at the floor. */
+ private static ResourceRole oneRungBelow(ResourceRole role) {
+ ResourceRole[] rungs = ResourceRole.values();
+ return rungs[role.ordinal() + 1];
+ }
+
+ private MockHttpServletResponse call(ScopedOp scopedOp, Fixture fixture, Requester requester)
+ throws Exception {
+ URI uri = URI.create("/api/v1" + resolve(scopedOp.path(), fixture));
+ MockHttpServletRequestBuilder request = MockMvcRequestBuilders
+ .request(scopedOp.method(), uri)
+ .header("Authorization", "Bearer " + requester.token());
+ if (scopedOp.reauth()) {
+ request = request.header(ReauthTestSupport.HEADER,
+ ReauthTestSupport.seededReauthHeader(jdbcTemplate, requester.userId()));
+ }
+ if (scopedOp.body() != null) {
+ request = request.contentType(MediaType.APPLICATION_JSON)
+ .content(resolve(scopedOp.body(), fixture));
+ }
+ return mockMvc.perform(request).andReturn().getResponse();
+ }
+
+ /** Fills the fixture's ids into a path or body template. */
+ private String resolve(String template, Fixture fixture) {
+ // The fixture builds its rows through SQL and holds their internal ids;
+ // every path placeholder is the public one, because that is the only
+ // thing the endpoints accept.
+ String resolved = template
+ .replace("{keyId}", String.valueOf(pub("llm_api_keys", fixture.keyId())))
+ .replace("{spareUserId}", String.valueOf(spareBystander.getPublicId()));
+ if (resolved.contains("{grantId}")) {
+ resolved = resolved.replace("{grantId}",
+ String.valueOf(pub("resource_access_grants", fixture.grantId())));
+ }
+ return resolved;
+ }
+
+ private ScopedOp issueOp() {
+ return opById("issueLlmKeyToken");
+ }
+
+ private ScopedOp revokeOp() {
+ return opById("revokeLlmKey");
+ }
+
+ private static ScopedOp opById(String id) {
+ return OPS.stream().filter(scopedOp -> scopedOp.id().equals(id)).findFirst().orElseThrow();
+ }
+
+ private Requester requester(User user) {
+ return new Requester(user.getId(), jwtService.createAccessToken(user));
+ }
+
+ private String errorCode(MockHttpServletResponse response) throws Exception {
+ return objectMapper.readTree(body(response)).get("code").asString();
+ }
+
+ private String body(MockHttpServletResponse response) throws Exception {
+ response.setCharacterEncoding("UTF-8");
+ return response.getContentAsString();
+ }
+
+ // ── fixtures ─────────────────────────────────────────────────────────────
+
+ /**
+ * A key plus a spare USER grant for the {grantId} ops to act on. The spare
+ * row deliberately names somebody else: a workspace-wide row here would
+ * raise the requester's own rung and quietly turn the below-rung scenarios
+ * into passes.
+ */
+ private Fixture newFixture() {
+ long keyId = insertKey();
+ grantLlmKeyToUser(jdbcTemplate, keyId, listedBystander.getId(), "VIEWER");
+ long grantId = jdbcTemplate.queryForObject("""
+ select id from resource_access_grants
+ where resource_type = 'LLM_API_KEY' and resource_id = ? and grantee_type = 'USER'
+ and user_id = ?
+ """, Long.class, keyId, listedBystander.getId());
+ return new Fixture(keyId, grantId);
+ }
+
+ /**
+ * Inserted straight into the table, so no approval ever granted anyone: the
+ * access list starts empty and each scenario writes exactly the standing it
+ * means to test. PENDING with a null hash is the state between approval and
+ * the owner's first mint — the one state every op here must handle, since
+ * issue transitions out of it and revoke must reach it.
+ */
+ private long insertKey() {
+ long requestId = RequestFixtures.insertLlmKeyRequest(jdbcTemplate, workspaceId, orgId,
+ workspaceOwner.getId(), "접근 범위 테스트");
+ return jdbcTemplate.queryForObject("""
+ insert into llm_api_keys (workspace_id, org_id, request_id, name, purpose,
+ created_by)
+ values (?, ?, ?, ?, '접근 범위 테스트', ?)
+ returning id
+ """, Long.class, workspaceId, orgId, requestId,
+ "scope-" + UUID.randomUUID().toString().substring(0, 12), workspaceOwner.getId());
+ }
+
+ private String status(long keyId) {
+ return jdbcTemplate.queryForObject("select status from llm_api_keys where id = ?",
+ String.class, keyId);
+ }
+
+ private String tokenHash(long keyId) {
+ return jdbcTemplate.queryForObject("select token_hash from llm_api_keys where id = ?",
+ String.class, keyId);
+ }
+
+ private long ensureWorkspace() {
+ // Reused across the cases in this class, and workspaces carry no unique
+ // key to upsert on any more, so this looks before it writes.
+ List existing = jdbcTemplate.queryForList("""
+ select id from workspaces
+ where name = 'LLM 키 접근 범위 테스트 팀' and deleted_at is null
+ """, Long.class);
+ if (!existing.isEmpty()) {
+ return existing.getFirst();
+ }
+ return jdbcTemplate.queryForObject("""
+ insert into workspaces (kind, name)
+ values ('TEAM'::workspace_kind, 'LLM 키 접근 범위 테스트 팀')
+ returning id
+ """, Long.class);
+ }
+
+ private void addMember(long userId, String role) {
+ jdbcTemplate.update("""
+ insert into workspace_members (workspace_id, user_id, role)
+ values (?, ?, ?::workspace_member_role)
+ on conflict (workspace_id, user_id) do update set role = excluded.role
+ """, workspaceId, userId, role);
+ }
+
+ private User ensureUser(String email, String name, UserRole role, Long userOrgId) {
+ return userRepository.findByEmail(email).orElseGet(() -> {
+ User user = new User(email, "{test-no-login}", name);
+ user.setStatus(UserStatus.ACTIVE);
+ user.setEmailVerifiedAt(Instant.now());
+ user.setRole(role);
+ user.setOrgId(userOrgId);
+ return userRepository.save(user);
+ });
+ }
+
+ /** The public identifier of a row this test set up through direct SQL. */
+ private UUID pub(String table, long id) {
+ return SeedFixtures.publicId(jdbcTemplate, table, id);
+ }
+}
diff --git a/src/test/java/kr/ac/pusan/pickle/security/PermissionMatrixTest.java b/src/test/java/kr/ac/pusan/pickle/security/PermissionMatrixTest.java
index 79537e0b..e62f69a4 100644
--- a/src/test/java/kr/ac/pusan/pickle/security/PermissionMatrixTest.java
+++ b/src/test/java/kr/ac/pusan/pickle/security/PermissionMatrixTest.java
@@ -149,7 +149,7 @@ void runtimeEndpointSetMatchesTheMatrixExactly() throws Exception {
Map matrix = loadMatrix();
Set runtime = runtimeOps().keySet();
- assertThat(matrix).as("permission-matrix.yaml op count (contract v0.35.0)").hasSize(148);
+ assertThat(matrix).as("permission-matrix.yaml op count (contract v0.40.0)").hasSize(157);
Set missingFromMatrix = new TreeSet<>(runtime);
missingFromMatrix.removeAll(matrix.keySet());
diff --git a/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java b/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java
index cfd3f8c6..216d6f77 100644
--- a/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java
+++ b/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java
@@ -43,6 +43,11 @@ class ReauthCoverageTest {
*/
private static final Set DECLARED_REAUTH_ENDPOINTS = new TreeSet<>(Set.of(
"POST /admin/relays/{relayId}/token",
+ // Minting or replacing a key's secret hands somebody a working
+ // credential, and revoking takes one away; both step up the way
+ // reading a VM password does.
+ "POST /llm-keys/{keyId}/token",
+ "POST /llm-keys/{keyId}/revoke",
"DELETE /vms/{vmId}",
"GET /vms/{vmId}/password",
"POST /vms/{vmId}/password/regenerate",
@@ -59,7 +64,12 @@ class ReauthCoverageTest {
// Reading the list does not.
"POST /vms/{vmId}/access",
"PATCH /vms/{vmId}/access/{grantId}",
- "DELETE /vms/{vmId}/access/{grantId}"));
+ "DELETE /vms/{vmId}/access/{grantId}",
+ // The LLM API key's access list steps up for the same reason the
+ // VM's does: an edit decides who reaches the key at all.
+ "POST /llm-keys/{keyId}/access",
+ "PATCH /llm-keys/{keyId}/access/{grantId}",
+ "DELETE /llm-keys/{keyId}/access/{grantId}"));
@Autowired
@Qualifier("requestMappingHandlerMapping")
diff --git a/src/test/java/kr/ac/pusan/pickle/support/AccessGrantFixtures.java b/src/test/java/kr/ac/pusan/pickle/support/AccessGrantFixtures.java
index c5a8cd91..68c0671a 100644
--- a/src/test/java/kr/ac/pusan/pickle/support/AccessGrantFixtures.java
+++ b/src/test/java/kr/ac/pusan/pickle/support/AccessGrantFixtures.java
@@ -47,4 +47,30 @@ public static void revokeVmGrants(JdbcTemplate jdbcTemplate, long vmId) {
"delete from resource_access_grants where resource_type = 'VM' and resource_id = ?",
vmId);
}
+
+ /** Names one person on one LLM API key at {@code role} (re-granting moves the rung). */
+ public static void grantLlmKeyToUser(JdbcTemplate jdbcTemplate, long keyId, long userId,
+ String role) {
+ jdbcTemplate.update("""
+ insert into resource_access_grants
+ (resource_type, resource_id, grantee_type, user_id, role)
+ values ('LLM_API_KEY', ?, 'USER', ?, ?::resource_role)
+ on conflict (resource_type, resource_id, user_id)
+ where grantee_type = 'USER'
+ do update set role = excluded.role
+ """, keyId, userId, role);
+ }
+
+ /** Grants the whole owning workspace one LLM API key at {@code role} (MEMBER or VIEWER). */
+ public static void grantLlmKeyToOwningWorkspace(JdbcTemplate jdbcTemplate, long keyId,
+ String role) {
+ jdbcTemplate.update("""
+ insert into resource_access_grants
+ (resource_type, resource_id, grantee_type, user_id, role)
+ values ('LLM_API_KEY', ?, 'WORKSPACE', null, ?::resource_role)
+ on conflict (resource_type, resource_id)
+ where grantee_type = 'WORKSPACE'
+ do update set role = excluded.role
+ """, keyId, role);
+ }
}
diff --git a/src/test/java/kr/ac/pusan/pickle/support/RequestFixtures.java b/src/test/java/kr/ac/pusan/pickle/support/RequestFixtures.java
index 64323cbd..7f732db6 100644
--- a/src/test/java/kr/ac/pusan/pickle/support/RequestFixtures.java
+++ b/src/test/java/kr/ac/pusan/pickle/support/RequestFixtures.java
@@ -83,4 +83,25 @@ public static long insertVmRequest(JdbcTemplate jdbc, long workspaceId, long org
long requesterId, String purpose, Long imageId) {
return insertVmRequest(jdbc, workspaceId, orgId, requesterId, purpose, imageId, 2, 2048, 10);
}
+
+ /**
+ * A submitted LLM API key request, for tests whose subject is the key that
+ * an approval would create rather than the request flow. The detail row is
+ * written alongside because a request of this type always carries one; the
+ * granted columns stay null, which is what "not yet reviewed" looks like.
+ */
+ public static long insertLlmKeyRequest(JdbcTemplate jdbc, long workspaceId, long orgId,
+ long requesterId, String purpose) {
+ long requestId = jdbc.queryForObject("""
+ insert into requests (resource_type, workspace_id, org_id, requester_id, purpose,
+ display_name)
+ values ('LLM_API_KEY', ?, ?, ?, ?, left(?, 100))
+ returning id
+ """, Long.class, workspaceId, orgId, requesterId, purpose, purpose);
+ jdbc.update("""
+ insert into llm_key_request_details (request_id, req_purpose)
+ values (?, ?)
+ """, requestId, purpose);
+ return requestId;
+ }
}
diff --git a/src/test/resources/permission-matrix.yaml b/src/test/resources/permission-matrix.yaml
index f37bacfa..fd1cc047 100644
--- a/src/test/resources/permission-matrix.yaml
+++ b/src/test/resources/permission-matrix.yaml
@@ -1,4 +1,4 @@
-# Operator-confirmed permission matrix (contract v0.35.0, 148 ops × 5 roles),
+# Operator-confirmed permission matrix (contract v0.35.0, 154 ops × 5 roles),
# enforced 1:1 by PermissionMatrixTest. One entry per operationId. This file is
# POLICY; PermissionMatrixTest asserts the live @PreAuthorize gates match it 1:1
# and that the runtime op set equals this file exactly.
@@ -113,6 +113,21 @@ operations:
- { id: updateVmAccessGrant, method: PATCH, path: "/vms/{vmId}/access/{grantId}", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
- { id: removeVmAccessGrant, method: DELETE, path: "/vms/{vmId}/access/{grantId}", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+ # ── 3.6b LLM API keys (same visibility model as VMs: the list is membership-
+ # scoped with restricted rows, the detail needs a grant, and a non-member is
+ # answered with the 404 existence mask) ────────────────────────────────────
+ - { id: listLlmKeys, method: GET, path: "/llm-keys", roles: { USER: allow_workspace_scoped:MEMBER, ORG_MANAGER: allow_workspace_scoped:MEMBER, ORG_ADMIN: allow_workspace_scoped:MEMBER, SYS_MANAGER: allow_workspace_scoped:MEMBER, SYS_ADMIN: allow_workspace_scoped:MEMBER } }
+ - { id: getLlmKey, method: GET, path: "/llm-keys/{keyId}", roles: { USER: allow_resource_scoped:VIEWER, ORG_MANAGER: allow_resource_scoped:VIEWER, ORG_ADMIN: allow_resource_scoped:VIEWER, SYS_MANAGER: allow_resource_scoped:VIEWER, SYS_ADMIN: allow_resource_scoped:VIEWER } }
+ - { id: updateLlmKey, method: PATCH, path: "/llm-keys/{keyId}", roles: { USER: allow_resource_scoped:EDITOR, ORG_MANAGER: allow_resource_scoped:EDITOR, ORG_ADMIN: allow_resource_scoped:EDITOR, SYS_MANAGER: allow_resource_scoped:EDITOR, SYS_ADMIN: allow_resource_scoped:EDITOR } }
+ - { id: issueLlmKeyToken, method: POST, path: "/llm-keys/{keyId}/token", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+ - { id: revokeLlmKey, method: POST, path: "/llm-keys/{keyId}/revoke", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+
+ # ── 3.6c LLM API key access list (resource OWNER; a workspace owner manages it too) ─
+ - { id: listLlmKeyAccessGrants, method: GET, path: "/llm-keys/{keyId}/access", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+ - { id: addLlmKeyAccessGrant, method: POST, path: "/llm-keys/{keyId}/access", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+ - { id: updateLlmKeyAccessGrant, method: PATCH, path: "/llm-keys/{keyId}/access/{grantId}", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+ - { id: removeLlmKeyAccessGrant, method: DELETE, path: "/llm-keys/{keyId}/access/{grantId}", roles: { USER: allow_resource_scoped:OWNER, ORG_MANAGER: allow_resource_scoped:OWNER, ORG_ADMIN: allow_resource_scoped:OWNER, SYS_MANAGER: allow_resource_scoped:OWNER, SYS_ADMIN: allow_resource_scoped:OWNER } }
+
# ── 3.7 Domains (access-list scoped via the owning VM) ────────────────────
- { id: listDomains, method: GET, path: "/domains", roles: { USER: allow_workspace_scoped:MEMBER, ORG_MANAGER: allow_workspace_scoped:MEMBER, ORG_ADMIN: allow_workspace_scoped:MEMBER, SYS_MANAGER: allow_workspace_scoped:MEMBER, SYS_ADMIN: allow_workspace_scoped:MEMBER } }
- { id: getDomain, method: GET, path: "/domains/{domainId}", roles: { USER: allow_resource_scoped:VIEWER, ORG_MANAGER: allow_resource_scoped:VIEWER, ORG_ADMIN: allow_resource_scoped:VIEWER, SYS_MANAGER: allow_resource_scoped:VIEWER, SYS_ADMIN: allow_resource_scoped:VIEWER } }