From 68c5f7f47faf21f628593d68c7613b28f2ad589c Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 16:00:03 +0900 Subject: [PATCH 01/18] feat: merge the inventory across resource types --- .../pickle/resource/ResourceIndexService.java | 84 ++++--- .../pickle/resource/ResourceTypeAdapter.java | 39 +++- .../pickle/resource/VmResourceAdapter.java | 27 ++- .../resource/ResourceIndexMergeTest.java | 208 ++++++++++++++++++ 4 files changed, 310 insertions(+), 48 deletions(-) create mode 100644 src/test/java/kr/ac/pusan/pickle/resource/ResourceIndexMergeTest.java 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 264d71e..4199396 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 993d61e..3efc6fb 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 eefc34a..531ec57 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/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 0000000..b99c601 --- /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); + } +} From 8bad76a7a188553dbe260957fbca54bcb83534a7 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 16:00:03 +0900 Subject: [PATCH 02/18] feat: add the LLM API key resource type --- contract/openapi.yaml | 3 ++- .../java/kr/ac/pusan/pickle/access/ResourceType.java | 11 ++++++----- .../ac/pusan/pickle/common/openapi/OpenApiConfig.java | 2 +- .../db/migration/V80__llm_api_key_resource_type.sql | 6 ++++++ 4 files changed, 15 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/db/migration/V80__llm_api_key_resource_type.sql diff --git a/contract/openapi.yaml b/contract/openapi.yaml index 7dd3586..edfe776 100644 --- a/contract/openapi.yaml +++ b/contract/openapi.yaml @@ -3408,6 +3408,7 @@ components: ResourceType: enum: - "VM" + - "LLM_API_KEY" type: "string" Resources: properties: @@ -5019,7 +5020,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: 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 3114862..a52cd33 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/common/openapi/OpenApiConfig.java b/src/main/java/kr/ac/pusan/pickle/common/openapi/OpenApiConfig.java index 69e2406..6b16e7a 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/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 0000000..557afff --- /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'; From 71480c0d3706f47dc8718a6ebcbe060a5c2ce1b3 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 16:38:24 +0900 Subject: [PATCH 03/18] feat: add the LLM API key tables and generalize the grant invariant --- .../db/migration/V81__llm_api_keys.sql | 202 ++++++++++++++++++ .../ApprovedRequestGrantInvariantTest.java | 104 +++++++++ 2 files changed, 306 insertions(+) create mode 100644 src/main/resources/db/migration/V81__llm_api_keys.sql create mode 100644 src/test/java/kr/ac/pusan/pickle/request/ApprovedRequestGrantInvariantTest.java 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 0000000..1fed9cb --- /dev/null +++ b/src/main/resources/db/migration/V81__llm_api_keys.sql @@ -0,0 +1,202 @@ +-- 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. + +create type llm_api_key_status as enum ('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. + token_hash char(64) not null, + -- The first characters of the plaintext, so a list can tell two keys apart + -- without holding anything that authenticates. + token_prefix text not null, + status llm_api_key_status not null default 'ACTIVE', + 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. +create unique index llm_api_keys_token_hash_key on llm_api_keys (token_hash); +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. 평문은 발급 시 한 번만 표시하고 저장하지 않는다.'; + +-- 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/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 0000000..2b243a8 --- /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"); + } +} From 988fb617d8d479d0aeadbe8903fa5be1f99bc92f Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 17:06:16 +0900 Subject: [PATCH 04/18] feat: add the LLM API key domain layer --- .../ac/pusan/pickle/common/text/Secrets.java | 31 +++ .../kr/ac/pusan/pickle/llm/LlmApiKey.java | 212 ++++++++++++++++++ .../pusan/pickle/llm/LlmApiKeyRepository.java | 23 ++ .../ac/pusan/pickle/llm/LlmApiKeyStatus.java | 17 ++ .../ac/pusan/pickle/llm/LlmApiKeyTokens.java | 52 +++++ .../kr/ac/pusan/pickle/relay/RelayTokens.java | 11 +- 6 files changed, 337 insertions(+), 9 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/common/text/Secrets.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyTokens.java 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 0000000..c7dff90 --- /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/llm/LlmApiKey.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java new file mode 100644 index 0000000..57c013b --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java @@ -0,0 +1,212 @@ +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; + + @Column(name = "token_hash", nullable = false) + private String tokenHash; + + @Column(name = "token_prefix", nullable = false) + private String tokenPrefix; + + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false) + private LlmApiKeyStatus status = LlmApiKeyStatus.ACTIVE; + + @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() { + } + + public LlmApiKey(long workspaceId, long orgId, long requestId, String name, + @Nullable String purpose, String tokenHash, String tokenPrefix, + @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.tokenHash = tokenHash; + this.tokenPrefix = tokenPrefix; + 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; + } + + public void rename(String name, @Nullable String purpose, Instant when) { + this.name = name; + 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 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/LlmApiKeyRepository.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java new file mode 100644 index 0000000..1dc5506 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java @@ -0,0 +1,23 @@ +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); + + 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/LlmApiKeyStatus.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java new file mode 100644 index 0000000..7d3f3c6 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java @@ -0,0 +1,17 @@ +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 { + 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 0000000..cb35e02 --- /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/relay/RelayTokens.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayTokens.java index b0ac4bf..1df1b66 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); } } From 927a6a3a326846b98746287d18db47a3832f4d8b Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 17:59:33 +0900 Subject: [PATCH 05/18] feat: let the owner mint the key the approval created The approver decides that somebody may have a key; only its owner may ever see the plaintext, and the approver is not in the room when they do. So approval creates the key, its access list and its period, and the owner mints the secret themselves - once. A key between the two is PENDING with no hash, and the gateway is not told about it at all: a key that authenticates nothing has no state worth publishing. Carries the token hash's char(64) mapping with it. Hibernate validates the schema at startup, so without it every application context refused to build - which no compile and no single-class run catches. --- .../kr/ac/pusan/pickle/llm/LlmApiKey.java | 48 ++++++++++++++----- .../ac/pusan/pickle/llm/LlmApiKeyStatus.java | 10 ++++ .../db/migration/V81__llm_api_keys.sql | 24 +++++++--- 3 files changed, 64 insertions(+), 18 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java index 57c013b..328ceb8 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java @@ -53,16 +53,25 @@ public class LlmApiKey { @Column private @Nullable String purpose; - @Column(name = "token_hash", nullable = false) - private String tokenHash; + /** + * 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", nullable = false) - private String tokenPrefix; + @Column(name = "token_prefix") + private @Nullable String tokenPrefix; @Enumerated(EnumType.STRING) @JdbcTypeCode(SqlTypes.NAMED_ENUM) @Column(nullable = false) - private LlmApiKeyStatus status = LlmApiKeyStatus.ACTIVE; + private LlmApiKeyStatus status = LlmApiKeyStatus.PENDING; @Column(name = "expires_at") private @Nullable Instant expiresAt; @@ -98,18 +107,16 @@ public class LlmApiKey { 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, String tokenHash, String tokenPrefix, - @Nullable Instant expiresAt, @Nullable Integer rpm, @Nullable Integer tpm, - @Nullable Integer concurrency, long createdBy) { + @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.tokenHash = tokenHash; - this.tokenPrefix = tokenPrefix; this.expiresAt = expiresAt; this.rpm = rpm; this.tpm = tpm; @@ -131,6 +138,25 @@ public void revoke(Instant 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, @Nullable String purpose, Instant when) { this.name = name; this.purpose = purpose; @@ -170,7 +196,7 @@ public String getName() { return purpose; } - public String getTokenPrefix() { + public @Nullable String getTokenPrefix() { return tokenPrefix; } diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java index 7d3f3c6..a201fe0 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyStatus.java @@ -10,6 +10,16 @@ * 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, diff --git a/src/main/resources/db/migration/V81__llm_api_keys.sql b/src/main/resources/db/migration/V81__llm_api_keys.sql index 1fed9cb..57a1c68 100644 --- a/src/main/resources/db/migration/V81__llm_api_keys.sql +++ b/src/main/resources/db/migration/V81__llm_api_keys.sql @@ -4,7 +4,11 @@ -- 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. -create type llm_api_key_status as enum ('ACTIVE', 'SUSPENDED', 'REVOKED', 'EXPIRED'); +-- 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. @@ -34,11 +38,15 @@ create table llm_api_keys ( -- 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. - token_hash char(64) not null, + -- + -- 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 not null, - status llm_api_key_status not null default 'ACTIVE', + 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, @@ -58,13 +66,15 @@ create table llm_api_keys ( 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. -create unique index llm_api_keys_token_hash_key on llm_api_keys (token_hash); +-- 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. 평문은 발급 시 한 번만 표시하고 저장하지 않는다.'; + '평문의 hex sha256. 평문은 발급 시 한 번만 표시하고 저장하지 않는다. 발급 전에는 null.'; -- One usage record per request the gateway served. -- From b6b7660cc17eb8b8e1d06559d9a2a7c8a0ad26e3 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 17:59:33 +0900 Subject: [PATCH 06/18] feat: add the LLM key request detail and its audit actions --- .../ac/pusan/pickle/audit/AuditService.java | 8 ++ .../pusan/pickle/common/error/ErrorCodes.java | 1 + .../pusan/pickle/llm/LlmKeyRequestDetail.java | 106 ++++++++++++++++++ .../llm/LlmKeyRequestDetailRepository.java | 12 ++ .../llm/dto/ApproveLlmKeyRequestSpec.java | 36 ++++++ .../llm/dto/CreateLlmKeyRequestSpec.java | 34 ++++++ 6 files changed, 197 insertions(+) create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetail.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetailRepository.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/ApproveLlmKeyRequestSpec.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/CreateLlmKeyRequestSpec.java 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 550b33b..83a0ef2 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"; 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 45d31f5..7124862 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,7 @@ 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"; // 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/llm/LlmKeyRequestDetail.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestDetail.java new file mode 100644 index 0000000..1bee207 --- /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 0000000..a2690ec --- /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/dto/ApproveLlmKeyRequestSpec.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/ApproveLlmKeyRequestSpec.java new file mode 100644 index 0000000..abece66 --- /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 0000000..5770711 --- /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) { +} From eaf047e3d5ab06e85eec747ba7281bbf138699e9 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 17:49:22 +0900 Subject: [PATCH 07/18] refactor: share the request body cap wrapper Promote the relay filter's chunked-body cap wrapper to common.web so the LLM gateway chain can reuse it. Behaviour unchanged. --- .../pickle/common/web/BodyCappingRequest.java | 83 +++++++++++++++++++ .../pusan/pickle/relay/RelayAuthFilter.java | 76 +---------------- .../pusan/pickle/relay/RelayBodyCapTest.java | 13 +-- 3 files changed, 91 insertions(+), 81 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/common/web/BodyCappingRequest.java 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 0000000..783f775 --- /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/relay/RelayAuthFilter.java b/src/main/java/kr/ac/pusan/pickle/relay/RelayAuthFilter.java index 6dd6449..87d849a 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/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java b/src/test/java/kr/ac/pusan/pickle/relay/RelayBodyCapTest.java index df22d6c..7a7e23c 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) { From f0a74d10031f1d1584f79ada6b1e113fa75af820 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 17:49:40 +0900 Subject: [PATCH 08/18] feat: serve the LLM gateway sync and ingest link (V82) The dedicated /internal/llm chain sits ahead of the broad /internal catch-all, which moves one order slot down with behaviour unchanged. The unchanged poll answers the bare generation without building the document, a generation reported above ours raises the counter instead of being discarded, per-event usage problems are never a 4xx, and the bodies endpoint accepts and deliberately stores nothing. --- README.md | 4 + .../ac/pusan/pickle/audit/AuditService.java | 12 + .../pickle/config/InternalSecurityConfig.java | 13 +- .../pickle/config/LlmGatewayProperties.java | 83 +++ .../config/LlmGatewaySecurityConfig.java | 44 ++ .../pickle/llm/LlmGatewayAuthFilter.java | 193 ++++++ .../pickle/llm/LlmGatewayController.java | 74 +++ .../pickle/llm/LlmGatewayGenerations.java | 61 ++ .../ac/pusan/pickle/llm/LlmSyncService.java | 268 +++++++++ .../ac/pusan/pickle/llm/LlmUsageService.java | 219 +++++++ .../pickle/llm/dto/LlmBodiesRequest.java | 36 ++ .../pusan/pickle/llm/dto/LlmSyncRequest.java | 36 ++ .../pusan/pickle/llm/dto/LlmSyncResponse.java | 87 +++ .../pusan/pickle/llm/dto/LlmUsageRequest.java | 50 ++ .../pickle/llm/dto/LlmUsageResponse.java | 10 + src/main/resources/application.yml | 22 + .../V82__llm_gateway_loss_counters.sql | 17 + .../pickle/llm/LlmGatewayEndpointTest.java | 564 ++++++++++++++++++ 18 files changed, 1787 insertions(+), 6 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/config/LlmGatewayProperties.java create mode 100644 src/main/java/kr/ac/pusan/pickle/config/LlmGatewaySecurityConfig.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayAuthFilter.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayController.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayGenerations.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmUsageService.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmBodiesRequest.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncRequest.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncResponse.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageRequest.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmUsageResponse.java create mode 100644 src/main/resources/db/migration/V82__llm_gateway_loss_counters.sql create mode 100644 src/test/java/kr/ac/pusan/pickle/llm/LlmGatewayEndpointTest.java diff --git a/README.md b/README.md index 83fc799..0834502 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/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java index 83a0ef2..d75ed98 100644 --- a/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java +++ b/src/main/java/kr/ac/pusan/pickle/audit/AuditService.java @@ -130,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"; @@ -156,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/config/InternalSecurityConfig.java b/src/main/java/kr/ac/pusan/pickle/config/InternalSecurityConfig.java index 4e3dd80..89b2553 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 0000000..ee0f0ca --- /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 0000000..26f8657 --- /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/LlmGatewayAuthFilter.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmGatewayAuthFilter.java new file mode 100644 index 0000000..182ab56 --- /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 0000000..00bfe0d --- /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 0000000..34cd300 --- /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/LlmSyncService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java new file mode 100644 index 0000000..16d5ef7 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java @@ -0,0 +1,268 @@ +package kr.ac.pusan.pickle.llm; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import kr.ac.pusan.pickle.audit.AuditService; +import kr.ac.pusan.pickle.common.text.Texts; +import kr.ac.pusan.pickle.llm.dto.LlmSyncRequest; +import kr.ac.pusan.pickle.llm.dto.LlmSyncResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import tools.jackson.databind.ObjectMapper; + +/** + * The LLM gateway sync heartbeat: stores the gateway's self-report, stamps + * contact, and answers either the bare generation or the full authorization + * document. + * + *

The unchanged path never builds the document. The report upsert + * returns the current generation, and only when the reported + * {@code appliedGeneration} differs does the document query run — 12 polls a + * minute answered by what is effectively one small upsert. (The relay sync + * service is deliberately NOT the template here: it runs its full snapshot + * join on every poll and throws the rows away when the generation matches.)

+ * + *

The document is read by one SQL statement (generation + + * service-enabled + key rows in a single MVCC view). This tree never raises + * the isolation level, so reading them separately under READ COMMITTED could + * pair a new generation with an older row set — which the gateway would then + * confirm as applied, and the missed change would never be re-sent.

+ * + *

A reported generation above ours is not treated as a violation: + * it means this side went backwards (a restored backup) while the gateway's + * persisted high-water mark did not. Refusing to act would wedge the link + * permanently — every document we could produce would sit below the gateway's + * floor — so the counter is raised above the reported value, the full + * document served, and the event audited once. (The relay link audits and + * discards here; copying that would wedge this link.)

+ */ +@Service +public class LlmSyncService { + + /** Server-side cap on any gateway-reported string persisted anywhere. */ + static final int REPORTED_TEXT_MAX = 1024; + + /** The one document format this build can produce. */ + static final int DOCUMENT_FORMAT = 1; + + /** Upstream names stored from the report; anything past this is dropped. */ + static final int MAX_UPSTREAM_REFS = 32; + + private static final Logger log = LoggerFactory.getLogger(LlmSyncService.class); + + /** + * Generation + kill switch + key rows in ONE statement (single MVCC + * view). The join condition — not a where clause, so a state with zero + * keys still yields its generation row — implements the retention the + * gateway relies on: + * + *
    + *
  • A key without a minted secret (PENDING, or any row with no + * {@code token_hash}) is ABSENT from the document, not + * present-and-refused: it authenticates nothing, so there is no + * state to publish about it, and a null hash would be a document + * the gateway rejects outright. The null-hash condition is the real + * gate (a revoked-before-mint key is not PENDING but still has no + * secret); the status condition is the belt.
  • + *
  • Revoked and expired keys STAY in the document for a 30-day grace + * period, status included, so the gateway can answer + * "api_key_revoked" instead of "invalid_api_key"; past the grace + * they are dropped and "invalid_api_key" becomes the correct + * answer. The payload stays bounded that way.
  • + *
  • An EXPIRED row must carry the expiry the gateway enforces; one + * without it (inconsistent data) is left out rather than served in + * a shape that could fail open.
  • + *
+ */ + private static final String DOCUMENT_SQL = """ + select s.generation, s.service_enabled, + 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 keys = new ArrayList<>(); + while (rs.next()) { + generation = rs.getLong("generation"); + serviceEnabled = rs.getBoolean("service_enabled"); + 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, + List.of(), List.copyOf(keys)); + }); + } + + 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 0000000..0017ffe --- /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/LlmBodiesRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmBodiesRequest.java new file mode 100644 index 0000000..fb19a82 --- /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/LlmSyncRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmSyncRequest.java new file mode 100644 index 0000000..5efbffd --- /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 0000000..932cc7b --- /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 0000000..af176ae --- /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 0000000..6d2581e --- /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/resources/application.yml b/src/main/resources/application.yml index b4c899f..c1c0a1f 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/V82__llm_gateway_loss_counters.sql b/src/main/resources/db/migration/V82__llm_gateway_loss_counters.sql new file mode 100644 index 0000000..5e94b53 --- /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/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 0000000..7f1ad82 --- /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; + }; + } +} From 7198b39eb6dfa7373f968fabd151428c46c66f54 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 18:40:43 +0900 Subject: [PATCH 09/18] refactor: move list access masking into ResourceAccessResolver The VM list's grant-to-visibility reduction is the platform's access rule, not the VM's; lifted so a second resource type reuses it instead of copying it. VM behavior is unchanged. --- .../pickle/access/ResourceAccessResolver.java | 63 ++++++++++++++++++- .../kr/ac/pusan/pickle/vm/VmQueryService.java | 44 ++----------- 2 files changed, 68 insertions(+), 39 deletions(-) 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 199f5c8..408d500 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/vm/VmQueryService.java b/src/main/java/kr/ac/pusan/pickle/vm/VmQueryService.java index 99ffff4..c8208d5 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 From 48c7c558b41233840629f2973fbcf85c795a72db Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 18:41:06 +0900 Subject: [PATCH 10/18] feat: add LLM API key read surface The list and detail a user sees for their keys, plus the key's access-list endpoints as a pass-through into the shared grant service. Visibility follows the platform rule: only a grant opens a row, a member without one gets a restricted row that never carries the token prefix, and a non-member is answered with the 404 existence mask. The token hash appears in no response, asserted against whole bodies. The published spec snapshot is regenerated in the contract round that versions this surface; until then ContractDriftTest's snapshot check is the one expected failure. --- .../access/LlmKeyAccessGrantController.java | 102 ++++++ .../pickle/access/LlmKeyAccessMessages.java | 37 +++ .../pickle/llm/LlmApiKeyQueryService.java | 141 +++++++++ .../pusan/pickle/llm/LlmApiKeyRepository.java | 2 + .../ac/pusan/pickle/llm/LlmKeyController.java | 52 +++ .../pickle/llm/dto/LlmKeyDetailResponse.java | 61 ++++ .../pickle/llm/dto/LlmKeySummaryResponse.java | 80 +++++ .../pickle/contract/ContractDriftTest.java | 9 +- .../ac/pusan/pickle/llm/LlmKeyQueryTest.java | 298 ++++++++++++++++++ .../pickle/security/PermissionMatrixTest.java | 2 +- .../pickle/security/ReauthCoverageTest.java | 7 +- src/test/resources/permission-matrix.yaml | 14 +- 12 files changed, 801 insertions(+), 4 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java create mode 100644 src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyDetailResponse.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeySummaryResponse.java create mode 100644 src/test/java/kr/ac/pusan/pickle/llm/LlmKeyQueryTest.java 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 0000000..9fdb9d5 --- /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 LlmKeyAccessMessages} + * 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/LlmKeyAccessMessages.java b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java new file mode 100644 index 0000000..661f488 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java @@ -0,0 +1,37 @@ +package kr.ac.pusan.pickle.access; + +import kr.ac.pusan.pickle.audit.AuditService; +import kr.ac.pusan.pickle.common.error.ErrorCodes; + +/** + * Every sentence the access machinery says about an LLM API key, and the names + * its access-list edits take in the audit trail. + * + *

Interim home. The resource-generic machinery expects these from the + * type's {@code ResourceTypeAdapter} ({@code accessMessages()} / + * {@code accessAudit()}), the way the VM's live on + * {@link kr.ac.pusan.pickle.resource.VmResourceAdapter}; when the LLM key's + * adapter lands, these constants move onto it and this class goes away. Until + * then this is their only home, so the key's own services and the access + * controller refuse in one wording rather than two that drift. + */ +public final class LlmKeyAccessMessages { + + 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 키의 접근 목록에 있습니다. 등급을 바꾸려면 기존 항목을 수정해 주세요.")); + + public 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 LlmKeyAccessMessages() { + } +} 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 0000000..37bb6fc --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java @@ -0,0 +1,141 @@ +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.LlmKeyAccessMessages; +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(() -> LlmKeyAccessMessages.MESSAGES.notFound()); + ResourceStanding standing = resourceAccessResolver.standing(ResourceType.LLM_API_KEY, + key.getId(), key.getWorkspaceId(), actor.id()); + standing.requireVisible(LlmKeyAccessMessages.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 index 1dc5506..9f43005 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyRepository.java @@ -13,6 +13,8 @@ public interface LlmApiKeyRepository extends JpaRepository { Page findByWorkspaceId(long workspaceId, Pageable pageable); + Page findByWorkspaceIdIn(List workspaceIds, Pageable pageable); + List findByWorkspaceId(long workspaceId); /** 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 0000000..4b05468 --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java @@ -0,0 +1,52 @@ +package kr.ac.pusan.pickle.llm; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +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.llm.dto.LlmKeyDetailResponse; +import kr.ac.pusan.pickle.llm.dto.LlmKeySummaryResponse; +import kr.ac.pusan.pickle.security.AuthenticatedUser; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +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; + + public LlmKeyController(LlmApiKeyQueryService queryService) { + this.queryService = queryService; + } + + @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); + } +} 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 0000000..c86f27c --- /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/LlmKeySummaryResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeySummaryResponse.java new file mode 100644 index 0000000..fdeb178 --- /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/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java b/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java index d736aa9..5290bdb 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,14 @@ 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}", + "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/LlmKeyQueryTest.java b/src/test/java/kr/ac/pusan/pickle/llm/LlmKeyQueryTest.java new file mode 100644 index 0000000..10eed76 --- /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/security/PermissionMatrixTest.java b/src/test/java/kr/ac/pusan/pickle/security/PermissionMatrixTest.java index 79537e0..43b3796 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.35.0)").hasSize(154); 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 cfd3f8c..980cff8 100644 --- a/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java +++ b/src/test/java/kr/ac/pusan/pickle/security/ReauthCoverageTest.java @@ -59,7 +59,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/resources/permission-matrix.yaml b/src/test/resources/permission-matrix.yaml index f37bacf..3f39705 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,18 @@ 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 } } + + # ── 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 } } From 44ea268d603c60b097a0d65bd9cef2d9d4845479 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 19:54:31 +0900 Subject: [PATCH 11/18] feat: answer for the LLM API key as a resource type --- .../access/LlmKeyAccessGrantController.java | 2 +- .../pickle/access/LlmKeyAccessMessages.java | 37 ------ .../pickle/llm/LlmApiKeyQueryService.java | 5 +- .../pickle/llm/LlmKeyResourceAdapter.java | 116 ++++++++++++++++++ 4 files changed, 119 insertions(+), 41 deletions(-) delete mode 100644 src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmKeyResourceAdapter.java diff --git a/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java index 9fdb9d5..33d9292 100644 --- a/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java +++ b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessGrantController.java @@ -32,7 +32,7 @@ *

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 LlmKeyAccessMessages} + * 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", diff --git a/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java b/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java deleted file mode 100644 index 661f488..0000000 --- a/src/main/java/kr/ac/pusan/pickle/access/LlmKeyAccessMessages.java +++ /dev/null @@ -1,37 +0,0 @@ -package kr.ac.pusan.pickle.access; - -import kr.ac.pusan.pickle.audit.AuditService; -import kr.ac.pusan.pickle.common.error.ErrorCodes; - -/** - * Every sentence the access machinery says about an LLM API key, and the names - * its access-list edits take in the audit trail. - * - *

Interim home. The resource-generic machinery expects these from the - * type's {@code ResourceTypeAdapter} ({@code accessMessages()} / - * {@code accessAudit()}), the way the VM's live on - * {@link kr.ac.pusan.pickle.resource.VmResourceAdapter}; when the LLM key's - * adapter lands, these constants move onto it and this class goes away. Until - * then this is their only home, so the key's own services and the access - * controller refuse in one wording rather than two that drift. - */ -public final class LlmKeyAccessMessages { - - 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 키의 접근 목록에 있습니다. 등급을 바꾸려면 기존 항목을 수정해 주세요.")); - - public 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 LlmKeyAccessMessages() { - } -} diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java index 37bb6fc..0d0b2d9 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyQueryService.java @@ -5,7 +5,6 @@ import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -import kr.ac.pusan.pickle.access.LlmKeyAccessMessages; import kr.ac.pusan.pickle.access.ResourceAccessResolver; import kr.ac.pusan.pickle.access.ResourceStanding; import kr.ac.pusan.pickle.access.ResourceType; @@ -128,10 +127,10 @@ public Page listPage(AuthenticatedUser actor, UUID worksp @Transactional(readOnly = true) public LlmKeyDetailResponse get(AuthenticatedUser actor, UUID keyId) { LlmApiKey key = keyRepository.findByPublicId(keyId) - .orElseThrow(() -> LlmKeyAccessMessages.MESSAGES.notFound()); + .orElseThrow(() -> LlmKeyResourceAdapter.MESSAGES.notFound()); ResourceStanding standing = resourceAccessResolver.standing(ResourceType.LLM_API_KEY, key.getId(), key.getWorkspaceId(), actor.id()); - standing.requireVisible(LlmKeyAccessMessages.MESSAGES); + standing.requireVisible(LlmKeyResourceAdapter.MESSAGES); Workspace workspace = workspaceRepository.findById(key.getWorkspaceId()).orElse(null); return LlmKeyDetailResponse.from(key, workspace == null ? null : workspace.getPublicId(), 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 0000000..9ff69be --- /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()); + } +} From 20261c3efe43595ea91eb282da1f8851956aa6ac Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 19:54:31 +0900 Subject: [PATCH 12/18] feat: accept and approve an LLM API key request --- .../admin/dto/ApproveRequestRequest.java | 10 +- .../pickle/llm/LlmKeyRequestSupport.java | 131 ++++++++++++++++++ .../request/dto/CreateRequestRequest.java | 6 +- 3 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestSupport.java 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 754cdab..897f12c 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/llm/LlmKeyRequestSupport.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyRequestSupport.java new file mode 100644 index 0000000..ab2ec7a --- /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/request/dto/CreateRequestRequest.java b/src/main/java/kr/ac/pusan/pickle/request/dto/CreateRequestRequest.java index 5d98a11..1ad31d3 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) { } From 58ab673b661e698fd433e48b261b4f2b3ea36da8 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 19:54:31 +0900 Subject: [PATCH 13/18] feat: let the owner mint, rotate and revoke the key --- .../pusan/pickle/common/error/ErrorCodes.java | 1 + .../ac/pusan/pickle/llm/LlmApiKeyService.java | 132 ++++++++++++++++++ .../ac/pusan/pickle/llm/LlmKeyController.java | 45 +++++- .../pickle/llm/dto/IssuedLlmKeyResponse.java | 37 +++++ .../pickle/llm/dto/UpdateLlmKeyRequest.java | 28 ++++ 5 files changed, 242 insertions(+), 1 deletion(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/IssuedLlmKeyResponse.java create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/UpdateLlmKeyRequest.java 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 7124862..77793e2 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 @@ -59,6 +59,7 @@ public final class ErrorCodes { 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/llm/LlmApiKeyService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java new file mode 100644 index 0000000..17df20f --- /dev/null +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java @@ -0,0 +1,132 @@ +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 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(); + 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 = writable(actor, keyId, ResourceRole.OWNER); + 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); + } + + /** 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(); + if (form.name() != null) { + key.rename(form.name().trim(), Texts.blankToNull(form.purpose()), Instant.now()); + args.put("name", key.getName()); + } + 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/LlmKeyController.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java index 4b05468..f57fe30 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmKeyController.java @@ -2,15 +2,24 @@ 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; @@ -24,9 +33,11 @@ public class LlmKeyController { private final LlmApiKeyQueryService queryService; + private final LlmApiKeyService keyService; - public LlmKeyController(LlmApiKeyQueryService queryService) { + public LlmKeyController(LlmApiKeyQueryService queryService, LlmApiKeyService keyService) { this.queryService = queryService; + this.keyService = keyService; } @GetMapping @@ -49,4 +60,36 @@ public LlmKeyDetailResponse getLlmKey( @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/dto/IssuedLlmKeyResponse.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/IssuedLlmKeyResponse.java new file mode 100644 index 0000000..f4415e6 --- /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/UpdateLlmKeyRequest.java b/src/main/java/kr/ac/pusan/pickle/llm/dto/UpdateLlmKeyRequest.java new file mode 100644 index 0000000..0cc0d02 --- /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) { +} From ea1f2a931a9d13f4489ab1869dd79fa890f97200 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 19:54:40 +0900 Subject: [PATCH 14/18] feat: publish the LLM API key surface in the contract --- contract/openapi.yaml | 677 ++++++++++++++++++ .../pickle/contract/ContractDriftTest.java | 3 + .../pickle/security/PermissionMatrixTest.java | 2 +- .../pickle/security/ReauthCoverageTest.java | 5 + src/test/resources/permission-matrix.yaml | 3 + 5 files changed, 689 insertions(+), 1 deletion(-) diff --git a/contract/openapi.yaml b/contract/openapi.yaml index edfe776..52175b7 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,189 @@ 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" + 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,6 +2964,31 @@ components: - "totalElements" - "totalPages" type: "object" + PageResponseLlmKeySummaryResponse: + properties: + content: + items: + $ref: "#/components/schemas/LlmKeySummaryResponse" + 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" PageResponseNotificationView: properties: content: @@ -3881,6 +4186,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: @@ -7663,6 +7991,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" @@ -9586,3 +10258,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/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java b/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java index 5290bdb..7a926f9 100644 --- a/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java +++ b/src/test/java/kr/ac/pusan/pickle/contract/ContractDriftTest.java @@ -237,6 +237,9 @@ class ContractDriftTest { // 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}", 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 43b3796..e62f69a 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(154); + 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 980cff8..216d6f7 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", diff --git a/src/test/resources/permission-matrix.yaml b/src/test/resources/permission-matrix.yaml index 3f39705..fd1cc04 100644 --- a/src/test/resources/permission-matrix.yaml +++ b/src/test/resources/permission-matrix.yaml @@ -118,6 +118,9 @@ operations: # 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 } } From 7dd686dfaad07dd15799839ea6973141b427e74e Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 20:10:04 +0900 Subject: [PATCH 15/18] feat: report what an LLM key request asked and was granted --- contract/openapi.yaml | 54 +++++++++++++++++++ .../llm/dto/LlmKeyRequestSpecResponse.java | 47 ++++++++++++++++ .../pickle/request/RequestAssembler.java | 14 ++++- .../request/dto/RequestDetailResponse.java | 4 ++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 src/main/java/kr/ac/pusan/pickle/llm/dto/LlmKeyRequestSpecResponse.java diff --git a/contract/openapi.yaml b/contract/openapi.yaml index 52175b7..565bff6 100644 --- a/contract/openapi.yaml +++ b/contract/openapi.yaml @@ -2032,6 +2032,56 @@ components: - "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: @@ -3387,6 +3437,10 @@ components: id: format: "uuid" type: "string" + llmKey: + anyOf: + - $ref: "#/components/schemas/LlmKeyRequestSpecResponse" + - type: "null" orgId: description: "신청 대상 기관. 행이 사라진 경우에만 null입니다." format: "uuid" 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 0000000..ab8de30 --- /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/request/RequestAssembler.java b/src/main/java/kr/ac/pusan/pickle/request/RequestAssembler.java index 6c930ae..083d36c 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/RequestDetailResponse.java b/src/main/java/kr/ac/pusan/pickle/request/dto/RequestDetailResponse.java index 9e8ecba..3f0e940 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) { } From 6cf1dab393a80ebe53f73fd5697cfb566b26f0d0 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 20:37:51 +0900 Subject: [PATCH 16/18] test: pin LLM key access scoping at every rung The matrix marks the key's operations resource-scoped, which the annotation-reading matrix test cannot verify at runtime. Drive all eight operations across the five standings, and pin the deliberate asymmetry between issuing (an OWNER grant, never standing rights) and revoking (a standing right of workspace owners and admins), plus rotation, the revoked-key conflict and revoke idempotence. --- .../security/LlmKeyAccessScopingTest.java | 622 ++++++++++++++++++ .../pickle/support/AccessGrantFixtures.java | 26 + .../pusan/pickle/support/RequestFixtures.java | 21 + 3 files changed, 669 insertions(+) create mode 100644 src/test/java/kr/ac/pusan/pickle/security/LlmKeyAccessScopingTest.java 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 0000000..2028c4e --- /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/support/AccessGrantFixtures.java b/src/test/java/kr/ac/pusan/pickle/support/AccessGrantFixtures.java index c5a8cd9..68c0671 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 64323cb..7f732db 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; + } } From f611cac0a1e0bb4dcf408690169e8d0196d367f5 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 20:38:47 +0900 Subject: [PATCH 17/18] fix: let a workspace owner revoke a key they cannot mint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revoking is a standing right in the access model - taking a resource away, which a workspace owner holds without a grant - and the code required an OWNER grant for it. During a leaked-key incident that left the only people able to stop it as the ones already on its access list, and a workspace owner whose key owner had left would have had to grant themselves content access first: a break-glass record for taking something away. ORG_ADMIN (own org) and SYS_ADMIN now reach it too, matching VM deletion. Minting stays on the OWNER grant. Handing somebody a working credential is content access, and standing rights deliberately do not include that - the same line the VM draws at reading a password. Also here, because both are in the same file: issue re-reads the status after taking the generation lock, since the check before it ran against a snapshot a concurrent revoke could have invalidated - the full-column update would then have written ACTIVE and a null revoked_at back over it. And update() no longer folds purpose into rename, which erased a purpose whenever somebody renamed without resending it, contradicting the schema's own '생략한 항목은 그대로 둡니다'. --- .../kr/ac/pusan/pickle/llm/LlmApiKey.java | 6 +- .../ac/pusan/pickle/llm/LlmApiKeyService.java | 57 ++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java index 328ceb8..14c888b 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKey.java @@ -157,8 +157,12 @@ public boolean isIssued() { return tokenHash != null; } - public void rename(String name, @Nullable String purpose, Instant when) { + 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; } diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java index 17df20f..c661386 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmApiKeyService.java @@ -15,6 +15,7 @@ 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; @@ -64,6 +65,18 @@ public IssuedLlmKeyResponse issue(AuthenticatedUser actor, UUID keyId) { 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<>(); @@ -81,7 +94,7 @@ public IssuedLlmKeyResponse issue(AuthenticatedUser actor, UUID keyId) { */ @Transactional public void revoke(AuthenticatedUser actor, UUID keyId) { - LlmApiKey key = writable(actor, keyId, ResourceRole.OWNER); + LlmApiKey key = revocable(actor, keyId); if (key.getStatus() == LlmApiKeyStatus.REVOKED) { return; // idempotent: a retried click must not move revoked_at } @@ -91,6 +104,38 @@ public void revoke(AuthenticatedUser actor, UUID keyId) { "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) { @@ -101,10 +146,18 @@ public void update(AuthenticatedUser actor, UUID keyId, UpdateLlmKeyRequest form // 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(), Texts.blankToNull(form.purpose()), Instant.now()); + 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()); From e0bc37c55cf74697bfd40f913c627dea373086e8 Mon Sep 17 00:00:00 2001 From: yessjun Date: Tue, 11 Aug 2026 21:09:25 +0900 Subject: [PATCH 18/18] feat: serve the model catalogue in the sync document --- .../ac/pusan/pickle/llm/LlmSyncService.java | 38 ++++++++++++++++++- .../db/migration/V83__llm_models.sql | 37 ++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/migration/V83__llm_models.sql diff --git a/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java index 16d5ef7..d071bf7 100644 --- a/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java +++ b/src/main/java/kr/ac/pusan/pickle/llm/LlmSyncService.java @@ -83,6 +83,15 @@ public class LlmSyncService { */ 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 @@ -214,10 +223,19 @@ 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 @@ -254,10 +272,28 @@ private LlmSyncResponse readDocument() { // "unchanged"). Serving it alongside keys keeps the // both-or-neither rule intact. return new LlmSyncResponse.Document(DOCUMENT_FORMAT, generation, serviceEnabled, - List.of(), List.copyOf(keys)); + 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) { 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 0000000..9531477 --- /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 + '게이트웨이가 서빙할 모델 카탈로그. 행은 운영 스크립트가 넣는다 — 어떤 업스트림이 있는지는 호스트마다 다르다.';