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: + * + *

    + *
  1. Sub-path — only the three known calls (sync/usage/bodies) + * exist on this surface; anything else answers a generic 403.
  2. + *
  3. 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.
  4. + *
  5. 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.
  6. + *
  7. 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.
  8. + *
  9. 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.
  10. + *
+ */ +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: + * + * + */ + 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 } }