From 44ea613b256bb86694d5f23a2be9a385b7231890 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 11:16:36 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=205a=20=E2=80=94=20REST=20and=20S?= =?UTF-8?q?SE=20API=20with=20tenant=20isolation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The API that makes the platform usable without writing YAML, on top of Phase 4. Custom resources stay the source of truth — the API holds no copy of the state — but it is the enforcement point for authorization: it checks the caller's rights first and only then talks to the Kubernetes API through its own ServiceAccount, with no impersonation. - The tenant is derived only from the validated token; no endpoint accepts a tenant, tenant id or namespace as a parameter, which would reopen every isolation hole closed in phases 2a and 3. A resource that does not exist in the caller's own namespace returns 404 even when it exists in another tenant, so a 403 can never be used to confirm another tenant's resource exists. Both are proven over real HTTP: BlueMapMapControllerHttpTest with fakes, and TenantIsolationIntegrationTest against a real k3s cluster with a real JWT and a real resource belonging to a different tenant. - JWT validation against a configurable issuer; roles platform-admin, tenant-owner, tenant-operator, tenant-viewer per spec §10.3. - REST endpoints for tenants, sources, maps, renders and hostings; SSE streams for live render progress (watching the resource, not polling) and for logs, sourced from Loki when APUS_LOKI_URL is configured or direct pod logs otherwise. - Response models are dedicated types, never pass-through custom resources, so finalizers, resourceVersion, managed fields and secret names never leak into a response and a CRD change can't silently alter the public interface. - REST endpoints and event streams were built in parallel worktrees; both independently built a Kubernetes client factory and a token-to-principal bridge, which were merged into one tested place with the claim name (organization) declared exactly once, avoiding per-endpoint behavioural drift. This branch replaces feat/phase-5-api (PR #6). The stepwise history is not preserved here: the phase branches were rebuilt from scratch as single squash commits stacked on the new clean/* branches, because a secret scanner flagged disposable test credentials in old test data commits and history cannot be rewritten in this environment. --- api/build.gradle.kts | 117 +++++++ .../onelitefeather/apus/api/Application.java | 30 ++ .../api/events/Fabric8RenderRepository.java | 51 +++ .../api/events/KubernetesPodLogSource.java | 93 ++++++ .../apus/api/events/LogSource.java | 41 +++ .../apus/api/events/LogSourceFactory.java | 63 ++++ .../apus/api/events/LokiLogSource.java | 138 ++++++++ .../apus/api/events/RenderPhases.java | 44 +++ .../apus/api/events/RenderProgress.java | 46 +++ .../apus/api/events/RenderRepository.java | 46 +++ .../api/events/RenderStreamController.java | 229 ++++++++++++++ .../apus/api/events/SseSource.java | 169 ++++++++++ .../hosting/BlueMapHostingController.java | 61 ++++ .../hosting/BlueMapHostingRepository.java | 32 ++ .../rest/hosting/BlueMapHostingResponse.java | 50 +++ .../FabricBlueMapHostingRepository.java | 39 +++ .../api/rest/map/BlueMapMapController.java | 136 ++++++++ .../api/rest/map/BlueMapMapRepository.java | 37 +++ .../apus/api/rest/map/BlueMapMapResponse.java | 83 +++++ .../rest/map/FabricBlueMapMapRepository.java | 46 +++ .../api/rest/map/TriggerRenderRequest.java | 29 ++ .../rest/render/BlueMapRenderController.java | 85 +++++ .../rest/render/BlueMapRenderRepository.java | 38 +++ .../rest/render/BlueMapRenderResponse.java | 61 ++++ .../render/FabricBlueMapRenderRepository.java | 51 +++ .../api/rest/support/BadRequestException.java | 34 ++ .../support/BadRequestExceptionHandler.java | 43 +++ .../api/rest/support/ConditionResponse.java | 36 +++ .../support/ForbiddenExceptionHandler.java | 47 +++ .../api/rest/support/NotFoundException.java | 34 ++ .../support/NotFoundExceptionHandler.java | 39 +++ .../apus/api/rest/support/TenantAccess.java | 46 +++ .../api/rest/tenant/CreateTenantRequest.java | 31 ++ .../rest/tenant/FabricTenantRepository.java | 50 +++ .../api/rest/tenant/TenantController.java | 107 +++++++ .../api/rest/tenant/TenantRepository.java | 45 +++ .../apus/api/rest/tenant/TenantResponse.java | 59 ++++ .../worldsource/CreateWorldSourceRequest.java | 50 +++ .../FabricWorldSourceRepository.java | 51 +++ .../worldsource/WorldSourceController.java | 152 +++++++++ .../worldsource/WorldSourceRepository.java | 37 +++ .../rest/worldsource/WorldSourceResponse.java | 71 +++++ .../apus/api/security/ApusPrincipal.java | 65 ++++ .../apus/api/security/ForbiddenException.java | 36 +++ .../apus/api/security/Role.java | 69 ++++ .../apus/api/security/TenantResolver.java | 63 ++++ .../api/support/KubernetesClientFactory.java | 51 +++ .../apus/api/support/PrincipalResolver.java | 80 +++++ api/src/main/resources/application.yml | 18 ++ .../api/TenantIsolationIntegrationTest.java | 289 +++++++++++++++++ .../apus/api/events/LogSourceFactoryTest.java | 59 ++++ .../apus/api/events/LokiLogSourceTest.java | 80 +++++ .../apus/api/events/RenderPhasesTest.java | 54 ++++ .../events/RenderStreamControllerTest.java | 297 ++++++++++++++++++ .../apus/api/events/SseSourceTest.java | 193 ++++++++++++ .../hosting/BlueMapHostingControllerTest.java | 69 ++++ .../InMemoryBlueMapHostingRepository.java | 43 +++ .../map/BlueMapMapControllerHttpTest.java | 154 +++++++++ .../rest/map/BlueMapMapControllerTest.java | 144 +++++++++ .../map/InMemoryBlueMapMapRepository.java | 53 ++++ .../map/InMemoryBlueMapRenderRepository.java | 65 ++++ .../rest/map/TestBlueMapMapRepository.java | 71 +++++ .../render/BlueMapRenderControllerTest.java | 96 ++++++ .../InMemoryBlueMapRenderRepository.java | 59 ++++ .../rest/support/ExceptionHandlerTest.java | 57 ++++ .../api/rest/support/TenantAccessTest.java | 59 ++++ .../rest/tenant/InMemoryTenantRepository.java | 55 ++++ .../api/rest/tenant/TenantControllerTest.java | 95 ++++++ .../InMemoryWorldSourceRepository.java | 62 ++++ .../WorldSourceControllerTest.java | 103 ++++++ .../apus/api/security/ApusPrincipalTest.java | 127 ++++++++ .../apus/api/security/RoleTest.java | 65 ++++ .../apus/api/security/TenantResolverTest.java | 130 ++++++++ .../K3sTestKubernetesClientFactory.java | 53 ++++ .../api/support/PrincipalResolverTest.java | 105 +++++++ api/src/test/resources/application-test.yml | 22 ++ .../plans/2026-08-09-phase-5a-api.md | 123 ++++++++ settings.gradle.kts | 61 +++- 78 files changed, 5971 insertions(+), 1 deletion(-) create mode 100644 api/build.gradle.kts create mode 100644 api/src/main/java/net/onelitefeather/apus/api/Application.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/Fabric8RenderRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/KubernetesPodLogSource.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/LogSource.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/LogSourceFactory.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/LokiLogSource.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/RenderPhases.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/RenderProgress.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/RenderRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/hosting/FabricBlueMapHostingRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/map/FabricBlueMapMapRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/map/TriggerRenderRequest.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/render/FabricBlueMapRenderRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestException.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestExceptionHandler.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/ConditionResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/ForbiddenExceptionHandler.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundException.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundExceptionHandler.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/TenantAccess.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/tenant/CreateTenantRequest.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/tenant/FabricTenantRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/CreateWorldSourceRequest.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/FabricWorldSourceRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceController.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceRepository.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceResponse.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/ForbiddenException.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/Role.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/security/TenantResolver.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/support/KubernetesClientFactory.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java create mode 100644 api/src/main/resources/application.yml create mode 100644 api/src/test/java/net/onelitefeather/apus/api/TenantIsolationIntegrationTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/LogSourceFactoryTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/LokiLogSourceTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/RenderPhasesTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/SseSourceTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/hosting/InMemoryBlueMapHostingRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerHttpTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapMapRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapRenderRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/map/TestBlueMapMapRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/render/InMemoryBlueMapRenderRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/support/ExceptionHandlerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/support/TenantAccessTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/tenant/InMemoryTenantRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/InMemoryWorldSourceRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceControllerTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/security/ApusPrincipalTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/security/TenantResolverTest.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/support/K3sTestKubernetesClientFactory.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java create mode 100644 api/src/test/resources/application-test.yml create mode 100644 docs/superpowers/plans/2026-08-09-phase-5a-api.md diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..683e121 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,117 @@ +import java.time.Duration + +// Needed before the integrationTest task below can reference :operator's generateCrds task by +// name -- without this, Gradle may configure :api before :operator has registered it. +evaluationDependsOn(":operator") + +plugins { + application +} + +dependencies { + // Tenant/BlueMapMap/BlueMapRender/WorldSource/WorldIngest/BlueMapHosting: pure CR data + // holders from phases 2a/2b/3, reused instead of duplicating their shape here. + implementation(project(":operator")) + + // The fabric8 client itself -- see settings.gradle.kts for why this is needed explicitly + // even though :operator already depends on it (transitively, via `implementation`, which + // does not leak onto this module's compile classpath). + implementation(libs.fabric8.kubernetes.client) + runtimeOnly(libs.fabric8.httpclient.jdk) + + implementation(platform(libs.micronaut.core.bom)) + implementation(libs.micronaut.http.server.netty) + implementation(libs.micronaut.runtime) + annotationProcessor(platform(libs.micronaut.core.bom)) + annotationProcessor(libs.micronaut.inject.java) + + // JWT validation against a configurable issuer -- see settings.gradle.kts and + // src/main/resources/application.yml. Which identity broker sits in front of Apus is an + // open question (design spec §15); micronaut-security-jwt only needs an issuer and a JWKS + // endpoint, both of which are plain OIDC-discovery concepts every candidate broker exposes. + implementation(platform(libs.micronaut.security.bom)) + implementation(libs.micronaut.security.jwt) + annotationProcessor(platform(libs.micronaut.security.bom)) + annotationProcessor(libs.micronaut.security.annotations) + + // JSON (de)serialisation for the REST responses task 2 adds. + implementation(platform(libs.micronaut.serde.bom)) + implementation(libs.micronaut.serde.jackson) + annotationProcessor(platform(libs.micronaut.serde.bom)) + annotationProcessor(libs.micronaut.serde.processor) + + testImplementation(platform(libs.junit.bom)) + testImplementation(libs.junit.jupiter) + testRuntimeOnly(libs.junit.platform.launcher) + + // Test-only: TenantResolverTest proves its namespace convention ("bluemap-") never + // drifts from TenantReconciler's, by calling the reconciler's own namespaceFor(Tenant) + // instead of duplicating the literal prefix as a second source of truth. JOSDK is not a + // main-code dependency of this module -- the api module never reconciles anything -- so it + // is scoped to testImplementation only, not the dependency added above for production code. + testImplementation(libs.josdk) + + // Phase 5a consolidation: both parallel worktrees reported this as missing, which meant + // every existing test called controller/repository methods directly instead of going + // through the real embedded server -- so role enforcement and 404-vs-403 error mapping over + // the actual HTTP/security-filter path were never proven. `micronaut-test-junit5` provides + // `@MicronautTest`/`TestPropertyProvider`; `micronaut-http-client` backs the `@Client("/") + // HttpClient` it injects. Both test-only: production code never makes outbound HTTP calls. + testImplementation(platform(libs.micronaut.test.bom)) + testImplementation(libs.micronaut.test.junit5) + testImplementation(libs.micronaut.http.client) + testAnnotationProcessor(platform(libs.micronaut.core.bom)) + testAnnotationProcessor(libs.micronaut.inject.java) + + // Test-only, for TenantIsolationIntegrationTest: a real k3s API server via Testcontainers, + // the same pattern operator/build.gradle.kts and ingest/build.gradle.kts already use. + testImplementation(platform(libs.testcontainers.bom)) + testImplementation(libs.testcontainers.junit) + testImplementation(libs.testcontainers.k3s) +} + +// io.micronaut.test:micronaut-test-bom imports its own, newer org.testcontainers:testcontainers- +// bom (2.0.5) than this project pins everywhere else (1.20.4, see settings.gradle.kts) -- as two +// competing platform constraints on the same modules, Gradle would otherwise pick the higher one, +// silently upgrading Testcontainers for this module's tests only, off of a major version this +// project has not verified against (2.x renamed/restructured artifacts, breaking this +// configuration's resolution outright). This module does not use micronaut-test's own +// Testcontainers integration -- forcing every org.testcontainers module back to the pinned +// version keeps exactly one Testcontainers version across the whole project. +configurations.matching { it.name == "testCompileClasspath" || it.name == "testRuntimeClasspath" }.configureEach { + resolutionStrategy.eachDependency { + if (requested.group == "org.testcontainers") { + useVersion(libs.versions.testcontainers.get()) + because("pin to the project-wide Testcontainers version, see settings.gradle.kts") + } + } +} + +application { + mainClass.set("net.onelitefeather.apus.api.Application") +} + +// TenantIsolationIntegrationTest starts a k3s container (via Testcontainers), applies the +// `:operator` module's generated CRDs to it, and proves cross-tenant isolation over a real, +// JWT-authenticated HTTP call against a real API server -- minutes of work and Docker, exactly +// like operator/build.gradle.kts's and ingest/build.gradle.kts's own `integrationTest` tasks. +// Excluded from the default `test` task/`build`/`check` for the same reason theirs are. +val operatorGenerateCrds = project(":operator").tasks.named("generateCrds") +val operatorCrdDir = project(":operator").layout.buildDirectory.dir("crds") + +tasks.test { + exclude("**/*IntegrationTest.class") +} + +val integrationTest by tasks.registering(Test::class) { + group = "verification" + description = "Runs the *IntegrationTest classes against a real k3s cluster started via Testcontainers. " + + "Requires Docker. Not part of build/check." + testClassesDirs = sourceSets.test.get().output.classesDirs + classpath = sourceSets.test.get().runtimeClasspath + dependsOn(operatorGenerateCrds) + systemProperty("apus.crd.dir", operatorCrdDir.get().asFile.absolutePath) + include("**/*IntegrationTest.class") + timeout.set(Duration.ofMinutes(10)) + outputs.upToDateWhen { false } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/Application.java b/api/src/main/java/net/onelitefeather/apus/api/Application.java new file mode 100644 index 0000000..18d9187 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/Application.java @@ -0,0 +1,30 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api; + +import io.micronaut.runtime.Micronaut; + +/** Entry point for the Apus REST/SSE API (design spec §11). */ +public final class Application { + + private Application() {} + + public static void main(String[] args) { + Micronaut.run(Application.class, args); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/Fabric8RenderRepository.java b/api/src/main/java/net/onelitefeather/apus/api/events/Fabric8RenderRepository.java new file mode 100644 index 0000000..f1175d5 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/Fabric8RenderRepository.java @@ -0,0 +1,51 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.fabric8.kubernetes.api.model.ListOptionsBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.Watch; +import io.fabric8.kubernetes.client.Watcher; +import jakarta.inject.Singleton; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** Thin {@link RenderRepository} adapter over the fabric8 {@link KubernetesClient}. */ +@Singleton +final class Fabric8RenderRepository implements RenderRepository { + + private final KubernetesClient client; + + Fabric8RenderRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public Optional find(String namespace, String name) { + return Optional.ofNullable( + client.resources(BlueMapRender.class).inNamespace(namespace).withName(name).get()); + } + + @Override + public Watch watch(String namespace, String name, String resourceVersion, Watcher watcher) { + return client.resources(BlueMapRender.class) + .inNamespace(namespace) + .withName(name) + .watch(new ListOptionsBuilder().withResourceVersion(resourceVersion).build(), watcher); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesPodLogSource.java b/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesPodLogSource.java new file mode 100644 index 0000000..6e5ba60 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesPodLogSource.java @@ -0,0 +1,93 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.fabric8.kubernetes.api.model.Pod; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.dsl.LogWatch; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Fallback {@link LogSource}: reads a render job's pod logs directly through the Kubernetes + * client, used only when no Loki instance is configured (see {@link LogSourceFactory}). Needs + * {@code get}/{@code list} on {@code pods} and {@code get} on {@code pods/log} in tenant + * namespaces for the API's ServiceAccount -- permissions the Loki path avoids entirely (design + * spec §11.1: "damit braucht die API keinen direkten Pod-Zugriff"). See the task 3 report for the + * full trade-off. + * + *

Finds the pod via the {@code job-name} label Kubernetes sets on every pod a {@code Job} + * creates (kept for backward compatibility alongside the newer {@code batch.kubernetes.io/ + * job-name} as of Kubernetes 1.27+; {@code RenderJobBuilder} in {@code :operator} does not + * override it, so the plain, older key is used here). If the job's pod was replaced (a retry + * after a crash, design spec §7.3) mid-stream, this does not follow the new pod -- a known gap, + * see the report. + */ +final class KubernetesPodLogSource implements LogSource { + + /** Set by Kubernetes itself on every Pod a Job creates -- not an Apus-specific label. */ + private static final String JOB_NAME_LABEL = "job-name"; + + private final KubernetesClient client; + + KubernetesPodLogSource(KubernetesClient client) { + this.client = client; + } + + @Override + public AutoCloseable tail(String namespace, String jobName, SseSource.Sink sink) { + List pods = client.pods() + .inNamespace(namespace) + .withLabel(JOB_NAME_LABEL, jobName) + .list() + .getItems(); + if (pods.isEmpty()) { + sink.error(new IllegalStateException("no pod found for render job '" + jobName + "'")); + return () -> {}; + } + + String podName = pods.get(0).getMetadata().getName(); + LogWatch logWatch = client.pods().inNamespace(namespace).withName(podName).watchLog(); + Thread reader = Thread.ofVirtual().name("render-log-tail-" + jobName).start(() -> readLines(logWatch, sink)); + + return () -> { + logWatch.close(); + reader.interrupt(); + }; + } + + private static void readLines(LogWatch logWatch, SseSource.Sink sink) { + try (BufferedReader reader = + new BufferedReader(new InputStreamReader(logWatch.getOutput(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + sink.next(line); + } + sink.complete(); + } catch (IOException e) { + // Expected, not exceptional, when the cleanup handle above already closed logWatch + // (client disconnected / render went terminal) -- the read is unblocked by the + // stream closing and surfaces as an IOException. sink itself is already a no-op past + // that point (SseSource.SingleSubscription.done), so this is harmless either way. + sink.error(e); + } + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/LogSource.java b/api/src/main/java/net/onelitefeather/apus/api/events/LogSource.java new file mode 100644 index 0000000..30dee17 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/LogSource.java @@ -0,0 +1,41 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +/** + * Where {@code GET /api/renders/{id}/logs} reads log lines from. Two implementations exist, + * chosen once at startup by {@link LogSourceFactory} depending on whether a Loki instance is + * configured -- see that class, and the "log source" section of the task 3 report, for the + * decision and its consequences for the API's ServiceAccount permissions. + */ +interface LogSource { + + /** + * Starts tailing log lines for the given render's job, pushing each into {@code sink} as it + * arrives. Returns a handle that stops the tail and releases whatever connection/thread it + * holds when closed -- called by {@link SseSource} once the SSE stream ends, whether that is + * because the client disconnected or the render became terminal. + * + * @param namespace the tenant namespace {@code jobName} lives in, already resolved and + * tenant-checked by the caller + * @param jobName {@link net.onelitefeather.apus.operator.api.BlueMapRenderStatus#getJobName()} + * of the render being tailed + * @param sink receives one {@link SseSource.Sink#next} call per log line, in arrival order + */ + AutoCloseable tail(String namespace, String jobName, SseSource.Sink sink); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/LogSourceFactory.java b/api/src/main/java/net/onelitefeather/apus/api/events/LogSourceFactory.java new file mode 100644 index 0000000..401c19b --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/LogSourceFactory.java @@ -0,0 +1,63 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Value; +import jakarta.inject.Singleton; +import java.net.URI; + +/** + * Picks the {@link LogSource} implementation once at startup: {@link LokiLogSource} if + * {@code apus.loki.url} (environment variable {@code APUS_LOKI_URL}) is set, otherwise the + * {@link KubernetesPodLogSource} fallback. + * + *

The decision and why: design spec §11.1 mandates Loki specifically so the API needs + * no direct pod access at all, keeping its ServiceAccount's permissions narrower. Whether that is + * achievable depends entirely on whether a Loki instance is actually reachable from wherever the + * API runs -- not a given in every environment this module might be deployed into (e.g. a local + * or CI run without the full cluster observability stack). Rather than hard-failing when Loki + * is not configured, this falls back to the direct Kubernetes client path (already a dependency + * of this module for the render watch), accepting the wider ServiceAccount permissions + * ({@code get}/{@code list} on {@code pods}, {@code get} on {@code pods/log} in tenant + * namespaces -- see {@link KubernetesPodLogSource}) that the spec's Loki-only design was meant to + * avoid, as the honest cost of that trade-off. + * + *

No health probe against Loki is performed -- presence of the URL is treated as "use it", + * mirroring how {@code APUS_JWT_JWKS_URI}/{@code APUS_JWT_ISSUER} are already handled in {@code + * application.yml} (task 1): configuration, not runtime connectivity, decides which path this + * module takes. A genuine connectivity failure at request time surfaces as a stream error, like + * any other downstream dependency failing. + */ +@Factory +class LogSourceFactory { + + @Singleton + LogSource logSource(@Value("${apus.loki.url:}") String lokiUrl, KubernetesClient client) { + return select(lokiUrl, client); + } + + /** Extracted for {@code LogSourceFactoryTest} to exercise without a Micronaut context. */ + static LogSource select(String lokiUrl, KubernetesClient client) { + if (lokiUrl != null && !lokiUrl.isBlank()) { + return new LokiLogSource(URI.create(lokiUrl)); + } + return new KubernetesPodLogSource(client); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/LokiLogSource.java b/api/src/main/java/net/onelitefeather/apus/api/events/LokiLogSource.java new file mode 100644 index 0000000..e78d953 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/LokiLogSource.java @@ -0,0 +1,138 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; + +/** + * Preferred {@link LogSource} (design spec §11.1): reads log lines out of Loki, which Alloy + * already fills with every pod's logs cluster-wide, instead of the API talking to pods directly. + * Chosen over the Kubernetes-client fallback by {@link LogSourceFactory} whenever a Loki base URL + * is configured -- see that class and the task 3 report for the full trade-off. + * + *

Uses {@code java.net.http.HttpClient} (JDK-provided, no extra compile dependency -- see + * {@link SseSource}'s Javadoc for why avoiding new dependencies matters in this module right + * now) to poll Loki's {@code query_range} HTTP endpoint every {@link #POLL_INTERVAL}, advancing + * the queried window past the last timestamp seen each round. Loki also exposes a push-based + * {@code /loki/api/v1/tail} WebSocket endpoint, which would avoid polling entirely and pairs + * naturally with {@code java.net.http.HttpClient}'s built-in WebSocket support (no extra + * dependency needed there either) -- left for a follow-up: {@code query_range} is enough to + * ship the feature and is far simpler to reason about and to unit-test ({@link + * #parseStreams(String)} needs no live server), and this class is the one place that would need + * to change to switch to it. + * + *

Label assumption, unverified against the actual cluster: the LogQL selector below + * assumes Alloy exposes the standard Kubernetes pod-discovery labels {@code namespace} and + * {@code pod} (the common Alloy/promtail convention). The design spec does not pin this down + * ("gefiltert auf den Job des jeweiligen Renders", §11.1, without saying how) -- confirm against + * the real Alloy scrape config before this is exercised against a live cluster. + */ +final class LokiLogSource implements LogSource { + + private static final Duration POLL_INTERVAL = Duration.ofSeconds(2); + + /** Replayed once at subscribe time, so a viewer opening the stream mid-render sees recent context. */ + private static final Duration INITIAL_LOOKBACK = Duration.ofMinutes(5); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final URI baseUri; + private final HttpClient httpClient; + + LokiLogSource(URI baseUri) { + this.baseUri = baseUri; + this.httpClient = HttpClient.newHttpClient(); + } + + @Override + public AutoCloseable tail(String namespace, String jobName, SseSource.Sink sink) { + String query = "{namespace=\"" + namespace + "\", pod=~\"" + jobName + ".*\"}"; + Thread poller = + Thread.ofVirtual().name("loki-tail-" + jobName).start(() -> poll(query, sink)); + return poller::interrupt; + } + + private void poll(String query, SseSource.Sink sink) { + long startNanos = (System.currentTimeMillis() - INITIAL_LOOKBACK.toMillis()) * 1_000_000L; + try { + while (!Thread.currentThread().isInterrupted()) { + long endNanos = System.currentTimeMillis() * 1_000_000L; + List lines = queryRange(query, startNanos, endNanos); + for (LogLine line : lines) { + sink.next(line.text()); + startNanos = Math.max(startNanos, line.timestampNanos() + 1); + } + Thread.sleep(POLL_INTERVAL); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + // Includes the cancelled-by-interrupt case where HttpClient.send surfaces the + // interruption as something other than InterruptedException; sink is a no-op past + // stream end either way (SseSource.SingleSubscription.done), so reporting it here + // even after cancellation is harmless. + sink.error(e); + return; + } + sink.complete(); + } + + private List queryRange(String query, long startNanos, long endNanos) + throws IOException, InterruptedException { + URI uri = URI.create(baseUri + "/loki/api/v1/query_range?query=" + + URLEncoder.encode(query, StandardCharsets.UTF_8) + "&start=" + startNanos + "&end=" + endNanos + + "&direction=forward&limit=1000"); + HttpRequest request = HttpRequest.newBuilder(uri).GET().build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() != 200) { + throw new IOException("Loki query_range returned HTTP " + response.statusCode() + ": " + response.body()); + } + return parseStreams(response.body()); + } + + /** + * Parses a Loki {@code query_range} response body into ordered log lines. Package-private + * and static so it can be unit-tested against a canned response body without a live Loki + * instance -- see {@code LokiLogSourceTest}. + */ + static List parseStreams(String json) throws IOException { + JsonNode root = MAPPER.readTree(json); + List lines = new ArrayList<>(); + for (JsonNode stream : root.path("data").path("result")) { + for (JsonNode value : stream.path("values")) { + lines.add(new LogLine(Long.parseLong(value.get(0).asText()), value.get(1).asText())); + } + } + lines.sort(Comparator.comparingLong(LogLine::timestampNanos)); + return lines; + } + + record LogLine(long timestampNanos, String text) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/RenderPhases.java b/api/src/main/java/net/onelitefeather/apus/api/events/RenderPhases.java new file mode 100644 index 0000000..e654249 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderPhases.java @@ -0,0 +1,44 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import java.util.Set; +import net.onelitefeather.apus.operator.api.BlueMapRenderStatus; + +/** + * Which {@link BlueMapRenderStatus#getPhase()} values are terminal (design spec §8.5: + * {@code Pending|Syncing|Rendering|Finalizing|Succeeded|Failed}). + * + *

{@code BlueMapRenderStatus.phase} is a plain {@code String}, not an enum -- mirrored here + * as the same two literal values {@code BlueMapRenderReconciler} in {@code :operator} treats as + * terminal, rather than importing that class's private constants (it has none it exposes). Event + * streams must stop once a render reaches one of these: otherwise every open browser tab holding + * an SSE connection open keeps its underlying Kubernetes watch alive forever (see the task 3 + * report's "operational point" section). + */ +final class RenderPhases { + + private static final Set TERMINAL = Set.of("Succeeded", "Failed"); + + private RenderPhases() {} + + /** @param phase the raw {@code status.phase} value; {@code null} (not yet set) is not terminal */ + static boolean isTerminal(String phase) { + return phase != null && TERMINAL.contains(phase); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/RenderProgress.java b/api/src/main/java/net/onelitefeather/apus/api/events/RenderProgress.java new file mode 100644 index 0000000..5722392 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderProgress.java @@ -0,0 +1,46 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.micronaut.serde.annotation.Serdeable; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** + * SSE payload for {@code GET /api/renders/{id}/events} -- an independent response type, not the + * {@link net.onelitefeather.apus.operator.api.BlueMapRenderStatus} custom resource field it is + * built from, for the same reason task 2's response models are independent types: a CR status + * field is the operator's business, not a public contract that should change every time the CRD + * does. + * + * @param phase raw {@code status.phase} (design spec §8.5); {@code null} until the operator sets it + * @param percent 0-100, how far the current render job has gotten + * @param currentMap the map/dimension currently being rendered, or {@code null} + * @param etaSeconds estimated remaining seconds, meaningless (any value, including negative) when {@code degraded} + * @param degraded {@code true} when the runner could not determine real progress (design spec §7.2) + */ +@Serdeable +record RenderProgress(String phase, double percent, String currentMap, long etaSeconds, boolean degraded) { + + static RenderProgress from(BlueMapRender render) { + var status = render.getStatus(); + var progress = status.getProgress(); + return new RenderProgress( + status.getPhase(), progress.getPercent(), progress.getCurrentMap(), progress.getEtaSeconds(), + progress.isDegraded()); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/RenderRepository.java b/api/src/main/java/net/onelitefeather/apus/api/events/RenderRepository.java new file mode 100644 index 0000000..7af2711 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderRepository.java @@ -0,0 +1,46 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.fabric8.kubernetes.client.Watch; +import io.fabric8.kubernetes.client.Watcher; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** + * Read access to {@link BlueMapRender} for the event streams, kept behind an interface so + * {@link RenderStreamController} can be unit-tested with a hand-written fake instead of a mocking + * framework or a real cluster -- neither {@code kubernetes-server-mock} nor a mocking library is + * a test dependency of the {@code api} module (see the task 3 report). + */ +interface RenderRepository { + + /** + * A single, point-in-time read -- used for the tenant/existence check that must happen + * before any stream opens, and to seed a watch's starting {@code resourceVersion} so no + * update landing between this read and the watch registration is missed. + */ + Optional find(String namespace, String name); + + /** + * Watches one {@link BlueMapRender} from a known {@code resourceVersion} onward. The caller + * owns the returned {@link Watch} and must close it once the stream ends (SseSource's + * {@code Wiring} contract does this automatically via the cleanup action). + */ + Watch watch(String namespace, String name, String resourceVersion, Watcher watcher); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java b/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java new file mode 100644 index 0000000..db929f1 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java @@ -0,0 +1,229 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import io.fabric8.kubernetes.client.Watcher; +import io.fabric8.kubernetes.client.WatcherException; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.http.sse.Event; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapRender; +import org.reactivestreams.Publisher; + +/** + * {@code GET /api/renders/{id}/events} and {@code GET /api/renders/{id}/logs} -- live progress + * and log line SSE streams for one render. + * + *

Tenant check before any stream opens (design spec §10.3, binding for this task): + * {@link #requireRender} resolves the caller's own namespace via {@link TenantResolver} and looks + * the render up only there -- never elsewhere. A render that exists in a different + * tenant's namespace is indistinguishable from one that does not exist at all: both are a plain + * 404, thrown synchronously before either endpoint constructs a {@link SseSource}, opens a + * Kubernetes watch, or starts a log tail. This is what stops one tenant from reading another's + * logs, which -- per the design spec's own warning -- typically carry more than a status value. + * + *

Streams end when the render becomes terminal (also binding, see the task 3 report's + * "operational point"): both endpoints watch {@link BlueMapRender} for {@link + * RenderPhases#isTerminal}, and complete the SSE response the moment it is. Without this, a + * browser tab left open after a render finishes would hold its connection -- and the Kubernetes + * watch behind it -- open indefinitely. + * + *

Open to any authenticated caller of the resolved tenant ({@code @Secured(IS_AUTHENTICATED)}) + * rather than a specific role: design spec §10.3 lists {@code tenant-viewer} as read-only, and + * reading a render's own progress/logs is exactly that baseline read access every tenant role + * has -- there is nothing here a {@code tenant-viewer} should be denied that {@code + * tenant-operator}/{@code tenant-owner} may see. + */ +@Controller("/api/renders") +@Secured(SecurityRule.IS_AUTHENTICATED) +class RenderStreamController { + + private final RenderRepository renderRepository; + private final TenantResolver tenantResolver; + private final LogSource logSource; + private final PrincipalResolver principalResolver; + + RenderStreamController( + RenderRepository renderRepository, + TenantResolver tenantResolver, + LogSource logSource, + PrincipalResolver principalResolver) { + this.renderRepository = renderRepository; + this.tenantResolver = tenantResolver; + this.logSource = logSource; + this.principalResolver = principalResolver; + } + + @Get(value = "/{id}/events", produces = MediaType.TEXT_EVENT_STREAM) + Publisher> events(Authentication authentication, @PathVariable String id) { + BlueMapRender render = requireRender(authentication, id); + String namespace = render.getMetadata().getNamespace(); + String resourceVersion = render.getMetadata().getResourceVersion(); + + return new SseSource>(sink -> { + SseSource.Sink progressSink = eventSink(sink); + // Emitted from the same read requireRender already did -- no extra API call -- so a + // viewer sees a value immediately instead of waiting for the next status change. + progressSink.next(RenderProgress.from(render)); + if (RenderPhases.isTerminal(render.getStatus().getPhase())) { + progressSink.complete(); + return () -> {}; + } + // Watching from this exact resourceVersion (not "from now") closes the gap between + // the read above and the watch registration below: no update can land unobserved in + // between. + return renderRepository.watch(namespace, id, resourceVersion, progressWatcher(progressSink)); + }); + } + + @Get(value = "/{id}/logs", produces = MediaType.TEXT_EVENT_STREAM) + Publisher> logs(Authentication authentication, @PathVariable String id) { + BlueMapRender render = requireRender(authentication, id); + String namespace = render.getMetadata().getNamespace(); + String jobName = render.getStatus().getJobName(); + String resourceVersion = render.getMetadata().getResourceVersion(); + boolean terminal = RenderPhases.isTerminal(render.getStatus().getPhase()); + + return new SseSource>(sink -> { + if (jobName == null || jobName.isBlank()) { + // No job has been created yet (e.g. still Pending) -- nothing to tail. A client + // sees an immediately-completed, empty stream and may retry once rendering starts. + sink.complete(); + return () -> {}; + } + AutoCloseable logHandle = logSource.tail(namespace, jobName, eventSink(sink)); + if (terminal) { + return logHandle; + } + AutoCloseable watchHandle = renderRepository.watch(namespace, id, resourceVersion, terminationWatcher(sink)); + return combine(logHandle, watchHandle); + }); + } + + /** + * Resolves the caller's namespace and looks the render up in it -- and only in it. See the + * class Javadoc for why this is the entire tenant-isolation mechanism for both endpoints. + * + * @throws HttpStatusException {@link HttpStatus#FORBIDDEN} if the caller carries no tenant + * claim at all (there is no default tenant to fall back to, {@link TenantResolver}); or + * {@link HttpStatus#NOT_FOUND} if {@code id} does not name a render in that namespace -- + * including when it names one in a different tenant's namespace + */ + private BlueMapRender requireRender(Authentication authentication, String id) { + ApusPrincipal principal = principalResolver.resolve(authentication); + String namespace; + try { + namespace = tenantResolver.namespaceFor(principal); + } catch (ForbiddenException e) { + throw new HttpStatusException(HttpStatus.FORBIDDEN, e.getMessage()); + } + return renderRepository + .find(namespace, id) + .orElseThrow(() -> new HttpStatusException(HttpStatus.NOT_FOUND, "render '" + id + "' not found")); + } + + private static Watcher progressWatcher(SseSource.Sink sink) { + return new Watcher<>() { + @Override + public void eventReceived(Action action, BlueMapRender resource) { + if (action == Action.DELETED) { + sink.complete(); + return; + } + sink.next(RenderProgress.from(resource)); + if (RenderPhases.isTerminal(resource.getStatus().getPhase())) { + sink.complete(); + } + } + + @Override + public void onClose(WatcherException cause) { + if (cause != null) { + sink.error(cause); + } else { + sink.complete(); + } + } + }; + } + + /** Only signals stream end -- the logs endpoint's own {@link LogSource} delivers the data. */ + private static Watcher terminationWatcher(SseSource.Sink> sink) { + return new Watcher<>() { + @Override + public void eventReceived(Action action, BlueMapRender resource) { + if (action == Action.DELETED || RenderPhases.isTerminal(resource.getStatus().getPhase())) { + sink.complete(); + } + } + + @Override + public void onClose(WatcherException cause) { + if (cause != null) { + sink.error(cause); + } else { + sink.complete(); + } + } + }; + } + + /** Wraps a domain-value {@link SseSource.Sink} around one that expects SSE {@link Event}s. */ + private static SseSource.Sink eventSink(SseSource.Sink> downstream) { + return new SseSource.Sink<>() { + @Override + public void next(T value) { + downstream.next(Event.of(value)); + } + + @Override + public void complete() { + downstream.complete(); + } + + @Override + public void error(Throwable throwable) { + downstream.error(throwable); + } + }; + } + + /** Closes every handle, independently -- one failing to close must not skip the others. */ + private static AutoCloseable combine(AutoCloseable... closeables) { + return () -> { + for (AutoCloseable closeable : closeables) { + try { + closeable.close(); + } catch (Exception ignored) { + // See SseSource.SingleSubscription.closeQuietly for why this is swallowed. + } + } + }; + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java b/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java new file mode 100644 index 0000000..0b59b66 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java @@ -0,0 +1,169 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * A minimal single-subscriber {@link Publisher} for driving Server-Sent Event streams from an + * external push source -- a Kubernetes watch, a log tail -- without Reactor or RxJava. Neither is + * a compile-time dependency of the {@code api} module (only the bare {@code reactive-streams} API + * that {@code micronaut-http} itself depends on is; {@code reactor-core} only appears on the + * runtime classpath, pulled in transitively by {@code micronaut-http-server-netty}). + * + *

Phase 5a consolidation decision: kept, {@code reactor-core} was not added. Task 3's + * report flagged the missing compile-time dependency as an open question for whoever merged this + * module back together. Weighing it now: adding {@code reactor-core} explicitly would cost + * nothing at runtime (it is already on the classpath transitively, as above) -- but replacing + * this class with {@code Flux.create}/{@code Sinks} would mean rewriting, by hand, protocol code + * that already works and is already covered by {@code SseSourceTest} for exactly the failure + * modes that matter for a hand-rolled reactive-streams {@link Publisher}: cancellation ({@code + * cancellingTheSubscriptionRunsCleanupWithoutCompletingOrErroring}), producer error ({@code + * erroringTheSinkPropagatesTheThrowableAndRunsCleanup}), double-completion, and the + * reactive-streams §3.9 non-positive-request case. A rewrite would trade known-correct, tested + * ~140 lines for the risk of re-introducing a subtle concurrency bug in the same code, for no + * behavioural gain -- nothing else in this module needs Reactor's operators. If a genuine + * multi-step reactive pipeline shows up in a later phase, that is the point to reconsider, not + * this one. + * + *

Deliberately does not implement per-item backpressure: the first {@link Subscription#request} + * call (whatever {@code n} it carries) is treated as "start delivering, and keep delivering + * everything produced from now on". That is the right trade-off for a live status/log feed: a + * slow consumer should see the newest state, not force the producer to buffer an ever-growing + * backlog of stale ones. Micronaut's own SSE writer requests unbounded demand once at + * subscription time in practice, so this never actually bites. + * + * @param the event payload type + */ +final class SseSource implements Publisher { + + /** The producer-facing half of the channel a {@link Wiring} pushes values into. */ + interface Sink { + /** Delivers one value downstream. A no-op once the stream has ended. */ + void next(T value); + + /** Ends the stream successfully. A no-op if already ended. */ + void complete(); + + /** Ends the stream with an error. A no-op if already ended. */ + void error(Throwable throwable); + } + + /** + * Connects an external push source to a {@link Sink} once a subscriber actually asks for + * data, and returns the cleanup action for that connection -- run exactly once, whether the + * stream ends because the source completed/errored, or because the subscriber cancelled + * (e.g. a client closed its SSE connection). + */ + @FunctionalInterface + interface Wiring { + AutoCloseable wire(Sink sink); + } + + private final Wiring wiring; + + SseSource(Wiring wiring) { + this.wiring = wiring; + } + + @Override + public void subscribe(Subscriber subscriber) { + subscriber.onSubscribe(new SingleSubscription(subscriber)); + } + + /** + * One subscription per subscriber, as required by this being a cold, single-use publisher + * (a fresh {@link SseSource} is built per SSE request). {@code started}/{@code done} are + * guarded independently on purpose: {@code started} only needs to fire {@link #wire} once + * even under concurrent {@link Subscription#request} calls; {@code done} guards every path + * that can end the stream (producer completion/error, subscriber cancellation) so cleanup + * runs exactly once regardless of which one happens first. + */ + private final class SingleSubscription implements Subscription, Sink { + + private final Subscriber subscriber; + private final AtomicBoolean started = new AtomicBoolean(); + private final AtomicBoolean done = new AtomicBoolean(); + private volatile AutoCloseable cleanup; + + private SingleSubscription(Subscriber subscriber) { + this.subscriber = subscriber; + } + + @Override + public void request(long n) { + if (n <= 0) { + if (done.compareAndSet(false, true)) { + subscriber.onError(new IllegalArgumentException( + "reactive-streams §3.9: request(n) called with a non-positive n=" + n)); + } + return; + } + if (started.compareAndSet(false, true)) { + cleanup = wiring.wire(this); + } + } + + @Override + public void cancel() { + if (done.compareAndSet(false, true)) { + closeQuietly(); + } + } + + @Override + public void next(T value) { + if (!done.get()) { + subscriber.onNext(value); + } + } + + @Override + public void complete() { + if (done.compareAndSet(false, true)) { + subscriber.onComplete(); + closeQuietly(); + } + } + + @Override + public void error(Throwable throwable) { + if (done.compareAndSet(false, true)) { + subscriber.onError(throwable); + closeQuietly(); + } + } + + private void closeQuietly() { + AutoCloseable toClose = cleanup; + if (toClose != null) { + try { + toClose.close(); + } catch (Exception ignored) { + // Best-effort cleanup (closing a Kubernetes Watch/LogWatch, joining a reader + // thread) -- the stream has already ended one way or another; a failure to + // release the underlying connection is not something the subscriber can act + // on, and is left to whatever the client library itself logs. + } + } + } + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingController.java new file mode 100644 index 0000000..f343868 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingController.java @@ -0,0 +1,61 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.TenantAccess; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; + +/** {@code GET /api/hostings} -- read-only, the caller's own tenant only. */ +@Controller("/api/hostings") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class BlueMapHostingController { + + private final BlueMapHostingRepository repository; + private final PrincipalResolver principalResolver; + private final TenantResolver tenantResolver; + + public BlueMapHostingController( + BlueMapHostingRepository repository, PrincipalResolver principalResolver, TenantResolver tenantResolver) { + this.repository = repository; + this.principalResolver = principalResolver; + this.tenantResolver = tenantResolver; + } + + @Get + public HttpResponse> list(Authentication authentication) { + ApusPrincipal principal = principalResolver.resolve(authentication); + if (!TenantAccess.canRead(principal)) { + throw new ForbiddenException("principal '" + principal.subject() + "' has no tenant role"); + } + String namespace = tenantResolver.namespaceFor(principal); + + List hostings = + repository.list(namespace).stream().map(BlueMapHostingResponse::from).toList(); + return HttpResponse.ok(hostings); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingRepository.java new file mode 100644 index 0000000..e3f92b4 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingRepository.java @@ -0,0 +1,32 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import java.util.List; +import net.onelitefeather.apus.operator.api.BlueMapHosting; + +/** + * Read access to {@link BlueMapHosting} custom resources, always scoped to a single namespace. + * task-2-brief.md's endpoint table lists only {@code GET /api/hostings} (no by-id lookup, no + * write), so unlike the other repositories in {@code rest/} this one is list-only. See {@code + * TenantRepository}'s Javadoc for why this is an interface. + */ +public interface BlueMapHostingRepository { + + List list(String namespace); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingResponse.java new file mode 100644 index 0000000..6790f41 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingResponse.java @@ -0,0 +1,50 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.ConditionResponse; +import net.onelitefeather.apus.operator.api.BlueMapHosting; + +/** A {@link BlueMapHosting}, as {@code GET /api/hostings} exposes it. */ +@Serdeable +public record BlueMapHostingResponse( + String name, + List maps, + String hostname, + String url, + boolean ready, + int replicas, + List conditions) { + + public static BlueMapHostingResponse from(BlueMapHosting hosting) { + var spec = hosting.getSpec(); + var status = hosting.getStatus(); + List maps = + spec.getMaps().stream().map(ref -> ref == null ? null : ref.getName()).toList(); + return new BlueMapHostingResponse( + hosting.getMetadata().getName(), + maps, + spec.getHostname(), + status.getUrl(), + status.isReady(), + spec.getReplicas(), + status.getConditions().stream().map(ConditionResponse::from).toList()); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/FabricBlueMapHostingRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/FabricBlueMapHostingRepository.java new file mode 100644 index 0000000..900bc21 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/FabricBlueMapHostingRepository.java @@ -0,0 +1,39 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import net.onelitefeather.apus.operator.api.BlueMapHosting; + +/** {@link BlueMapHostingRepository} backed by a real {@link KubernetesClient}. */ +@Singleton +public class FabricBlueMapHostingRepository implements BlueMapHostingRepository { + + private final KubernetesClient client; + + public FabricBlueMapHostingRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public List list(String namespace) { + return client.resources(BlueMapHosting.class).inNamespace(namespace).list().getItems(); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapController.java new file mode 100644 index 0000000..1fa1479 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapController.java @@ -0,0 +1,136 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import io.micronaut.core.annotation.Nullable; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import java.util.List; +import net.onelitefeather.apus.api.rest.render.BlueMapRenderRepository; +import net.onelitefeather.apus.api.rest.render.BlueMapRenderResponse; +import net.onelitefeather.apus.api.rest.support.NotFoundException; +import net.onelitefeather.apus.api.rest.support.TenantAccess; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import net.onelitefeather.apus.operator.api.BlueMapRender; +import net.onelitefeather.apus.operator.api.Ref; + +/** + * {@code GET /api/maps}, {@code GET /api/maps/{id}}, and {@code POST /api/maps/{id}/render} -- + * the caller's own tenant only (design spec §10.3, §11.1). The namespace always comes from + * {@link TenantResolver}, never from a request parameter. + * + *

{@code POST /api/maps/{id}/render} looks the map up in the caller's own namespace first, + * exactly like {@code getById} -- so triggering a render against a foreign tenant's map ID + * fails with the same 404 a plain lookup would, rather than either leaking that the map exists + * elsewhere or creating a {@code BlueMapRender} whose {@code mapRef} dangles. Only once that + * lookup succeeds does it create the {@code BlueMapRender}, in the same namespace as the map it + * refers to (design spec §10.1: a resource may only reference something in its own namespace). + */ +@Controller("/api/maps") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class BlueMapMapController { + + private final BlueMapMapRepository mapRepository; + private final BlueMapRenderRepository renderRepository; + private final PrincipalResolver principalResolver; + private final TenantResolver tenantResolver; + + public BlueMapMapController( + BlueMapMapRepository mapRepository, + BlueMapRenderRepository renderRepository, + PrincipalResolver principalResolver, + TenantResolver tenantResolver) { + this.mapRepository = mapRepository; + this.renderRepository = renderRepository; + this.principalResolver = principalResolver; + this.tenantResolver = tenantResolver; + } + + @Get + public HttpResponse> list(Authentication authentication) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireRead(principal); + String namespace = tenantResolver.namespaceFor(principal); + + List maps = + mapRepository.list(namespace).stream().map(BlueMapMapResponse::from).toList(); + return HttpResponse.ok(maps); + } + + @Get("/{id}") + public HttpResponse getById(Authentication authentication, @PathVariable String id) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireRead(principal); + String namespace = tenantResolver.namespaceFor(principal); + + BlueMapMap map = findOwnMap(namespace, id); + return HttpResponse.ok(BlueMapMapResponse.from(map)); + } + + @Post("/{id}/render") + public HttpResponse triggerRender( + Authentication authentication, @PathVariable String id, @Nullable @Body TriggerRenderRequest request) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireWrite(principal); + String namespace = tenantResolver.namespaceFor(principal); + + // Confirmed to exist in the caller's own namespace before anything is created -- see + // this class's Javadoc for why a foreign-tenant map ID must fail exactly like a + // non-existent one, before a BlueMapRender referencing it is ever created. + findOwnMap(namespace, id); + + BlueMapRender render = new BlueMapRender(); + render.getMetadata().setGenerateName(id + "-"); + Ref mapRef = new Ref(); + mapRef.setName(id); + render.getSpec().setMapRef(mapRef); + render.getSpec().setForce(request != null && request.force()); + + BlueMapRender created = renderRepository.create(namespace, render); + return HttpResponse.created(BlueMapRenderResponse.from(created)); + } + + private BlueMapMap findOwnMap(String namespace, String id) { + return mapRepository + .find(namespace, id) + .orElseThrow(() -> new NotFoundException("no map '" + id + "' in namespace '" + namespace + "'")); + } + + private void requireRead(ApusPrincipal principal) { + if (!TenantAccess.canRead(principal)) { + throw new ForbiddenException("principal '" + principal.subject() + "' has no tenant role"); + } + } + + private void requireWrite(ApusPrincipal principal) { + if (!principal.canWrite()) { + throw new ForbiddenException("principal '" + principal.subject() + "' is not tenant-owner/tenant-operator"); + } + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapRepository.java new file mode 100644 index 0000000..ffb19b7 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapRepository.java @@ -0,0 +1,37 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapMap; + +/** + * Read access to {@link BlueMapMap} custom resources, always scoped to a single namespace. This + * task does not add write endpoints for maps themselves (only {@code POST + * /api/maps/{id}/render}, which creates a {@code BlueMapRender}, not a {@code BlueMapMap} -- + * see task-2-brief.md's endpoint table), so unlike the other repositories in {@code rest/} this + * one has no {@code create}. See {@code TenantRepository}'s Javadoc for why this is an + * interface. + */ +public interface BlueMapMapRepository { + + List list(String namespace); + + Optional find(String namespace, String name); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapResponse.java new file mode 100644 index 0000000..ba72dac --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/BlueMapMapResponse.java @@ -0,0 +1,83 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.ConditionResponse; +import net.onelitefeather.apus.operator.api.BlueMapMap; + +/** + * A {@link BlueMapMap}, as {@code /api/maps} exposes it. {@code bucket} carries only the bucket + * name and endpoint -- never {@code BlueMapMapStatus.Bucket#getSecretName()}, which names the + * Secret holding that bucket's credentials and is exactly the kind of value task-2-brief.md + * forbids in a response. + */ +@Serdeable +public record BlueMapMapResponse( + String name, + SourceResponse source, + TriggerResponse trigger, + BlueMapSettingsResponse bluemap, + int shards, + int historyLimit, + boolean purgeOnDelete, + BucketResponse bucket, + LatestRenderResponse latestRender, + List conditions) { + + public static BlueMapMapResponse from(BlueMapMap map) { + var spec = map.getSpec(); + var status = map.getStatus(); + var source = spec.getSource(); + var trigger = spec.getTrigger(); + var bluemap = spec.getBluemap(); + var bucket = status.getBucket(); + var latestRender = status.getLatestRender(); + return new BlueMapMapResponse( + map.getMetadata().getName(), + new SourceResponse( + source.getSourceRef() == null ? null : source.getSourceRef().getName(), + source.getWorld(), + source.getDimension()), + new TriggerResponse(trigger.isOnNewBundle(), trigger.getSchedule(), trigger.getConcurrencyPolicy()), + new BlueMapSettingsResponse(bluemap.getVersion(), bluemap.getMinecraftVersion()), + spec.getShards(), + spec.getHistoryLimit(), + spec.isPurgeOnDelete(), + new BucketResponse(bucket.getName(), bucket.getEndpoint()), + new LatestRenderResponse(latestRender.getName(), latestRender.getPhase()), + status.getConditions().stream().map(ConditionResponse::from).toList()); + } + + @Serdeable + public record SourceResponse(String sourceRef, String world, String dimension) {} + + @Serdeable + public record TriggerResponse(boolean onNewBundle, String schedule, String concurrencyPolicy) {} + + @Serdeable + public record BlueMapSettingsResponse(String version, String minecraftVersion) {} + + /** Bucket name and endpoint only -- never the Secret name holding its credentials. */ + @Serdeable + public record BucketResponse(String name, String endpoint) {} + + @Serdeable + public record LatestRenderResponse(String name, String phase) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/map/FabricBlueMapMapRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/map/FabricBlueMapMapRepository.java new file mode 100644 index 0000000..cb0ea26 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/FabricBlueMapMapRepository.java @@ -0,0 +1,46 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapMap; + +/** {@link BlueMapMapRepository} backed by a real {@link KubernetesClient}. */ +@Singleton +public class FabricBlueMapMapRepository implements BlueMapMapRepository { + + private final KubernetesClient client; + + public FabricBlueMapMapRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public List list(String namespace) { + return client.resources(BlueMapMap.class).inNamespace(namespace).list().getItems(); + } + + @Override + public Optional find(String namespace, String name) { + return Optional.ofNullable( + client.resources(BlueMapMap.class).inNamespace(namespace).withName(name).get()); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/map/TriggerRenderRequest.java b/api/src/main/java/net/onelitefeather/apus/api/rest/map/TriggerRenderRequest.java new file mode 100644 index 0000000..108ab5e --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/TriggerRenderRequest.java @@ -0,0 +1,29 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * Optional request body for {@code POST /api/maps/{id}/render}, mirroring {@code + * BlueMapRenderSpec#isForce()} ("entspricht {@code --force-render}", design spec §8.5). The + * request deliberately carries nothing else -- in particular no {@code bundleVersion}: which + * bundle a render picks up is resolved from the map's source, not supplied by the caller. + */ +@Serdeable +public record TriggerRenderRequest(boolean force) {} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderController.java new file mode 100644 index 0000000..e9b7021 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderController.java @@ -0,0 +1,85 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.NotFoundException; +import net.onelitefeather.apus.api.rest.support.TenantAccess; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; + +/** + * {@code GET /api/renders} and {@code GET /api/renders/{id}} -- read-only, the caller's own + * tenant only. A render belonging to a different tenant looks up empty in this tenant's + * namespace and, per task-2-brief.md's central rule, produces the exact same 404 as a render + * that does not exist anywhere -- see {@link NotFoundException}'s Javadoc. + */ +@Controller("/api/renders") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class BlueMapRenderController { + + private final BlueMapRenderRepository repository; + private final PrincipalResolver principalResolver; + private final TenantResolver tenantResolver; + + public BlueMapRenderController( + BlueMapRenderRepository repository, PrincipalResolver principalResolver, TenantResolver tenantResolver) { + this.repository = repository; + this.principalResolver = principalResolver; + this.tenantResolver = tenantResolver; + } + + @Get + public HttpResponse> list(Authentication authentication) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireRead(principal); + String namespace = tenantResolver.namespaceFor(principal); + + List renders = repository.list(namespace).stream() + .map(BlueMapRenderResponse::from) + .toList(); + return HttpResponse.ok(renders); + } + + @Get("/{id}") + public HttpResponse getById(Authentication authentication, @PathVariable String id) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireRead(principal); + String namespace = tenantResolver.namespaceFor(principal); + + var render = repository + .find(namespace, id) + .orElseThrow(() -> new NotFoundException("no render '" + id + "' in namespace '" + namespace + "'")); + return HttpResponse.ok(BlueMapRenderResponse.from(render)); + } + + private void requireRead(ApusPrincipal principal) { + if (!TenantAccess.canRead(principal)) { + throw new ForbiddenException("principal '" + principal.subject() + "' has no tenant role"); + } + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderRepository.java new file mode 100644 index 0000000..52c22e0 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderRepository.java @@ -0,0 +1,38 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** + * Read/write access to {@link BlueMapRender} custom resources, always scoped to a single + * namespace. Also used by {@code net.onelitefeather.apus.api.rest.map.BlueMapMapController} to + * create the render {@code POST /api/maps/{id}/render} triggers -- a render is its own resource + * kind (design spec §8.5), so creating one belongs here rather than being duplicated into the + * map package. See {@code TenantRepository}'s Javadoc for why this is an interface. + */ +public interface BlueMapRenderRepository { + + List list(String namespace); + + Optional find(String namespace, String name); + + BlueMapRender create(String namespace, BlueMapRender render); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderResponse.java new file mode 100644 index 0000000..9b9571c --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderResponse.java @@ -0,0 +1,61 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.ConditionResponse; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** + * A {@link BlueMapRender}, as {@code /api/renders} and {@code POST /api/maps/{id}/render} + * expose it. Omits {@code jobName} and {@code bundleUrl}/{@code bundleVersion} -- Kubernetes Job + * names and internal bundle addressing are the operator's bookkeeping, not something a caller + * driving renders through this API needs to see (design plan: response models are their own + * types, not managed CR fields passed through). + */ +@Serdeable +public record BlueMapRenderResponse( + String name, + String mapRef, + boolean force, + String phase, + ProgressResponse progress, + String startTime, + String completionTime, + List conditions) { + + public static BlueMapRenderResponse from(BlueMapRender render) { + var spec = render.getSpec(); + var status = render.getStatus(); + var progress = status.getProgress(); + return new BlueMapRenderResponse( + render.getMetadata().getName(), + spec.getMapRef() == null ? null : spec.getMapRef().getName(), + spec.isForce(), + status.getPhase(), + new ProgressResponse( + progress.getPercent(), progress.getCurrentMap(), progress.getEtaSeconds(), progress.isDegraded()), + status.getStartTime(), + status.getCompletionTime(), + status.getConditions().stream().map(ConditionResponse::from).toList()); + } + + @Serdeable + public record ProgressResponse(double percent, String currentMap, long etaSeconds, boolean degraded) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/render/FabricBlueMapRenderRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/render/FabricBlueMapRenderRepository.java new file mode 100644 index 0000000..edadb67 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/render/FabricBlueMapRenderRepository.java @@ -0,0 +1,51 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** {@link BlueMapRenderRepository} backed by a real {@link KubernetesClient}. */ +@Singleton +public class FabricBlueMapRenderRepository implements BlueMapRenderRepository { + + private final KubernetesClient client; + + public FabricBlueMapRenderRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public List list(String namespace) { + return client.resources(BlueMapRender.class).inNamespace(namespace).list().getItems(); + } + + @Override + public Optional find(String namespace, String name) { + return Optional.ofNullable( + client.resources(BlueMapRender.class).inNamespace(namespace).withName(name).get()); + } + + @Override + public BlueMapRender create(String namespace, BlueMapRender render) { + return client.resources(BlueMapRender.class).inNamespace(namespace).resource(render).create(); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestException.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestException.java new file mode 100644 index 0000000..8ffd878 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestException.java @@ -0,0 +1,34 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +/** + * Thrown for a malformed request body. {@code micronaut-validation} (the usual home for + * {@code @NotBlank}/{@code @Valid}-driven checks) is not on this module's classpath -- task 1's + * report flagged it as deliberately left out (YAGNI, nothing until now needed it) and adding it + * would mean editing {@code api/build.gradle.kts}, which is out of this task's file scope (see + * task-2-brief.md) and, per the same report, a build-file conflict better reported than resolved + * unilaterally while task 3 works in the same module. Request bodies are therefore validated by + * hand in each controller, and this exception is the uniform result. + */ +public class BadRequestException extends RuntimeException { + + public BadRequestException(String message) { + super(message); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestExceptionHandler.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestExceptionHandler.java new file mode 100644 index 0000000..6723066 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/BadRequestExceptionHandler.java @@ -0,0 +1,43 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.inject.Singleton; + +/** Maps {@link BadRequestException} (hand-rolled request validation, see its Javadoc) to HTTP + * 400, with the exception's message surfaced so a caller can see what was wrong with the body. */ +@Produces +@Singleton +@Requires(classes = BadRequestException.class) +public class BadRequestExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, BadRequestException exception) { + return HttpResponse.badRequest(new ErrorBody(exception.getMessage())); + } + + /** Minimal JSON error body -- {@code {"message": "..."}} -- for a failed manual validation. */ + @Serdeable + public record ErrorBody(String message) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/ConditionResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/ConditionResponse.java new file mode 100644 index 0000000..ef1548c --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/ConditionResponse.java @@ -0,0 +1,36 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import io.fabric8.kubernetes.api.model.Condition; +import io.micronaut.serde.annotation.Serdeable; + +/** + * A simplified view of a Kubernetes {@link Condition}, shared by every response type in {@code + * rest/} that surfaces a resource's conditions. Deliberately not {@link Condition} itself -- + * that type carries {@code observedGeneration} and other reconciler bookkeeping nobody outside + * the cluster needs (see task-2-brief.md on response models being their own types). + */ +@Serdeable +public record ConditionResponse(String type, String status, String reason, String message) { + + public static ConditionResponse from(Condition condition) { + return new ConditionResponse( + condition.getType(), condition.getStatus(), condition.getReason(), condition.getMessage()); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/ForbiddenExceptionHandler.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/ForbiddenExceptionHandler.java new file mode 100644 index 0000000..d957ad2 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/ForbiddenExceptionHandler.java @@ -0,0 +1,47 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import jakarta.inject.Singleton; +import net.onelitefeather.apus.api.security.ForbiddenException; + +/** + * Maps {@link ForbiddenException} to HTTP 403. Task 1 built {@code ForbiddenException} (thrown + * by {@code TenantResolver} when a principal carries no tenant claim) but explicitly left this + * mapping undone -- see its report's "Concerns for Task 2 / Task 3": "nothing currently maps it + * ... that mapping logic doesn't exist yet and needs to land wherever the first controller + * does." Controllers in {@code rest/} also throw this exception directly for their own + * insufficient-role checks (see {@code TenantAccess}), so every 403 in this module -- whether + * "no tenant" or "wrong role" -- funnels through here. + */ +@Produces +@Singleton +@Requires(classes = ForbiddenException.class) +public class ForbiddenExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, ForbiddenException exception) { + return HttpResponse.status(HttpStatus.FORBIDDEN); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundException.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundException.java new file mode 100644 index 0000000..67aff55 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundException.java @@ -0,0 +1,34 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +/** + * Thrown when a resource does not exist in the caller's own namespace -- including, critically, + * when it exists but only in a different tenant's namespace. Every repository in {@code rest/} + * looks resources up already scoped to the caller's namespace (via {@code TenantResolver}), so a + * foreign tenant's resource is indistinguishable from one that does not exist anywhere: both + * produce this exception, and both therefore map to the same HTTP 404. That is deliberate -- see + * task-2-brief.md: a 403 here would itself disclose that the resource exists under a different + * tenant, turning the API into a directory of other tenants' resources. + */ +public class NotFoundException extends RuntimeException { + + public NotFoundException(String message) { + super(message); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundExceptionHandler.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundExceptionHandler.java new file mode 100644 index 0000000..f23f741 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/NotFoundExceptionHandler.java @@ -0,0 +1,39 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.Produces; +import io.micronaut.http.server.exceptions.ExceptionHandler; +import jakarta.inject.Singleton; + +/** Maps {@link NotFoundException} to HTTP 404. See that class's Javadoc for why this is the + * uniform outcome for both "does not exist" and "exists in a different tenant". */ +@Produces +@Singleton +@Requires(classes = NotFoundException.class) +public class NotFoundExceptionHandler implements ExceptionHandler> { + + @Override + public HttpResponse handle(HttpRequest request, NotFoundException exception) { + return HttpResponse.status(HttpStatus.NOT_FOUND); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/TenantAccess.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/TenantAccess.java new file mode 100644 index 0000000..74efff6 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/TenantAccess.java @@ -0,0 +1,46 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; + +/** + * Role gates for tenant-scoped endpoints (sources, maps, renders, hostings) that {@link + * ApusPrincipal} itself does not expose. {@link ApusPrincipal#canWrite()} already covers the + * write gate; this class adds the read gate -- "does this caller hold any of the three + * tenant-level roles at all" -- which is deliberately not a method on {@code ApusPrincipal} + * itself (task 1's report and its unchanged signature) so it lives with the callers that need + * it instead. + * + *

A caller with a tenant claim but zero recognised roles (for example a §10.3 service token + * scoped only to {@code world:push}) resolves a namespace fine via {@code TenantResolver} but + * fails both gates here -- by design: a narrow-scope service token must not gain general + * read/write access to the tenant's REST API just because it is tied to a tenant. + */ +public final class TenantAccess { + + private TenantAccess() {} + + /** Whether {@code principal} holds any of the three tenant-level roles (read access). */ + public static boolean canRead(ApusPrincipal principal) { + return principal.roles().contains(Role.TENANT_OWNER) + || principal.roles().contains(Role.TENANT_OPERATOR) + || principal.roles().contains(Role.TENANT_VIEWER); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/CreateTenantRequest.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/CreateTenantRequest.java new file mode 100644 index 0000000..a197177 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/CreateTenantRequest.java @@ -0,0 +1,31 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; + +/** + * Request body for {@code POST /api/tenants}. {@code name} becomes the {@code Tenant}'s + * {@code metadata.name} and therefore, via {@code TenantReconciler}, the tenant slug used + * throughout the platform -- validated by hand in {@code TenantController} since + * {@code micronaut-validation} is not available (see {@code BadRequestException}'s Javadoc). + */ +@Serdeable +public record CreateTenantRequest( + String name, String displayName, String storageQuota, Long maxObjects, List allowedHostingDomains) {} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/FabricTenantRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/FabricTenantRepository.java new file mode 100644 index 0000000..a4fd2da --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/FabricTenantRepository.java @@ -0,0 +1,50 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.Tenant; + +/** {@link TenantRepository} backed by a real {@link KubernetesClient}. */ +@Singleton +public class FabricTenantRepository implements TenantRepository { + + private final KubernetesClient client; + + public FabricTenantRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public List list() { + return client.resources(Tenant.class).list().getItems(); + } + + @Override + public Optional findByName(String name) { + return Optional.ofNullable(client.resources(Tenant.class).withName(name).get()); + } + + @Override + public Tenant create(Tenant tenant) { + return client.resource(tenant).create(); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantController.java new file mode 100644 index 0000000..9f0aefb --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantController.java @@ -0,0 +1,107 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.BadRequestException; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.Tenant; +import net.onelitefeather.apus.operator.api.TenantSpec; + +/** + * {@code GET /api/tenants} and {@code POST /api/tenants} -- platform-level, {@code + * platform-admin} only (design spec §10.3, §11.1). Unlike every other controller in {@code + * rest/}, this one never calls {@code TenantResolver}: {@code Tenant} is cluster-scoped, and a + * platform-admin's reach here is deliberately cluster-wide, not confined to a single namespace + * -- see {@code TenantResolverTest#namespaceForRejectsAPlatformAdminWithoutATenantToo}'s Javadoc + * from task 1, which is exactly the boundary this controller sits on the other side of. + * + *

{@code @Secured(IS_AUTHENTICATED)} only enforces the deny-by-default baseline (no anonymous + * access); the {@code platform-admin} role gate itself is a manual check against {@link + * ApusPrincipal#isPlatformAdmin()} in each method, not a role string on the annotation -- + * Micronaut's {@code @Secured} role matching happens via + * an AOP interceptor that only runs inside a live IoC container, and with no {@code + * micronaut-test-junit5}/HTTP-client dependency on this module's test classpath (see + * task-1-report.md's "Concerns" section), a unit test that instantiates this controller directly + * cannot exercise that interceptor at all. A manual check keeps the "insufficient role -> 403" + * behaviour testable the same way as everything else in this module. + */ +@Controller("/api/tenants") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class TenantController { + + private final TenantRepository repository; + private final PrincipalResolver principalResolver; + + public TenantController(TenantRepository repository, PrincipalResolver principalResolver) { + this.repository = repository; + this.principalResolver = principalResolver; + } + + @Get + public HttpResponse> list(Authentication authentication) { + requirePlatformAdmin(authentication); + List tenants = + repository.list().stream().map(TenantResponse::from).toList(); + return HttpResponse.ok(tenants); + } + + @Post + public HttpResponse create(Authentication authentication, @Body CreateTenantRequest request) { + requirePlatformAdmin(authentication); + if (request.name() == null || request.name().isBlank()) { + throw new BadRequestException("name must not be blank"); + } + + Tenant tenant = new Tenant(); + tenant.getMetadata().setName(request.name()); + TenantSpec spec = tenant.getSpec(); + spec.setDisplayName(request.displayName()); + if (request.storageQuota() != null) { + spec.getStorage().setQuota(request.storageQuota()); + } + if (request.maxObjects() != null) { + spec.getStorage().setMaxObjects(request.maxObjects()); + } + if (request.allowedHostingDomains() != null) { + spec.getHosting().setAllowedDomains(request.allowedHostingDomains()); + } + + Tenant created = repository.create(tenant); + return HttpResponse.created(TenantResponse.from(created)); + } + + private ApusPrincipal requirePlatformAdmin(Authentication authentication) { + ApusPrincipal principal = principalResolver.resolve(authentication); + if (!principal.isPlatformAdmin()) { + throw new ForbiddenException( + "principal '" + principal.subject() + "' is not a platform-admin"); + } + return principal; + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantRepository.java new file mode 100644 index 0000000..ceb55d4 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantRepository.java @@ -0,0 +1,45 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.Tenant; + +/** + * Read/write access to {@link Tenant} custom resources. {@code Tenant} is cluster-scoped (design + * spec §8.1), so unlike every other repository in {@code rest/} this one carries no namespace + * parameter -- there is deliberately no per-tenant filtering here, because {@link + * net.onelitefeather.apus.api.rest.tenant.TenantController} only reaches this repository once it + * has already confirmed the caller is a {@code platform-admin} with cluster-wide reach (design + * spec §10.3). + * + *

An interface, not a concrete fabric8-backed class directly, so controller tests can supply + * an in-memory fake instead of needing a live or mocked Kubernetes API server -- neither + * {@code kubernetes-server-mock} nor {@code micronaut-test-junit5} is on this module's test + * classpath (see task-1-report.md's "Concerns" section on the missing dependencies this task + * would otherwise need). + */ +public interface TenantRepository { + + List list(); + + Optional findByName(String name); + + Tenant create(Tenant tenant); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java new file mode 100644 index 0000000..1ab3e19 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/TenantResponse.java @@ -0,0 +1,59 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.ConditionResponse; +import net.onelitefeather.apus.operator.api.Tenant; + +/** + * A {@link Tenant}, as the {@code platform-admin}-only {@code /api/tenants} endpoints expose it. + * Its own type, not the custom resource itself -- {@code Tenant} carries a finalizer, + * {@code resourceVersion}, and other managed fields that are the operator's business, not an + * API consumer's, and would change shape with every CRD revision if reused directly here. + */ +@Serdeable +public record TenantResponse( + String name, + String displayName, + StorageResponse storage, + List allowedHostingDomains, + String namespace, + String objectStoreUser, + Long storageUsedBytes, + List conditions) { + + public static TenantResponse from(Tenant tenant) { + var spec = tenant.getSpec(); + var status = tenant.getStatus(); + return new TenantResponse( + tenant.getMetadata().getName(), + spec.getDisplayName(), + new StorageResponse(spec.getStorage().getQuota(), spec.getStorage().getMaxObjects()), + List.copyOf(spec.getHosting().getAllowedDomains()), + status.getNamespace(), + status.getObjectStoreUser(), + status.getStorageUsedBytes(), + status.getConditions().stream().map(ConditionResponse::from).toList()); + } + + /** The tenant's storage quota -- never {@code storageUsedBytes}' Ceph credentials. */ + @Serdeable + public record StorageResponse(String quota, Long maxObjects) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/CreateWorldSourceRequest.java b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/CreateWorldSourceRequest.java new file mode 100644 index 0000000..32ea104 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/CreateWorldSourceRequest.java @@ -0,0 +1,50 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; + +/** + * Request body for {@code POST /api/sources}. Unlike {@link WorldSourceResponse}, this request + * *does* carry {@code credentialsSecretName} for the S3/Pterodactyl connection types -- the + * caller is naming a Secret they already created in their own namespace, not something this API + * discloses back to them (the no-secret-names rule in task-2-brief.md is about responses). + * {@code credentialsSecretName} becomes a {@code Ref} in the caller's own namespace only, + * exactly like every other reference in this data model (design spec §10.1). + */ +@Serdeable +public record CreateWorldSourceRequest( + String name, + String type, + S3Request s3, + PterodactylRequest pterodactyl, + String poll, + List worlds, + Integer keepVersions) { + + @Serdeable + public record S3Request(String endpoint, String bucket, String prefix, String credentialsSecretName) {} + + @Serdeable + public record PterodactylRequest( + String panelUrl, String serverId, String credentialsSecretName, String select) {} + + @Serdeable + public record WorldSelectorRequest(String name, String layout, String minecraftVersion) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/FabricWorldSourceRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/FabricWorldSourceRepository.java new file mode 100644 index 0000000..aa8485e --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/FabricWorldSourceRepository.java @@ -0,0 +1,51 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.WorldSource; + +/** {@link WorldSourceRepository} backed by a real {@link KubernetesClient}. */ +@Singleton +public class FabricWorldSourceRepository implements WorldSourceRepository { + + private final KubernetesClient client; + + public FabricWorldSourceRepository(KubernetesClient client) { + this.client = client; + } + + @Override + public List list(String namespace) { + return client.resources(WorldSource.class).inNamespace(namespace).list().getItems(); + } + + @Override + public Optional find(String namespace, String name) { + return Optional.ofNullable( + client.resources(WorldSource.class).inNamespace(namespace).withName(name).get()); + } + + @Override + public WorldSource create(String namespace, WorldSource source) { + return client.resources(WorldSource.class).inNamespace(namespace).resource(source).create(); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceController.java b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceController.java new file mode 100644 index 0000000..dfad720 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceController.java @@ -0,0 +1,152 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import io.micronaut.http.HttpResponse; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.Post; +import io.micronaut.security.annotation.Secured; +import io.micronaut.security.authentication.Authentication; +import io.micronaut.security.rules.SecurityRule; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import net.onelitefeather.apus.api.rest.support.BadRequestException; +import net.onelitefeather.apus.api.rest.support.TenantAccess; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.Ref; +import net.onelitefeather.apus.operator.api.WorldSource; +import net.onelitefeather.apus.operator.api.WorldSourceSpec; + +/** + * {@code GET /api/sources} and {@code POST /api/sources} -- the caller's own tenant only (design + * spec §10.3, §11.1). The namespace always comes from {@link TenantResolver}, never from a + * request parameter -- see task-2-brief.md's central rule. {@code list} requires any of the + * three tenant roles; {@code create} requires {@link ApusPrincipal#canWrite()} (owner/operator). + * + *

See {@code TenantController}'s Javadoc for why the role gates below are manual checks + * throwing {@link ForbiddenException} rather than {@code @Secured} role strings. + */ +@Controller("/api/sources") +@Secured(SecurityRule.IS_AUTHENTICATED) +public class WorldSourceController { + + private static final Set VALID_TYPES = Set.of("s3", "pterodactyl", "upload", "push"); + + private final WorldSourceRepository repository; + private final PrincipalResolver principalResolver; + private final TenantResolver tenantResolver; + + public WorldSourceController( + WorldSourceRepository repository, PrincipalResolver principalResolver, TenantResolver tenantResolver) { + this.repository = repository; + this.principalResolver = principalResolver; + this.tenantResolver = tenantResolver; + } + + @Get + public HttpResponse> list(Authentication authentication) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireRead(principal); + String namespace = tenantResolver.namespaceFor(principal); + + List sources = repository.list(namespace).stream() + .map(WorldSourceResponse::from) + .toList(); + return HttpResponse.ok(sources); + } + + @Post + public HttpResponse create( + Authentication authentication, @Body CreateWorldSourceRequest request) { + ApusPrincipal principal = principalResolver.resolve(authentication); + requireWrite(principal); + String namespace = tenantResolver.namespaceFor(principal); + + if (request.name() == null || request.name().isBlank()) { + throw new BadRequestException("name must not be blank"); + } + if (request.type() == null || !VALID_TYPES.contains(request.type())) { + throw new BadRequestException("type must be one of " + VALID_TYPES); + } + + WorldSource source = new WorldSource(); + source.getMetadata().setName(request.name()); + WorldSourceSpec spec = source.getSpec(); + spec.setType(request.type()); + spec.setPoll(request.poll()); + if (request.keepVersions() != null) { + spec.getRetention().setKeepVersions(request.keepVersions()); + } + if (request.s3() != null) { + spec.getS3().setEndpoint(request.s3().endpoint()); + spec.getS3().setBucket(request.s3().bucket()); + spec.getS3().setPrefix(request.s3().prefix()); + if (request.s3().credentialsSecretName() != null) { + Ref ref = new Ref(); + ref.setName(request.s3().credentialsSecretName()); + spec.getS3().setCredentialsSecretRef(ref); + } + } + if (request.pterodactyl() != null) { + spec.getPterodactyl().setPanelUrl(request.pterodactyl().panelUrl()); + spec.getPterodactyl().setServerId(request.pterodactyl().serverId()); + if (request.pterodactyl().select() != null) { + spec.getPterodactyl().setSelect(request.pterodactyl().select()); + } + if (request.pterodactyl().credentialsSecretName() != null) { + Ref ref = new Ref(); + ref.setName(request.pterodactyl().credentialsSecretName()); + spec.getPterodactyl().setCredentialsSecretRef(ref); + } + } + if (request.worlds() != null) { + List worlds = new ArrayList<>(); + for (var w : request.worlds()) { + WorldSource.WorldSelector selector = new WorldSource.WorldSelector(); + selector.setName(w.name()); + if (w.layout() != null) { + selector.setLayout(w.layout()); + } + selector.setMinecraftVersion(w.minecraftVersion()); + worlds.add(selector); + } + spec.setWorlds(worlds); + } + + WorldSource created = repository.create(namespace, source); + return HttpResponse.created(WorldSourceResponse.from(created)); + } + + private void requireRead(ApusPrincipal principal) { + if (!TenantAccess.canRead(principal)) { + throw new ForbiddenException("principal '" + principal.subject() + "' has no tenant role"); + } + } + + private void requireWrite(ApusPrincipal principal) { + if (!principal.canWrite()) { + throw new ForbiddenException("principal '" + principal.subject() + "' is not tenant-owner/tenant-operator"); + } + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceRepository.java b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceRepository.java new file mode 100644 index 0000000..08762f9 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceRepository.java @@ -0,0 +1,37 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.WorldSource; + +/** + * Read/write access to {@link WorldSource} custom resources, always scoped to a single + * namespace -- the caller never gets to pick which one (see {@code TenantResolver}). An + * interface so controller tests can supply an in-memory fake; see {@link + * net.onelitefeather.apus.api.rest.tenant.TenantRepository}'s Javadoc for why. + */ +public interface WorldSourceRepository { + + List list(String namespace); + + Optional find(String namespace, String name); + + WorldSource create(String namespace, WorldSource source); +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceResponse.java b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceResponse.java new file mode 100644 index 0000000..306fbb8 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceResponse.java @@ -0,0 +1,71 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import io.micronaut.serde.annotation.Serdeable; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.ConditionResponse; +import net.onelitefeather.apus.operator.api.WorldSource; + +/** + * A {@link WorldSource}, as {@code /api/sources} exposes it. Deliberately omits {@code + * s3.credentialsSecretRef}/{@code pterodactyl.credentialsSecretRef} entirely -- those are Secret + * *names*, and the brief is explicit that no response may carry one, even though a name alone is + * not a credential's value (see task-2-brief.md / the design plan's tenant-isolation section). + */ +@Serdeable +public record WorldSourceResponse( + String name, + String type, + String poll, + List worlds, + int keepVersions, + String lastSeenVersion, + BundleResponse latestBundle, + String lastPollTime, + List conditions) { + + public static WorldSourceResponse from(WorldSource source) { + var spec = source.getSpec(); + var status = source.getStatus(); + List worlds = spec.getWorlds().stream() + .map(w -> new WorldSelectorResponse(w.getName(), w.getLayout(), w.getMinecraftVersion())) + .toList(); + BundleResponse latestBundle = status.getLatestBundle() == null + ? null + : new BundleResponse( + status.getLatestBundle().getPath(), status.getLatestBundle().getVersion()); + return new WorldSourceResponse( + source.getMetadata().getName(), + spec.getType(), + spec.getPoll(), + worlds, + spec.getRetention().getKeepVersions(), + status.getLastSeenVersion(), + latestBundle, + status.getLastPollTime(), + status.getConditions().stream().map(ConditionResponse::from).toList()); + } + + @Serdeable + public record WorldSelectorResponse(String name, String layout, String minecraftVersion) {} + + /** Which bundle version this source last produced -- path and version only. */ + @Serdeable + public record BundleResponse(String path, String version) {} +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java b/api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java new file mode 100644 index 0000000..44acb15 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java @@ -0,0 +1,65 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import java.util.Objects; +import java.util.Set; + +/** + * Who is calling, derived solely from the validated token -- never from anything the caller + * supplies in a request. This is the only source {@link TenantResolver} may read a tenant from. + * + *

{@code tenant} is the organisation claim from the token (design spec §10.3) and may be + * {@code null}: a {@code platform-admin} is not necessarily a member of any tenant. It is + * deliberately never defaulted to a fallback value here or anywhere downstream -- a caller + * without a tenant is a caller {@link TenantResolver} refuses to resolve a namespace for, not + * one that silently lands in some default namespace. + * + * @param subject the token subject, i.e. who authenticated, for logging/auditing + * @param tenant the organisation claim, or {@code null} if the token carries none + * @param roles the roles granted to this caller; never {@code null}, may be empty + */ +public record ApusPrincipal(String subject, String tenant, Set roles) { + + public ApusPrincipal { + Objects.requireNonNull(subject, "subject must not be null"); + Objects.requireNonNull(roles, "roles must not be null"); + // Defensive copy: an immutable snapshot, so a caller mutating the Set they passed in + // (or one this record hands back via roles()) can never retroactively change what this + // principal was authorized with. + roles = Set.copyOf(roles); + if (tenant != null && tenant.isBlank()) { + tenant = null; + } + } + + /** Whether this caller holds the platform-wide {@link Role#PLATFORM_ADMIN} role. */ + public boolean isPlatformAdmin() { + return roles.contains(Role.PLATFORM_ADMIN); + } + + /** + * Whether this caller may write within its own tenant -- {@link Role#TENANT_OWNER} or + * {@link Role#TENANT_OPERATOR}. Deliberately excludes {@link Role#PLATFORM_ADMIN}: that + * role's write access is to platform-level resources (tenants, quotas), not to a tenant's + * sources/maps/renders, and excludes {@link Role#TENANT_VIEWER} by definition. + */ + public boolean canWrite() { + return roles.contains(Role.TENANT_OWNER) || roles.contains(Role.TENANT_OPERATOR); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/ForbiddenException.java b/api/src/main/java/net/onelitefeather/apus/api/security/ForbiddenException.java new file mode 100644 index 0000000..05cdf9a --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/security/ForbiddenException.java @@ -0,0 +1,36 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +/** + * Thrown when a caller is authenticated but not authorized for what they asked to do -- most + * centrally, by {@link TenantResolver} when a principal has no tenant to resolve a namespace + * for. Deliberately unchecked: every call site up to the eventual HTTP boundary treats this the + * same way, so forcing it into every intermediate method signature would add noise without + * adding safety. + * + *

Mapping this to an HTTP status (403, or 404 where revealing "forbidden" would itself leak + * that a foreign tenant's resource exists -- see design plan §"Fehler geben keine Auskunft") is + * the responsibility of the REST layer that consumes this module, not of this exception itself. + */ +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/Role.java b/api/src/main/java/net/onelitefeather/apus/api/security/Role.java new file mode 100644 index 0000000..cb45232 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/security/Role.java @@ -0,0 +1,69 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import java.util.Locale; +import java.util.Optional; + +/** + * The four roles defined by design spec §10.3. There is no fifth, implicit "no role" default: + * a token that carries none of these grants no permission at all. + * + * + * + * + * + * + * + * + * + *
Role capabilities, from §10.3
RoleMay
{@link #PLATFORM_ADMIN}create/change/delete tenants, quotas, cluster-wide view
{@link #TENANT_OWNER}everything in its own tenant, including members
{@link #TENANT_OPERATOR}maintain sources and maps, trigger renders
{@link #TENANT_VIEWER}read only
+ */ +public enum Role { + PLATFORM_ADMIN, + TENANT_OWNER, + TENANT_OPERATOR, + TENANT_VIEWER; + + /** + * Parses a role claim value as it appears in a token (kebab-case, e.g. {@code + * "platform-admin"}) into a {@link Role}. Unknown values -- a role the identity broker + * knows about but Apus does not (yet) -- resolve to {@link Optional#empty()} rather than + * throwing, so that one unrecognised entry in a roles claim does not reject the whole + * token; the caller decides whether to ignore it or reject the request. + * + * @param claim the raw role claim value, e.g. {@code "tenant-operator"} + * @return the matching role, or empty if {@code claim} does not name one of the four roles + */ + public static Optional fromClaim(String claim) { + if (claim == null || claim.isBlank()) { + return Optional.empty(); + } + // Exact match against the four spec §10.3 names only (case-insensitive, trimmed) -- no + // separator tolerance (e.g. "platform_admin"), so a near-miss spelling fails closed as + // "no role" instead of being guessed at. + String normalized = claim.trim().toLowerCase(Locale.ROOT); + return switch (normalized) { + case "platform-admin" -> Optional.of(PLATFORM_ADMIN); + case "tenant-owner" -> Optional.of(TENANT_OWNER); + case "tenant-operator" -> Optional.of(TENANT_OPERATOR); + case "tenant-viewer" -> Optional.of(TENANT_VIEWER); + default -> Optional.empty(); + }; + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/security/TenantResolver.java b/api/src/main/java/net/onelitefeather/apus/api/security/TenantResolver.java new file mode 100644 index 0000000..c127556 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/security/TenantResolver.java @@ -0,0 +1,63 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import jakarta.inject.Singleton; +import java.util.Objects; + +/** + * Maps a caller to the single namespace it may act in. This is the only place in the {@code + * api} module allowed to turn a tenant name into a namespace, and the namespace always comes + * from {@link ApusPrincipal#tenant()} -- never from a request path, query parameter, or request + * body. {@link #namespaceFor(ApusPrincipal)} is deliberately the only public method this class + * has: there is no overload that accepts a namespace, a tenant name, or any other value a + * caller could supply, because any such parameter would be exactly the cross-tenant hole design + * spec §10.3 exists to close (see the class Javadoc on why this matters and + * TenantResolverTest#namespaceForHasExactlyOnePublicMethod, which fails the build the moment a + * second entry point is added). + * + *

The naming convention ({@code "bluemap-" + tenant}) mirrors {@code + * net.onelitefeather.apus.operator.tenant.TenantReconciler#namespaceFor(Tenant)} exactly -- + * TenantResolverTest asserts the two never drift apart by calling the reconciler's own method, + * rather than importing it into production code here. The reconciler class itself is not a + * dependency of this class: it implements JOSDK's {@code Reconciler}, and pulling that + * interface's dependency chain into a REST/SSE API module (which does not reconcile anything) + * for the sake of one static method would be the wrong trade. + */ +@Singleton +public final class TenantResolver { + + /** Must match {@code TenantReconciler.namespaceFor}'s prefix -- see the class Javadoc. */ + private static final String NAMESPACE_PREFIX = "bluemap-"; + + /** + * @param principal the caller, taken from the validated token and nothing else + * @return the namespace {@code principal} may act in + * @throws ForbiddenException when {@code principal} has no tenant -- there is no default + * tenant a token without one falls back to + */ + public String namespaceFor(ApusPrincipal principal) { + Objects.requireNonNull(principal, "principal must not be null"); + String tenant = principal.tenant(); + if (tenant == null) { + throw new ForbiddenException( + "principal '" + principal.subject() + "' carries no tenant claim; there is no default tenant"); + } + return NAMESPACE_PREFIX + tenant; + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/support/KubernetesClientFactory.java b/api/src/main/java/net/onelitefeather/apus/api/support/KubernetesClientFactory.java new file mode 100644 index 0000000..c9d3205 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/support/KubernetesClientFactory.java @@ -0,0 +1,51 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.support; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.micronaut.context.annotation.Factory; +import jakarta.inject.Singleton; + +/** + * The single {@link KubernetesClient} bean for this module, shared by every repository under + * both {@code rest/} and {@code events/}. + * + *

Phase 5a consolidation: task 2 ({@code rest/}) and task 3 ({@code events/}) were + * built in parallel worktrees against the same module, neither with a build file it was allowed + * to touch to declare a shared factory through. Each landed its own client wiring to avoid an + * ambiguous-bean collision at merge time: task 2 behind a {@code RestKubernetesClient} wrapper + * bean (since an unqualified second {@code @Singleton KubernetesClient} factory would have made + * every unqualified injection point ambiguous), task 3 as its own {@code events}-local {@code + * @Factory}. Both said as much in their own Javadoc/report as the documented follow-up. This + * class is that follow-up: the one place either package injects {@link KubernetesClient} from, + * now that a single factory can live outside both. + * + *

Picks up ambient in-cluster or kubeconfig configuration the same way {@code + * io.javaoperatorsdk} does for {@code :operator} -- {@link KubernetesClientBuilder#build()} with + * no explicit config, since design spec §10.3 has the backend authenticate to the Kubernetes API + * with its own ServiceAccount, never impersonation. + */ +@Factory +public class KubernetesClientFactory { + + @Singleton + public KubernetesClient kubernetesClient() { + return new KubernetesClientBuilder().build(); + } +} diff --git a/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java new file mode 100644 index 0000000..4829e99 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java @@ -0,0 +1,80 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.support; + +import io.micronaut.security.authentication.Authentication; +import jakarta.inject.Singleton; +import java.util.LinkedHashSet; +import java.util.Objects; +import java.util.Set; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; + +/** + * Bridges Micronaut Security's validated {@link Authentication} to this module's own {@link + * ApusPrincipal}. Task 1 (see its report, "Concerns for Task 2 / Task 3") deliberately left this + * bridge unbuilt: it depends on details -- which claim carries the tenant, whether roles arrive + * as a flat list or something richer -- that are downstream of picking an identity broker + * (design spec §15), which had not happened yet. That is also why this bridge is not part of + * the {@code security} package alongside {@link ApusPrincipal}/{@link + * net.onelitefeather.apus.api.security.TenantResolver}: task 1's scope there was deliberately + * only the pure security-invariant classes, not the Micronaut Security-specific translation. + * + *

Phase 5a consolidation: task 2 ({@code rest/}) and task 3 ({@code events/}) each + * built this exact bridge independently in their own parallel worktree -- {@code + * rest.support.PrincipalResolver} and {@code events.PrincipalMapper} -- and picked two + * different tenant claim names ({@code "org"} vs. {@code "organization"}). That + * divergence was the dangerous half of the duplication: every controller under {@code rest/} + * and the SSE endpoints under {@code events/} would have resolved the very same token's tenant + * differently depending on which package happened to handle the request. This class merges both + * into the one place every controller and SSE endpoint in this module now goes through. + * + *

Tenant claim key: {@code organization}. Not fixed anywhere else yet at the time of + * writing (identity broker undecided, design spec §15) -- picked to match the vocabulary the + * design spec itself already uses for this exact concept: {@code Tenant.spec.auth.organization} + * (§8.1's example manifest) and "der Organisations-Claim im Token bestimmt den Mandanten" + * (§10.3). This is the single place that constant is declared; nowhere else in this module may + * duplicate the literal. + */ +@Singleton +public class PrincipalResolver { + + /** See the class Javadoc for why this specific claim name. */ + public static final String TENANT_CLAIM = "organization"; + + /** + * @param authentication the token-derived authentication Micronaut Security already + * validated (signature, issuer) before this method ever sees it + * @return the equivalent {@link ApusPrincipal}, with unrecognised role claims silently + * dropped (see {@link Role#fromClaim(String)}) and a missing/non-string tenant claim + * mapped to {@code null} -- never to a default tenant + */ + public ApusPrincipal resolve(Authentication authentication) { + Objects.requireNonNull(authentication, "authentication must not be null"); + + Set roles = new LinkedHashSet<>(); + for (String rawRole : authentication.getRoles()) { + Role.fromClaim(rawRole).ifPresent(roles::add); + } + + Object tenantClaim = authentication.getAttributes().get(TENANT_CLAIM); + String tenant = tenantClaim instanceof String value ? value : null; + + return new ApusPrincipal(authentication.getName(), tenant, roles); + } +} diff --git a/api/src/main/resources/application.yml b/api/src/main/resources/application.yml new file mode 100644 index 0000000..bf7f005 --- /dev/null +++ b/api/src/main/resources/application.yml @@ -0,0 +1,18 @@ +# Which identity broker sits in front of Apus is intentionally undecided (design spec §15; +# Keycloak 26+ and Zitadel are both under evaluation, both expose standard OIDC discovery). Only +# the JWKS endpoint and the expected issuer are configured -- both from environment, with no +# broker-specific default -- so picking one later is a config change, not a code change. +micronaut: + application: + name: apus-api + security: + enabled: true + token: + jwt: + enabled: true + signatures: + jwks: + apus-issuer: + jwks-uri: ${APUS_JWT_JWKS_URI} + claims-validators: + issuer: ${APUS_JWT_ISSUER} diff --git a/api/src/test/java/net/onelitefeather/apus/api/TenantIsolationIntegrationTest.java b/api/src/test/java/net/onelitefeather/apus/api/TenantIsolationIntegrationTest.java new file mode 100644 index 0000000..e5ec9be --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/TenantIsolationIntegrationTest.java @@ -0,0 +1,289 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.Config; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.micronaut.core.type.Argument; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.security.token.generator.TokenGenerator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import io.micronaut.test.support.TestPropertyProvider; +import jakarta.inject.Inject; +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.rest.map.BlueMapMapResponse; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.OperatorConfig; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import net.onelitefeather.apus.operator.api.BlueMapRender; +import net.onelitefeather.apus.operator.api.Tenant; +import net.onelitefeather.apus.operator.tenant.TenantReconciler; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.testcontainers.k3s.K3sContainer; +import org.testcontainers.utility.DockerImageName; + +/** + * Phase 5a's actual proof: two tenants' resources on a real Kubernetes API server (k3s, started + * via Testcontainers, following the exact pattern {@code operator}'s {@code + * OperatorIntegrationTest}/{@code BlueMapHostingIntegrationTest} already established), and a + * real, JWT-signed token for tenant {@code acme} proven unable to either see or modify + * tenant {@code globex}'s {@code BlueMapMap} -- over the real embedded HTTP server, the real + * security filter chain, and the real {@code Fabric8*Repository} implementations, none of them + * replaced with a fake (compare {@code BlueMapMapControllerHttpTest}, which replaces the + * repository precisely because it does *not* need a real cluster). This is the one test in the + * module where "the tenant isolation holds" is checked against the actual thing it depends on -- + * the Kubernetes API server enforcing namespace boundaries -- rather than against an in-memory + * stand-in of it. + * + *

Runs under the {@code k3s} Micronaut environment (see {@code + * net.onelitefeather.apus.api.support.K3sTestKubernetesClientFactory}), which is what points + * every repository's {@link KubernetesClient} bean at this test's container instead of + * ambient/in-cluster config. {@link #getProperties()} starts the container and applies the + * generated CRDs before the Micronaut context (and with it, that factory) is built -- + * the same ordering guarantee {@link TestPropertyProvider} exists to give. + * + *

Not part of {@code build}/{@code check}: see the {@code integrationTest} Gradle task in + * {@code api/build.gradle.kts}, matched by this class's {@code *IntegrationTest} name the same + * way {@code operator}'s and {@code ingest}'s own {@code integrationTest} tasks match theirs. + */ +@MicronautTest(environments = "k3s") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class TenantIsolationIntegrationTest implements TestPropertyProvider { + + private static final Duration CRD_REGISTRATION_TIMEOUT = Duration.ofMinutes(2); + private static final K3sContainer K3S = new K3sContainer(DockerImageName.parse("rancher/k3s:v1.31.2-k3s1")); + + private static KubernetesClient verificationClient; + private static String acmeNamespace; + private static String globexNamespace; + + @Override + public Map getProperties() { + K3S.start(); + String kubeconfigYaml = K3S.getKubeConfigYaml(); + Config config = Config.fromKubeconfig(kubeconfigYaml); + verificationClient = new KubernetesClientBuilder().withConfig(config).build(); + + applyGeneratedCrds(verificationClient); + awaitCrdRegistration(verificationClient, "tenants.bluemap.onelitefeather.net"); + awaitCrdRegistration(verificationClient, "bluemapmaps.bluemap.onelitefeather.net"); + awaitCrdRegistration(verificationClient, "bluemaprenders.bluemap.onelitefeather.net"); + + Tenant acme = createReconciledTenant("acme"); + Tenant globex = createReconciledTenant("globex"); + acmeNamespace = TenantReconciler.namespaceFor(acme); + globexNamespace = TenantReconciler.namespaceFor(globex); + + createMap(globexNamespace, "globex-only-map"); + createMap(acmeNamespace, "acme-own-map"); + + return Map.of("apus.test.k3s.kubeconfig", kubeconfigYaml); + } + + @AfterAll + static void closeVerificationClient() { + if (verificationClient != null) { + verificationClient.close(); + } + } + + @Inject + @Client("/") + HttpClient client; + + @Inject + TokenGenerator tokenGenerator; + + // -- "weder sehen ..." (cannot see) ----------------------------------------------------- + + @Test + void tokenForTenantACannotGetTenantBsMapById() { + String tokenA = token("carol", List.of("tenant-viewer"), "acme"); + + HttpClientResponseException e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking() + .exchange(HttpRequest.GET("/api/maps/globex-only-map").bearerAuth(tokenA))); + + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } + + @Test + void tokenForTenantACannotSeeTenantBsMapInTheListEndpointEither() { + String tokenA = token("carol", List.of("tenant-viewer"), "acme"); + + List maps = client.toBlocking() + .exchange(HttpRequest.GET("/api/maps").bearerAuth(tokenA), Argument.listOf(BlueMapMapResponse.class)) + .body(); + + assertEquals(List.of("acme-own-map"), maps.stream().map(BlueMapMapResponse::name).toList()); + } + + // -- "... noch ändern" (cannot modify) -------------------------------------------------- + + @Test + void tokenForTenantACannotTriggerARenderForTenantBsMap() { + String tokenA = token("dave", List.of("tenant-operator"), "acme"); + + HttpClientResponseException e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking() + .exchange(HttpRequest.POST("/api/maps/globex-only-map/render", null) + .bearerAuth(tokenA))); + + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + assertTrue( + verificationClient + .resources(BlueMapRender.class) + .inNamespace(globexNamespace) + .list() + .getItems() + .isEmpty(), + "no BlueMapRender may be created in a foreign tenant's namespace, even after a rejected attempt"); + } + + // -- Sanity check: the same mechanism does not also block the caller's own tenant ------- + + @Test + void tokenForTenantACanSeeAndModifyItsOwnMap() { + String tokenA = token("dave", List.of("tenant-operator"), "acme"); + + var getResponse = client.toBlocking() + .exchange(HttpRequest.GET("/api/maps/acme-own-map").bearerAuth(tokenA), BlueMapMapResponse.class); + assertEquals(HttpStatus.OK, getResponse.getStatus()); + + var renderResponse = client.toBlocking() + .exchange( + HttpRequest.POST("/api/maps/acme-own-map/render", null).bearerAuth(tokenA), + net.onelitefeather.apus.api.rest.render.BlueMapRenderResponse.class); + assertEquals(HttpStatus.CREATED, renderResponse.getStatus()); + assertTrue(verificationClient + .resources(BlueMapRender.class) + .inNamespace(acmeNamespace) + .list() + .getItems() + .stream() + .anyMatch(r -> "acme-own-map".equals( + r.getSpec().getMapRef().getName()))); + } + + // -- Fixtures ----------------------------------------------------------------------------- + + private static Tenant createReconciledTenant(String name) { + Tenant tenant = new Tenant(); + tenant.setMetadata(new ObjectMetaBuilder().withName(name).build()); + tenant.getSpec().setDisplayName(name); + tenant.getSpec().getStorage().setQuota("10Gi"); + Tenant created = + verificationClient.resources(Tenant.class).resource(tenant).create(); + + new TenantReconciler(verificationClient, OperatorConfig.defaults()).reconcile(created, null); + return created; + } + + private static void createMap(String namespace, String name) { + BlueMapMap map = new BlueMapMap(); + map.setMetadata( + new ObjectMetaBuilder().withName(name).withNamespace(namespace).build()); + map.getSpec().getSource().setDimension("minecraft:overworld"); + map.getSpec().getBluemap().setMinecraftVersion("1.21.10"); + BlueMapMap created = verificationClient + .resources(BlueMapMap.class) + .inNamespace(namespace) + .resource(map) + .create(); + + created.getStatus().getBucket().setName(name + "-bucket"); + created.getStatus().getBucket().setSecretName(name + "-secret"); + created.getStatus().getBucket().setEndpoint("http://rgw.example.svc:80"); + verificationClient + .resources(BlueMapMap.class) + .inNamespace(namespace) + .resource(created) + .updateStatus(); + } + + private String token(String subject, List roles, String tenant) { + Map claims = new HashMap<>(); + claims.put("sub", subject); + claims.put("roles", roles); + claims.put(PrincipalResolver.TENANT_CLAIM, tenant); + claims.put("iss", "https://apus-test-issuer.internal"); + return tokenGenerator + .generateToken(claims) + .orElseThrow(() -> new IllegalStateException("test token generation failed")); + } + + // -- CRD apply/await, mirroring operator's K3sCrdSupport (no cross-module test-fixture + // wiring exists yet to share it directly -- see api/build.gradle.kts's integrationTest task + // for how this module still reuses :operator's *generated CRD manifests* via apus.crd.dir). - + + private static void applyGeneratedCrds(KubernetesClient client) { + Path crdDir = Path.of(System.getProperty("apus.crd.dir", "build/crds")); + try (var files = Files.list(crdDir)) { + files.filter(path -> path.toString().endsWith(".yml") || path.toString().endsWith(".yaml")) + .forEach(path -> { + try (InputStream in = Files.newInputStream(path)) { + client.load(in).serverSideApply(); + } catch (IOException e) { + throw new UncheckedIOException("failed to apply CRD manifest " + path, e); + } + }); + } catch (IOException e) { + throw new UncheckedIOException("failed to list CRD manifests in " + crdDir, e); + } + } + + private static void awaitCrdRegistration(KubernetesClient client, String crdName) { + long deadline = System.currentTimeMillis() + CRD_REGISTRATION_TIMEOUT.toMillis(); + boolean known = false; + while (System.currentTimeMillis() < deadline && !known) { + known = client.apiextensions().v1().customResourceDefinitions().list().getItems().stream() + .anyMatch(crd -> crdName.equals(crd.getMetadata().getName())); + if (!known) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + } + assertTrue(known, crdName + " CRD must be registered on the API server"); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/LogSourceFactoryTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/LogSourceFactoryTest.java new file mode 100644 index 0000000..eb171b8 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/LogSourceFactoryTest.java @@ -0,0 +1,59 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; + +import org.junit.jupiter.api.Test; + +/** + * The Loki-vs-Kubernetes-client decision itself (task 3 report has the full reasoning): presence + * of a configured Loki URL picks {@link LokiLogSource}; its absence falls back to {@link + * KubernetesPodLogSource}. Neither implementation is exercised here against a live backend -- + * only which one gets chosen. + */ +class LogSourceFactoryTest { + + @Test + void picksLokiWhenAUrlIsConfigured() { + LogSource logSource = LogSourceFactory.select("http://loki.observability.svc:3100", null); + + assertInstanceOf(LokiLogSource.class, logSource); + } + + @Test + void fallsBackToTheKubernetesClientWhenNoUrlIsConfigured() { + LogSource logSource = LogSourceFactory.select("", null); + + assertInstanceOf(KubernetesPodLogSource.class, logSource); + } + + @Test + void fallsBackToTheKubernetesClientWhenTheUrlIsNull() { + LogSource logSource = LogSourceFactory.select(null, null); + + assertInstanceOf(KubernetesPodLogSource.class, logSource); + } + + @Test + void fallsBackToTheKubernetesClientWhenTheUrlIsBlank() { + LogSource logSource = LogSourceFactory.select(" ", null); + + assertInstanceOf(KubernetesPodLogSource.class, logSource); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/LokiLogSourceTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/LokiLogSourceTest.java new file mode 100644 index 0000000..f42b464 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/LokiLogSourceTest.java @@ -0,0 +1,80 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +/** + * Unit-tests {@link LokiLogSource#parseStreams(String)} against a canned response body -- the + * only part of {@link LokiLogSource} that does not need a live Loki instance to exercise, and the + * part most likely to have an off-by-one/ordering bug (merging and sorting several concurrent + * streams by timestamp). + */ +class LokiLogSourceTest { + + @Test + void parsesAndOrdersLinesAcrossMultipleStreamsByTimestamp() throws Exception { + // Two streams (e.g. two containers/pods), values deliberately out of chronological order + // relative to each other -- the merge must still produce one globally time-ordered list. + String json = + """ + { + "status": "success", + "data": { + "resultType": "streams", + "result": [ + { + "stream": {"pod": "render-abc-1"}, + "values": [ + ["100", "first"], + ["300", "third"] + ] + }, + { + "stream": {"pod": "render-abc-1"}, + "values": [ + ["200", "second"] + ] + } + ] + } + } + """; + + List lines = LokiLogSource.parseStreams(json); + + assertEquals( + List.of("first", "second", "third"), + lines.stream().map(LokiLogSource.LogLine::text).toList()); + assertEquals(List.of(100L, 200L, 300L), lines.stream().map(LokiLogSource.LogLine::timestampNanos).toList()); + } + + @Test + void emptyResultProducesNoLines() throws Exception { + String json = + """ + {"status": "success", "data": {"resultType": "streams", "result": []}} + """; + + assertTrue(LokiLogSource.parseStreams(json).isEmpty()); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/RenderPhasesTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/RenderPhasesTest.java new file mode 100644 index 0000000..bad6097 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/RenderPhasesTest.java @@ -0,0 +1,54 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RenderPhasesTest { + + @Test + void succeededIsTerminal() { + assertTrue(RenderPhases.isTerminal("Succeeded")); + } + + @Test + void failedIsTerminal() { + assertTrue(RenderPhases.isTerminal("Failed")); + } + + @Test + void pendingSyncingRenderingFinalizingAreNotTerminal() { + for (String phase : new String[] {"Pending", "Syncing", "Rendering", "Finalizing"}) { + assertFalse(RenderPhases.isTerminal(phase), () -> phase + " must not be terminal"); + } + } + + @Test + void nullPhaseIsNotTerminal() { + // Not yet set by the operator -- must not be mistaken for "done". + assertFalse(RenderPhases.isTerminal(null)); + } + + @Test + void unknownPhaseIsNotTerminal() { + assertFalse(RenderPhases.isTerminal("SomethingElse")); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java new file mode 100644 index 0000000..d7c2311 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java @@ -0,0 +1,297 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.Watch; +import io.fabric8.kubernetes.client.Watcher; +import io.fabric8.kubernetes.client.WatcherException; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.exceptions.HttpStatusException; +import io.micronaut.http.sse.Event; +import io.micronaut.security.authentication.Authentication; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapRender; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Publisher; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * Covers the task 3 brief's three binding requirements directly against {@link + * RenderStreamController}, using hand-written fakes for {@link RenderRepository} and {@link + * LogSource} instead of a mocking framework or a Micronaut test context -- neither {@code + * micronaut-test-junit5} nor a mocking library is a test dependency of the {@code api} module + * (see the task 3 report), so these tests call the controller's methods directly rather than + * going through an embedded server. + */ +class RenderStreamControllerTest { + + private static final Authentication VIEWER = + Authentication.build("carol", List.of("tenant-viewer"), Map.of("organization", "acme")); + + private static final class RecordingSubscriber implements Subscriber> { + final List values = new ArrayList<>(); + Throwable error; + boolean completed; + Subscription subscription; + + @Override + public void onSubscribe(Subscription s) { + subscription = s; + } + + @Override + public void onNext(Event event) { + values.add(event.getData()); + } + + @Override + public void onError(Throwable t) { + error = t; + } + + @Override + public void onComplete() { + completed = true; + } + } + + private static final class FakeRenderRepository implements RenderRepository { + private final Map renders = new HashMap<>(); + Watcher capturedWatcher; + boolean watchCalled; + boolean watchClosed; + + void put(String namespace, String name, BlueMapRender render) { + renders.put(namespace + "/" + name, render); + } + + @Override + public Optional find(String namespace, String name) { + return Optional.ofNullable(renders.get(namespace + "/" + name)); + } + + @Override + public Watch watch(String namespace, String name, String resourceVersion, Watcher watcher) { + watchCalled = true; + capturedWatcher = watcher; + return () -> watchClosed = true; + } + } + + private static final class FakeLogSource implements LogSource { + boolean tailCalled; + boolean closed; + SseSource.Sink capturedSink; + + @Override + public AutoCloseable tail(String namespace, String jobName, SseSource.Sink sink) { + tailCalled = true; + capturedSink = sink; + return () -> closed = true; + } + } + + private static BlueMapRender render(String namespace, String name, String phase) { + BlueMapRender render = new BlueMapRender(); + render.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .withResourceVersion("1") + .build()); + render.getStatus().setPhase(phase); + render.getStatus().setJobName(name); + return render; + } + + // -- GET /api/renders/{id}/events ----------------------------------------------------- + + @Test + void progressStreamDeliversTheCurrentSnapshotImmediatelyAndAgainOnEachStatusChange() { + BlueMapRender render = render("bluemap-acme", "render-1", "Rendering"); + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-acme", "render-1", render); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + Publisher> publisher = controller.events(VIEWER, "render-1"); + RecordingSubscriber subscriber = new RecordingSubscriber<>(); + publisher.subscribe(subscriber); + subscriber.subscription.request(Long.MAX_VALUE); + + // Initial snapshot, from the same read that already proved the render exists. + assertEquals(1, subscriber.values.size()); + assertEquals("Rendering", subscriber.values.get(0).phase()); + + // The operator writes a new progress value -- the watch (not a poll) is what delivers it. + render.getStatus().getProgress().setPercent(42.0); + repository.capturedWatcher.eventReceived(Watcher.Action.MODIFIED, render); + + assertEquals(2, subscriber.values.size()); + assertEquals(42.0, subscriber.values.get(1).percent()); + assertFalse(subscriber.completed); + } + + @Test + void progressStreamEndsAndClosesTheWatchWhenTheRenderBecomesTerminal() { + BlueMapRender render = render("bluemap-acme", "render-1", "Rendering"); + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-acme", "render-1", render); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + RecordingSubscriber subscriber = new RecordingSubscriber<>(); + controller.events(VIEWER, "render-1").subscribe(subscriber); + subscriber.subscription.request(Long.MAX_VALUE); + + render.getStatus().setPhase("Succeeded"); + repository.capturedWatcher.eventReceived(Watcher.Action.MODIFIED, render); + + assertTrue(subscriber.completed, "subscriber must see onComplete once the render is terminal"); + assertTrue(repository.watchClosed, "the Kubernetes watch must be closed, not left open"); + } + + @Test + void progressStreamOfAnAlreadyTerminalRenderCompletesWithoutEverWatching() { + BlueMapRender render = render("bluemap-acme", "render-1", "Succeeded"); + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-acme", "render-1", render); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + RecordingSubscriber subscriber = new RecordingSubscriber<>(); + controller.events(VIEWER, "render-1").subscribe(subscriber); + subscriber.subscription.request(Long.MAX_VALUE); + + assertEquals(1, subscriber.values.size()); + assertTrue(subscriber.completed); + assertFalse(repository.watchCalled, "nothing left to watch for a render that is already done"); + } + + @Test + void aRenderInAForeignTenantsNamespaceIs404BeforeAnyWatchOpens() { + FakeRenderRepository repository = new FakeRenderRepository(); + // Exists, but only in a different tenant's namespace -- never looked up there. + repository.put("bluemap-globex", "render-1", render("bluemap-globex", "render-1", "Rendering")); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + HttpStatusException e = + assertThrows(HttpStatusException.class, () -> controller.events(VIEWER, "render-1")); + + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + assertFalse(repository.watchCalled, "must not have looked in any other namespace to find it"); + } + + @Test + void aPrincipalWithNoTenantClaimIsForbiddenBeforeAnyLookupHappens() { + FakeRenderRepository repository = new FakeRenderRepository(); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + Authentication noTenant = Authentication.build("root", List.of("platform-admin"), Map.of()); + + HttpStatusException e = + assertThrows(HttpStatusException.class, () -> controller.events(noTenant, "render-1")); + + assertEquals(HttpStatus.FORBIDDEN, e.getStatus()); + } + + // -- GET /api/renders/{id}/logs -------------------------------------------------------- + + @Test + void logStreamTailsTheJobAndEndsWhenTheRenderBecomesTerminal() { + BlueMapRender render = render("bluemap-acme", "render-1", "Rendering"); + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-acme", "render-1", render); + FakeLogSource logSource = new FakeLogSource(); + RenderStreamController controller = new RenderStreamController(repository, new TenantResolver(), logSource, new PrincipalResolver()); + + RecordingSubscriber subscriber = new RecordingSubscriber<>(); + controller.logs(VIEWER, "render-1").subscribe(subscriber); + subscriber.subscription.request(Long.MAX_VALUE); + + assertTrue(logSource.tailCalled); + logSource.capturedSink.next("[bluemap] rendering overworld: 12%"); + assertEquals(List.of("[bluemap] rendering overworld: 12%"), subscriber.values); + + render.getStatus().setPhase("Succeeded"); + repository.capturedWatcher.eventReceived(Watcher.Action.MODIFIED, render); + + assertTrue(subscriber.completed); + assertTrue(logSource.closed, "the log tail must be released, not left open"); + assertTrue(repository.watchClosed, "the termination watch must be released too"); + } + + @Test + void logStreamOfAForeignTenantsRenderIs404BeforeAnyLogTailOpens() { + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-globex", "render-1", render("bluemap-globex", "render-1", "Rendering")); + FakeLogSource logSource = new FakeLogSource(); + RenderStreamController controller = new RenderStreamController(repository, new TenantResolver(), logSource, new PrincipalResolver()); + + HttpStatusException e = assertThrows(HttpStatusException.class, () -> controller.logs(VIEWER, "render-1")); + + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + assertFalse(logSource.tailCalled, "logs of a render outside the caller's tenant must never be read"); + } + + @Test + void anUnrelatedRenderIdIs404TheSameWayAForeignTenantsIs() { + // No render by this id exists anywhere -- proves the 404 does not leak "exists elsewhere" + // vs. "does not exist at all" as two different outcomes. + FakeRenderRepository repository = new FakeRenderRepository(); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + HttpStatusException e = + assertThrows(HttpStatusException.class, () -> controller.events(VIEWER, "does-not-exist")); + + assertEquals(HttpStatus.NOT_FOUND, e.getStatus()); + } + + @Test + void logStreamPropagatesAWatcherCloseErrorAsAStreamError() { + BlueMapRender render = render("bluemap-acme", "render-1", "Rendering"); + FakeRenderRepository repository = new FakeRenderRepository(); + repository.put("bluemap-acme", "render-1", render); + RenderStreamController controller = + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); + + RecordingSubscriber subscriber = new RecordingSubscriber<>(); + controller.events(VIEWER, "render-1").subscribe(subscriber); + subscriber.subscription.request(Long.MAX_VALUE); + + WatcherException cause = new WatcherException("connection reset"); + repository.capturedWatcher.onClose(cause); + + assertEquals(cause, subscriber.error); + assertFalse(subscriber.completed); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/SseSourceTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/SseSourceTest.java new file mode 100644 index 0000000..f259058 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/SseSourceTest.java @@ -0,0 +1,193 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.events; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.Test; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +/** + * Exercises the hand-rolled reactive-streams plumbing directly (no Reactor/RxJava on this + * module's compile classpath, see {@link SseSource}'s Javadoc) -- the mechanism both SSE + * endpoints in {@link RenderStreamController} rely on for "deliver values as they happen, and + * release the underlying watch/log tail exactly once, however the stream ends". + */ +class SseSourceTest { + + /** Captures every signal a real SSE writer would otherwise consume. */ + private static class RecordingSubscriber implements Subscriber { + final List values = new ArrayList<>(); + Throwable error; + boolean completed; + + @Override + public void onSubscribe(Subscription s) { + s.request(Long.MAX_VALUE); + } + + @Override + public void onNext(String value) { + values.add(value); + } + + @Override + public void onError(Throwable t) { + error = t; + } + + @Override + public void onComplete() { + completed = true; + } + } + + private static final class RecordingCleanup implements AutoCloseable { + final AtomicBoolean closed = new AtomicBoolean(); + + @Override + public void close() { + closed.set(true); + } + } + + @Test + void wiresUpOnlyAfterASubscriberRequestsDemand() { + AtomicBoolean wired = new AtomicBoolean(); + SseSource source = new SseSource<>(sink -> { + wired.set(true); + return () -> {}; + }); + + RecordingSubscriber subscriber = new RecordingSubscriber() { + @Override + public void onSubscribe(Subscription s) { + // Deliberately does not request -- wiring must not have happened yet. + } + }; + source.subscribe(subscriber); + + assertFalse(wired.get()); + } + + @Test + void deliversEveryValuePushedThroughTheSink() { + SseSource.Sink[] captured = new SseSource.Sink[1]; + SseSource source = new SseSource<>(sink -> { + captured[0] = sink; + return () -> {}; + }); + + RecordingSubscriber subscriber = new RecordingSubscriber(); + source.subscribe(subscriber); + + captured[0].next("first"); + captured[0].next("second"); + + assertEquals(List.of("first", "second"), subscriber.values); + assertFalse(subscriber.completed); + } + + @Test + void completingTheSinkCompletesTheSubscriberAndRunsCleanupExactlyOnce() { + RecordingCleanup cleanup = new RecordingCleanup(); + SseSource.Sink[] captured = new SseSource.Sink[1]; + SseSource source = new SseSource<>(sink -> { + captured[0] = sink; + return cleanup; + }); + + RecordingSubscriber subscriber = new RecordingSubscriber(); + source.subscribe(subscriber); + + captured[0].complete(); + captured[0].complete(); // must be a no-op the second time + captured[0].next("too late"); // must be dropped, not delivered + + assertTrue(subscriber.completed); + assertTrue(cleanup.closed.get()); + assertTrue(subscriber.values.isEmpty()); + } + + @Test + void erroringTheSinkPropagatesTheThrowableAndRunsCleanup() { + RecordingCleanup cleanup = new RecordingCleanup(); + SseSource.Sink[] captured = new SseSource.Sink[1]; + SseSource source = new SseSource<>(sink -> { + captured[0] = sink; + return cleanup; + }); + RecordingSubscriber subscriber = new RecordingSubscriber(); + source.subscribe(subscriber); + + RuntimeException boom = new RuntimeException("watch failed"); + captured[0].error(boom); + + assertEquals(boom, subscriber.error); + assertTrue(cleanup.closed.get()); + } + + @Test + void cancellingTheSubscriptionRunsCleanupWithoutCompletingOrErroring() { + // The client-disconnect path: no producer-side signal ever arrives, only cancel(). + RecordingCleanup cleanup = new RecordingCleanup(); + SseSource source = new SseSource<>(sink -> cleanup); + + Subscription[] captured = new Subscription[1]; + source.subscribe(new RecordingSubscriber() { + @Override + public void onSubscribe(Subscription s) { + captured[0] = s; + s.request(1); + } + }); + + captured[0].cancel(); + + assertTrue(cleanup.closed.get()); + } + + @Test + void nonPositiveRequestFailsTheStreamWithoutWiringAnything() { + AtomicBoolean wired = new AtomicBoolean(); + SseSource source = new SseSource<>(sink -> { + wired.set(true); + return () -> {}; + }); + + Subscription[] captured = new Subscription[1]; + RecordingSubscriber subscriber = new RecordingSubscriber() { + @Override + public void onSubscribe(Subscription s) { + captured[0] = s; + } + }; + source.subscribe(subscriber); + captured[0].request(0); + + assertFalse(wired.get()); + assertTrue(subscriber.error instanceof IllegalArgumentException); + assertTrue(subscriber.values.isEmpty()); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingControllerTest.java new file mode 100644 index 0000000..cc6f1f1 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/BlueMapHostingControllerTest.java @@ -0,0 +1,69 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapHosting; +import org.junit.jupiter.api.Test; + +class BlueMapHostingControllerTest { + + private final InMemoryBlueMapHostingRepository repository = new InMemoryBlueMapHostingRepository(); + private final BlueMapHostingController controller = + new BlueMapHostingController(repository, new PrincipalResolver(), new TenantResolver()); + + private static Authentication viewer(String tenant) { + return Authentication.build("carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication noRoles(String tenant) { + return Authentication.build("service-token", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static BlueMapHosting hosting(String name, String hostname) { + BlueMapHosting hosting = new BlueMapHosting(); + hosting.getMetadata().setName(name); + hosting.getSpec().setHostname(hostname); + return hosting; + } + + @Test + void listReturnsOnlyHostingsInTheCallersOwnNamespace() { + repository.put("bluemap-acme", hosting("survival-hosting", "map.acme.example.net")); + repository.put("bluemap-globex", hosting("foreign-hosting", "map.globex.example.net")); + + var response = controller.list(viewer("acme")); + + assertEquals(1, response.body().size()); + assertEquals("survival-hosting", response.body().get(0).name()); + assertEquals("map.acme.example.net", response.body().get(0).hostname()); + } + + @Test + void listRejectsACallerWithNoTenantRole() { + assertThrows(ForbiddenException.class, () -> controller.list(noRoles("acme"))); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/InMemoryBlueMapHostingRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/InMemoryBlueMapHostingRepository.java new file mode 100644 index 0000000..1b2c88e --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/hosting/InMemoryBlueMapHostingRepository.java @@ -0,0 +1,43 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.hosting; + +import java.util.ArrayList; +import java.util.List; +import net.onelitefeather.apus.operator.api.BlueMapHosting; + +/** An in-memory, namespace-partitioned {@link BlueMapHostingRepository} fake. See {@code + * InMemoryTenantRepository}'s Javadoc (in the {@code tenant} package) for why. */ +final class InMemoryBlueMapHostingRepository implements BlueMapHostingRepository { + + private final List items = new ArrayList<>(); + + void put(String namespace, BlueMapHosting hosting) { + items.add(new Namespaced(namespace, hosting)); + } + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + private record Namespaced(String namespace, BlueMapHosting resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerHttpTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerHttpTest.java new file mode 100644 index 0000000..fd66607 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerHttpTest.java @@ -0,0 +1,154 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.client.HttpClient; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.http.client.exceptions.HttpClientResponseException; +import io.micronaut.security.token.generator.TokenGenerator; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Proves the three binding invariants from the phase 5a consolidation brief over a real, + * embedded HTTP server -- not by calling {@link BlueMapMapController}'s methods directly, the + * way every other controller test in this module does (see those classes' Javadoc: neither + * {@code micronaut-test-junit5} nor an HTTP client was a test dependency before this + * consolidation). This is what actually proves the {@code @Secured} annotations and the + * exception handlers under {@code rest/support} are wired into the real filter chain, not just + * that the plain Java methods behave correctly in isolation: + * + *

+ * + *

Runs under the {@code apitest} Micronaut environment, which replaces {@link + * FabricBlueMapMapRepository} with {@link TestBlueMapMapRepository} (see its Javadoc) so these + * tests need neither Docker nor a reachable Kubernetes API server -- that real-cluster proof is + * {@code TenantIsolationIntegrationTest}'s job. JWT signing/validation is configured in {@code + * src/test/resources/application-test.yml} with a symmetric test-only secret so tokens can be + * minted here without a real identity broker. + */ +@MicronautTest(environments = "apitest") +class BlueMapMapControllerHttpTest { + + @Inject + @Client("/") + HttpClient client; + + @Inject + TestBlueMapMapRepository mapRepository; + + @Inject + TokenGenerator tokenGenerator; + + @BeforeEach + void clearFixtures() { + mapRepository.clear(); + } + + @Test + void requestWithoutATokenIsUnauthorized() { + HttpClientResponseException e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking().exchange(HttpRequest.GET("/api/maps"))); + + assertEquals(HttpStatus.UNAUTHORIZED, e.getStatus()); + } + + @Test + void requestWithAValidTokenButNoTenantRoleIsForbidden() { + // A real, validly signed token -- authentication succeeds -- for a caller with zero + // recognised tenant roles (e.g. a narrowly scoped service token, design spec §10.3). + String token = token("service-token", List.of(), "acme"); + + HttpClientResponseException e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking() + .exchange(HttpRequest.GET("/api/maps").bearerAuth(token))); + + assertEquals(HttpStatus.FORBIDDEN, e.getStatus()); + } + + @Test + void resourceInAForeignTenantsNamespaceIs404NotForbidden() { + BlueMapMap foreignMap = new BlueMapMap(); + foreignMap.getMetadata().setName("globex-only-map"); + mapRepository.put("bluemap-globex", foreignMap); + + // Sufficiently privileged (tenant-viewer), but for the wrong tenant: "acme", not + // "globex". The map exists -- just not where this caller may look. + String token = token("carol", List.of("tenant-viewer"), "acme"); + + HttpClientResponseException e = assertThrows( + HttpClientResponseException.class, + () -> client.toBlocking() + .exchange(HttpRequest.GET("/api/maps/globex-only-map").bearerAuth(token))); + + assertEquals( + HttpStatus.NOT_FOUND, + e.getStatus(), + "a foreign tenant's resource must be a plain 404, not a 403 that would confirm it exists"); + } + + @Test + void resourceInTheCallersOwnNamespaceIsFound() { + // Sanity check alongside the two failure cases above: the same mechanism that blocks a + // foreign tenant must not also block the caller's own tenant. + BlueMapMap ownMap = new BlueMapMap(); + ownMap.getMetadata().setName("survival-overworld"); + mapRepository.put("bluemap-acme", ownMap); + + String token = token("carol", List.of("tenant-viewer"), "acme"); + + var response = client.toBlocking() + .exchange(HttpRequest.GET("/api/maps/survival-overworld").bearerAuth(token), BlueMapMapResponse.class); + + assertEquals(HttpStatus.OK, response.getStatus()); + assertEquals("survival-overworld", response.body().name()); + } + + private String token(String subject, List roles, String tenant) { + Map claims = new HashMap<>(); + claims.put("sub", subject); + claims.put("roles", roles); + claims.put(PrincipalResolver.TENANT_CLAIM, tenant); + claims.put("iss", "https://apus-test-issuer.internal"); + return tokenGenerator + .generateToken(claims) + .orElseThrow(() -> new IllegalStateException("test token generation failed")); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerTest.java new file mode 100644 index 0000000..3031223 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerTest.java @@ -0,0 +1,144 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.rest.support.NotFoundException; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapMap; +import org.junit.jupiter.api.Test; + +/** + * Covers the two id-based endpoints ({@code getById}, {@code triggerRender}) with the full + * three-case shape task-2-brief.md asks for: happy path, foreign tenant -> 404, insufficient + * role -> 403. {@code list} gets happy path plus a cross-tenant isolation check instead of a 404 + * case -- a collection has no single id that could belong to a foreign tenant, so "404" does not + * apply to it the way it does to a by-id lookup; isolation is the equivalent invariant for a + * list (see {@code WorldSourceControllerTest} for the same reasoning applied there). + */ +class BlueMapMapControllerTest { + + private final InMemoryBlueMapMapRepository mapRepository = new InMemoryBlueMapMapRepository(); + private final InMemoryBlueMapRenderRepository renderRepository = new InMemoryBlueMapRenderRepository(); + private final BlueMapMapController controller = new BlueMapMapController( + mapRepository, renderRepository, new PrincipalResolver(), new TenantResolver()); + + private static Authentication viewer(String tenant) { + return Authentication.build("carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication operator(String tenant) { + return Authentication.build( + "dave", List.of("tenant-operator"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication noRoles(String tenant) { + return Authentication.build("service-token", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static BlueMapMap map(String name) { + BlueMapMap map = new BlueMapMap(); + map.getMetadata().setName(name); + return map; + } + + @Test + void listReturnsOnlyMapsInTheCallersOwnNamespace() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + mapRepository.put("bluemap-globex", map("foreign-map")); + + var response = controller.list(viewer("acme")); + + assertEquals(1, response.body().size()); + assertEquals("survival-overworld", response.body().get(0).name()); + } + + @Test + void listRejectsACallerWithNoTenantRole() { + assertThrows(ForbiddenException.class, () -> controller.list(noRoles("acme"))); + } + + @Test + void getByIdReturnsAMapInTheCallersOwnNamespace() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + + var response = controller.getById(viewer("acme"), "survival-overworld"); + + assertEquals("survival-overworld", response.body().name()); + } + + @Test + void getByIdReturns404ForAForeignTenantsMap() { + // The central rule under test: a map that exists, but only in a different tenant's + // namespace, must be indistinguishable from one that does not exist at all. + mapRepository.put("bluemap-globex", map("survival-overworld")); + + assertThrows(NotFoundException.class, () -> controller.getById(viewer("acme"), "survival-overworld")); + } + + @Test + void getByIdRejectsACallerWithNoTenantRole() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + assertThrows(ForbiddenException.class, () -> controller.getById(noRoles("acme"), "survival-overworld")); + } + + @Test + void triggerRenderCreatesABlueMapRenderReferencingTheMap() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + + var response = controller.triggerRender(operator("acme"), "survival-overworld", null); + + assertEquals(201, response.getStatus().getCode()); + assertEquals("survival-overworld", response.body().mapRef()); + assertTrue(renderRepository.list("bluemap-acme").stream() + .anyMatch(r -> "survival-overworld".equals(r.getSpec().getMapRef().getName()))); + } + + @Test + void triggerRenderHonoursTheForceFlag() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + + var response = controller.triggerRender(operator("acme"), "survival-overworld", new TriggerRenderRequest(true)); + + assertTrue(response.body().force()); + } + + @Test + void triggerRenderReturns404ForAForeignTenantsMapWithoutCreatingARender() { + mapRepository.put("bluemap-globex", map("survival-overworld")); + + assertThrows( + NotFoundException.class, () -> controller.triggerRender(operator("acme"), "survival-overworld", null)); + assertEquals(0, renderRepository.list("bluemap-acme").size()); + } + + @Test + void triggerRenderRejectsAViewer() { + mapRepository.put("bluemap-acme", map("survival-overworld")); + assertThrows( + ForbiddenException.class, () -> controller.triggerRender(viewer("acme"), "survival-overworld", null)); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapMapRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapMapRepository.java new file mode 100644 index 0000000..91a8798 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapMapRepository.java @@ -0,0 +1,53 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapMap; + +/** An in-memory, namespace-partitioned {@link BlueMapMapRepository} fake. See {@code + * InMemoryTenantRepository}'s Javadoc for why. */ +final class InMemoryBlueMapMapRepository implements BlueMapMapRepository { + + private final List items = new ArrayList<>(); + + void put(String namespace, BlueMapMap map) { + items.add(new Namespaced(namespace, map)); + } + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + @Override + public Optional find(String namespace, String name) { + return items.stream() + .filter(item -> item.namespace().equals(namespace) + && item.resource().getMetadata().getName().equals(name)) + .map(Namespaced::resource) + .findFirst(); + } + + private record Namespaced(String namespace, BlueMapMap resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapRenderRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapRenderRepository.java new file mode 100644 index 0000000..5432c0f --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/map/InMemoryBlueMapRenderRepository.java @@ -0,0 +1,65 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.api.rest.render.BlueMapRenderRepository; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** + * An in-memory, namespace-partitioned {@link BlueMapRenderRepository} fake used to assert what + * {@link BlueMapMapController#triggerRender} creates, without needing a real Kubernetes API + * server. {@code create} assigns a name from {@code generateName} the way a real API server + * would, so tests can assert a render was actually created. + */ +final class InMemoryBlueMapRenderRepository implements BlueMapRenderRepository { + + private final List items = new ArrayList<>(); + private int nextSuffix = 1; + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + @Override + public Optional find(String namespace, String name) { + return items.stream() + .filter(item -> item.namespace().equals(namespace) + && item.resource().getMetadata().getName().equals(name)) + .map(Namespaced::resource) + .findFirst(); + } + + @Override + public BlueMapRender create(String namespace, BlueMapRender render) { + String generateName = render.getMetadata().getGenerateName(); + if (generateName != null) { + render.getMetadata().setName(generateName + nextSuffix++); + } + items.add(new Namespaced(namespace, render)); + return render; + } + + private record Namespaced(String namespace, BlueMapRender resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/map/TestBlueMapMapRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/TestBlueMapMapRepository.java new file mode 100644 index 0000000..b017736 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/map/TestBlueMapMapRepository.java @@ -0,0 +1,71 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.map; + +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import jakarta.inject.Singleton; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapMap; + +/** + * An in-memory, namespace-partitioned {@link BlueMapMapRepository}, wired into the Micronaut + * context in place of {@link FabricBlueMapMapRepository} only under the {@code apitest} + * environment ({@link BlueMapMapControllerHttpTest}) -- the HTTP-level 401/403/404 tests exercise + * the real embedded server and security filter chain, but do not need a real Kubernetes API + * server behind it; that real-cluster proof is {@code TenantIsolationIntegrationTest}'s job + * instead (environment {@code k3s}), which leaves this bean unreplaced so its repositories stay + * the real, cluster-backed ones. See {@code InMemoryBlueMapMapRepository} in this same package + * for the equivalent non-DI fake the direct-call controller tests use. + */ +@Singleton +@Requires(env = "apitest") +@Replaces(FabricBlueMapMapRepository.class) +public class TestBlueMapMapRepository implements BlueMapMapRepository { + + private final List items = new ArrayList<>(); + + public void put(String namespace, BlueMapMap map) { + items.add(new Namespaced(namespace, map)); + } + + public void clear() { + items.clear(); + } + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + @Override + public Optional find(String namespace, String name) { + return items.stream() + .filter(item -> item.namespace().equals(namespace) + && item.resource().getMetadata().getName().equals(name)) + .map(Namespaced::resource) + .findFirst(); + } + + private record Namespaced(String namespace, BlueMapMap resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderControllerTest.java new file mode 100644 index 0000000..19dd75d --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderControllerTest.java @@ -0,0 +1,96 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.rest.support.NotFoundException; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.BlueMapRender; +import net.onelitefeather.apus.operator.api.Ref; +import org.junit.jupiter.api.Test; + +class BlueMapRenderControllerTest { + + private final InMemoryBlueMapRenderRepository repository = new InMemoryBlueMapRenderRepository(); + private final BlueMapRenderController controller = + new BlueMapRenderController(repository, new PrincipalResolver(), new TenantResolver()); + + private static Authentication viewer(String tenant) { + return Authentication.build("carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication noRoles(String tenant) { + return Authentication.build("service-token", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static BlueMapRender render(String name, String mapName) { + BlueMapRender render = new BlueMapRender(); + render.getMetadata().setName(name); + Ref mapRef = new Ref(); + mapRef.setName(mapName); + render.getSpec().setMapRef(mapRef); + render.getStatus().setPhase("Rendering"); + return render; + } + + @Test + void listReturnsOnlyRendersInTheCallersOwnNamespace() { + repository.put("bluemap-acme", render("render-1", "survival-overworld")); + repository.put("bluemap-globex", render("foreign-render", "creative-overworld")); + + var response = controller.list(viewer("acme")); + + assertEquals(1, response.body().size()); + assertEquals("render-1", response.body().get(0).name()); + } + + @Test + void listRejectsACallerWithNoTenantRole() { + assertThrows(ForbiddenException.class, () -> controller.list(noRoles("acme"))); + } + + @Test + void getByIdReturnsARenderInTheCallersOwnNamespace() { + repository.put("bluemap-acme", render("render-1", "survival-overworld")); + + var response = controller.getById(viewer("acme"), "render-1"); + + assertEquals("render-1", response.body().name()); + assertEquals("Rendering", response.body().phase()); + } + + @Test + void getByIdReturns404ForAForeignTenantsRender() { + repository.put("bluemap-globex", render("render-1", "creative-overworld")); + + assertThrows(NotFoundException.class, () -> controller.getById(viewer("acme"), "render-1")); + } + + @Test + void getByIdRejectsACallerWithNoTenantRole() { + repository.put("bluemap-acme", render("render-1", "survival-overworld")); + assertThrows(ForbiddenException.class, () -> controller.getById(noRoles("acme"), "render-1")); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/render/InMemoryBlueMapRenderRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/render/InMemoryBlueMapRenderRepository.java new file mode 100644 index 0000000..ba9016e --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/render/InMemoryBlueMapRenderRepository.java @@ -0,0 +1,59 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.render; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.BlueMapRender; + +/** An in-memory, namespace-partitioned {@link BlueMapRenderRepository} fake. See {@code + * InMemoryTenantRepository}'s Javadoc (in the {@code tenant} package) for why. */ +final class InMemoryBlueMapRenderRepository implements BlueMapRenderRepository { + + private final List items = new ArrayList<>(); + + void put(String namespace, BlueMapRender render) { + items.add(new Namespaced(namespace, render)); + } + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + @Override + public Optional find(String namespace, String name) { + return items.stream() + .filter(item -> item.namespace().equals(namespace) + && item.resource().getMetadata().getName().equals(name)) + .map(Namespaced::resource) + .findFirst(); + } + + @Override + public BlueMapRender create(String namespace, BlueMapRender render) { + put(namespace, render); + return render; + } + + private record Namespaced(String namespace, BlueMapRender resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/support/ExceptionHandlerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/support/ExceptionHandlerTest.java new file mode 100644 index 0000000..7f818fc --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/support/ExceptionHandlerTest.java @@ -0,0 +1,57 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import io.micronaut.http.HttpStatus; +import net.onelitefeather.apus.api.security.ForbiddenException; +import org.junit.jupiter.api.Test; + +/** + * These handlers are plain classes -- no {@code @Requires}/{@code @Produces} processing needed + * to call {@code handle} directly -- so this closes the loop the controller tests can't: proof + * that {@link ForbiddenException} and {@link NotFoundException}, once thrown, actually resolve + * to the HTTP status task-2-brief.md requires (403 and 404 respectively). + */ +class ExceptionHandlerTest { + + @Test + void forbiddenExceptionMapsTo403() { + var handler = new ForbiddenExceptionHandler(); + var response = handler.handle(null, new ForbiddenException("no tenant")); + assertEquals(HttpStatus.FORBIDDEN, response.status()); + } + + @Test + void notFoundExceptionMapsTo404() { + var handler = new NotFoundExceptionHandler(); + var response = handler.handle(null, new NotFoundException("no such resource")); + assertEquals(HttpStatus.NOT_FOUND, response.status()); + } + + @Test + void badRequestExceptionMapsTo400WithMessage() { + var handler = new BadRequestExceptionHandler(); + var response = handler.handle(null, new BadRequestException("name must not be blank")); + assertEquals(HttpStatus.BAD_REQUEST, response.status()); + assertEquals( + "name must not be blank", + ((BadRequestExceptionHandler.ErrorBody) response.body()).message()); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/support/TenantAccessTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/support/TenantAccessTest.java new file mode 100644 index 0000000..43c1219 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/support/TenantAccessTest.java @@ -0,0 +1,59 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.support; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Set; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; +import org.junit.jupiter.api.Test; + +class TenantAccessTest { + + @Test + void ownerCanRead() { + assertTrue(TenantAccess.canRead(new ApusPrincipal("a", "acme", Set.of(Role.TENANT_OWNER)))); + } + + @Test + void operatorCanRead() { + assertTrue(TenantAccess.canRead(new ApusPrincipal("a", "acme", Set.of(Role.TENANT_OPERATOR)))); + } + + @Test + void viewerCanRead() { + assertTrue(TenantAccess.canRead(new ApusPrincipal("a", "acme", Set.of(Role.TENANT_VIEWER)))); + } + + @Test + void noRolesCannotRead() { + // The §10.3 service-token case: tenant claim present, but scoped to world:push only, so + // it carries none of the four Role values -- must not gain general read access just + // because it resolves a namespace fine. + assertFalse(TenantAccess.canRead(new ApusPrincipal("service-token", "acme", Set.of()))); + } + + @Test + void platformAdminAloneCannotReadATenant() { + // Mirrors ApusPrincipal#canWrite()'s own deliberate exclusion of platform-admin: that + // role's reach is platform-level, not into a specific tenant's sources/maps/renders. + assertFalse(TenantAccess.canRead(new ApusPrincipal("root", "acme", Set.of(Role.PLATFORM_ADMIN)))); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/InMemoryTenantRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/InMemoryTenantRepository.java new file mode 100644 index 0000000..2e27644 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/InMemoryTenantRepository.java @@ -0,0 +1,55 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.Tenant; + +/** + * An in-memory {@link TenantRepository} fake for controller tests. Standing in for {@code + * kubernetes-server-mock}/{@code micronaut-test-junit5}, neither of which is on this module's + * test classpath (task-1-report.md's "Concerns" section) -- see {@code TenantRepository}'s + * Javadoc for why the repository is an interface in the first place. + */ +final class InMemoryTenantRepository implements TenantRepository { + + private final Map byName = new LinkedHashMap<>(); + + void put(Tenant tenant) { + byName.put(tenant.getMetadata().getName(), tenant); + } + + @Override + public List list() { + return List.copyOf(byName.values()); + } + + @Override + public Optional findByName(String name) { + return Optional.ofNullable(byName.get(name)); + } + + @Override + public Tenant create(Tenant tenant) { + put(tenant); + return tenant; + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java new file mode 100644 index 0000000..b8a0fec --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/tenant/TenantControllerTest.java @@ -0,0 +1,95 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.tenant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import net.onelitefeather.apus.api.rest.support.BadRequestException; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.Tenant; +import org.junit.jupiter.api.Test; + +/** + * {@code TenantController} is platform-scoped ({@code Tenant} is cluster-scoped, design spec + * §8.1), so unlike the tenant-scoped controllers there is no "foreign tenant -> 404" case here: + * a {@code platform-admin} legitimately sees every tenant, by design (§10.3 "clusterweite + * Sicht"). What replaces it: insufficient role (not a platform-admin) must produce 403. + */ +class TenantControllerTest { + + private final InMemoryTenantRepository repository = new InMemoryTenantRepository(); + private final TenantController controller = new TenantController(repository, new PrincipalResolver()); + + private static Authentication platformAdmin() { + return Authentication.build("root", List.of("platform-admin"), java.util.Map.of()); + } + + private static Authentication tenantOwner() { + return Authentication.build( + "alice", List.of("tenant-owner"), java.util.Map.of(PrincipalResolver.TENANT_CLAIM, "acme")); + } + + @Test + void listReturnsAllTenantsForAPlatformAdmin() { + Tenant tenant = new Tenant(); + tenant.getMetadata().setName("acme"); + tenant.getSpec().setDisplayName("Acme Corp"); + repository.put(tenant); + + var response = controller.list(platformAdmin()); + + assertEquals(200, response.getStatus().getCode()); + assertEquals(1, response.body().size()); + assertEquals("acme", response.body().get(0).name()); + } + + @Test + void listRejectsANonPlatformAdmin() { + assertThrows(ForbiddenException.class, () -> controller.list(tenantOwner())); + } + + @Test + void createAddsANewTenantForAPlatformAdmin() { + var request = new CreateTenantRequest("globex", "Globex", "200Gi", 1_000_000L, List.of("*.globex.example.net")); + + var response = controller.create(platformAdmin(), request); + + assertEquals(201, response.getStatus().getCode()); + assertEquals("globex", response.body().name()); + assertEquals("Globex", response.body().displayName()); + assertEquals("200Gi", response.body().storage().quota()); + assertTrue(repository.findByName("globex").isPresent()); + } + + @Test + void createRejectsANonPlatformAdmin() { + var request = new CreateTenantRequest("globex", "Globex", null, null, List.of()); + assertThrows(ForbiddenException.class, () -> controller.create(tenantOwner(), request)); + } + + @Test + void createRejectsABlankName() { + var request = new CreateTenantRequest(" ", "Globex", null, null, List.of()); + assertThrows(BadRequestException.class, () -> controller.create(platformAdmin(), request)); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/InMemoryWorldSourceRepository.java b/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/InMemoryWorldSourceRepository.java new file mode 100644 index 0000000..2908994 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/InMemoryWorldSourceRepository.java @@ -0,0 +1,62 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.operator.api.WorldSource; + +/** + * An in-memory, namespace-partitioned {@link WorldSourceRepository} fake -- namespace + * partitioning is exactly what lets tests prove a source seeded under a different tenant's + * namespace never surfaces for this one, without needing a real or mocked Kubernetes API server. + */ +final class InMemoryWorldSourceRepository implements WorldSourceRepository { + + private final List items = new ArrayList<>(); + + void put(String namespace, WorldSource source) { + items.add(new Namespaced(namespace, source)); + } + + @Override + public List list(String namespace) { + return items.stream() + .filter(item -> item.namespace().equals(namespace)) + .map(Namespaced::resource) + .toList(); + } + + @Override + public Optional find(String namespace, String name) { + return items.stream() + .filter(item -> item.namespace().equals(namespace) + && item.resource().getMetadata().getName().equals(name)) + .map(Namespaced::resource) + .findFirst(); + } + + @Override + public WorldSource create(String namespace, WorldSource source) { + put(namespace, source); + return source; + } + + private record Namespaced(String namespace, WorldSource resource) {} +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceControllerTest.java new file mode 100644 index 0000000..a6b4142 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/worldsource/WorldSourceControllerTest.java @@ -0,0 +1,103 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.rest.worldsource; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import java.util.Map; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +import net.onelitefeather.apus.api.support.PrincipalResolver; +import net.onelitefeather.apus.operator.api.WorldSource; +import org.junit.jupiter.api.Test; + +class WorldSourceControllerTest { + + private final InMemoryWorldSourceRepository repository = new InMemoryWorldSourceRepository(); + private final WorldSourceController controller = + new WorldSourceController(repository, new PrincipalResolver(), new TenantResolver()); + + private static Authentication viewer(String tenant) { + return Authentication.build("carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication operator(String tenant) { + return Authentication.build( + "dave", List.of("tenant-operator"), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static Authentication noRoles(String tenant) { + // §10.3 service-token shape: tenant claim present, no recognised role. + return Authentication.build("service-token", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, tenant)); + } + + private static WorldSource source(String name) { + WorldSource source = new WorldSource(); + source.getMetadata().setName(name); + source.getSpec().setType("s3"); + return source; + } + + @Test + void listReturnsOnlySourcesInTheCallersOwnNamespace() { + repository.put("bluemap-acme", source("survival")); + repository.put("bluemap-globex", source("foreign-source")); + + var response = controller.list(viewer("acme")); + + assertEquals(200, response.getStatus().getCode()); + assertEquals(1, response.body().size()); + assertEquals("survival", response.body().get(0).name()); + } + + @Test + void listRejectsACallerWithNoTenantRole() { + assertThrows(ForbiddenException.class, () -> controller.list(noRoles("acme"))); + } + + @Test + void createAddsASourceInTheCallersOwnNamespace() { + var request = new CreateWorldSourceRequest( + "survival", + "s3", + new CreateWorldSourceRequest.S3Request("https://s3.example.net", "bucket", "prefix", "s3-creds"), + null, + null, + List.of(new CreateWorldSourceRequest.WorldSelectorRequest("world", "auto", "1.21.10")), + null); + + var response = controller.create(operator("acme"), request); + + assertEquals(201, response.getStatus().getCode()); + assertEquals("survival", response.body().name()); + assertTrue(repository.find("bluemap-acme", "survival").isPresent()); + // The response never carries the Secret name the request supplied. + assertFalse(response.body().toString().contains("s3-creds")); + } + + @Test + void createRejectsAViewer() { + var request = new CreateWorldSourceRequest("survival", "s3", null, null, null, List.of(), null); + assertThrows(ForbiddenException.class, () -> controller.create(viewer("acme"), request)); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/security/ApusPrincipalTest.java b/api/src/test/java/net/onelitefeather/apus/api/security/ApusPrincipalTest.java new file mode 100644 index 0000000..61976ae --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/security/ApusPrincipalTest.java @@ -0,0 +1,127 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class ApusPrincipalTest { + + @Test + void tenantOwnerCanWrite() { + ApusPrincipal owner = new ApusPrincipal("alice", "acme", Set.of(Role.TENANT_OWNER)); + assertTrue(owner.canWrite()); + } + + @Test + void tenantOperatorCanWrite() { + ApusPrincipal operator = new ApusPrincipal("bob", "acme", Set.of(Role.TENANT_OPERATOR)); + assertTrue(operator.canWrite()); + } + + @Test + void tenantViewerCannotWrite() { + ApusPrincipal viewer = new ApusPrincipal("carol", "acme", Set.of(Role.TENANT_VIEWER)); + assertFalse(viewer.canWrite()); + } + + @Test + void principalWithNoRolesCannotWrite() { + ApusPrincipal noRoles = new ApusPrincipal("dave", "acme", Set.of()); + assertFalse(noRoles.canWrite()); + } + + @Test + void platformAdminAloneCannotWriteWithinATenant() { + // canWrite() is specifically "owner or operator" (see the Javadoc on the interface this + // was built from) -- platform-admin's write access is to platform-level resources + // (tenants, quotas), never to a tenant's own sources/maps/renders. A platform-admin + // that also needs to write inside a tenant must hold tenant-owner/-operator too. + ApusPrincipal admin = new ApusPrincipal("root", "acme", Set.of(Role.PLATFORM_ADMIN)); + assertFalse(admin.canWrite()); + assertTrue(admin.isPlatformAdmin()); + } + + @Test + void platformAdminIsRecognisedRegardlessOfOtherRolesPresent() { + ApusPrincipal admin = new ApusPrincipal("root", null, EnumSet.of(Role.PLATFORM_ADMIN, Role.TENANT_VIEWER)); + assertTrue(admin.isPlatformAdmin()); + } + + @Test + void nonAdminIsNeverReportedAsPlatformAdmin() { + ApusPrincipal owner = new ApusPrincipal("alice", "acme", Set.of(Role.TENANT_OWNER)); + assertFalse(owner.isPlatformAdmin()); + } + + @Test + void tenantMayBeAbsentForAPlatformAdmin() { + // A platform-admin is not necessarily a member of any tenant -- this must construct + // without complaint. Whether a namespace can be resolved for such a principal is + // TenantResolver's decision, not this record's. + ApusPrincipal admin = new ApusPrincipal("root", null, Set.of(Role.PLATFORM_ADMIN)); + assertNull(admin.tenant()); + } + + @Test + void blankTenantIsNormalizedToNull() { + // A blank tenant claim is exactly as absent as no claim at all -- there is no + // distinction an attacker (or a misbehaving broker) could use to sneak past the "no + // default tenant" rule via an empty-but-present claim. + ApusPrincipal principal = new ApusPrincipal("alice", " ", Set.of(Role.TENANT_VIEWER)); + assertNull(principal.tenant()); + } + + @Test + void subjectMustNotBeNull() { + assertThrows(NullPointerException.class, () -> new ApusPrincipal(null, "acme", Set.of())); + } + + @Test + void rolesMustNotBeNull() { + assertThrows(NullPointerException.class, () -> new ApusPrincipal("alice", "acme", null)); + } + + @Test + void rolesAreDefensivelyCopiedAndImmutable() { + Set mutable = new HashSet<>(Set.of(Role.TENANT_VIEWER)); + ApusPrincipal principal = new ApusPrincipal("alice", "acme", mutable); + + // Mutating the caller's original set afterwards must not retroactively change what + // this principal was constructed with. + mutable.add(Role.PLATFORM_ADMIN); + assertFalse(principal.isPlatformAdmin()); + + assertThrows(UnsupportedOperationException.class, () -> principal.roles().add(Role.PLATFORM_ADMIN)); + } + + @Test + void equalPrincipalsAreEqual() { + ApusPrincipal a = new ApusPrincipal("alice", "acme", Set.of(Role.TENANT_VIEWER)); + ApusPrincipal b = new ApusPrincipal("alice", "acme", Set.of(Role.TENANT_VIEWER)); + assertEquals(a, b); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java b/api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java new file mode 100644 index 0000000..fcec2aa --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/security/RoleTest.java @@ -0,0 +1,65 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.NullAndEmptySource; +import org.junit.jupiter.params.provider.ValueSource; + +class RoleTest { + + @ParameterizedTest + @CsvSource({ + "platform-admin, PLATFORM_ADMIN", + "tenant-owner, TENANT_OWNER", + "tenant-operator, TENANT_OPERATOR", + "tenant-viewer, TENANT_VIEWER", + // Case-insensitive, exactly the four spec §10.3 role names -- nothing more is invented. + "Tenant-Viewer, TENANT_VIEWER", + }) + void fromClaimParsesTheFourSpecRoles(String claim, Role expected) { + assertEquals(Optional.of(expected), Role.fromClaim(claim)); + } + + @ParameterizedTest + @ValueSource(strings = {"admin", "owner", "platform_admin", "tenant-manager", "platform-administrator"}) + void fromClaimRejectsUnknownRoleNames(String claim) { + // An unrecognised role string never silently maps onto one of the four real roles -- + // near-miss spellings ("tenant-manager") and separator variants ("platform_admin") must + // not accidentally grant a role nobody issued. + assertTrue(Role.fromClaim(claim).isEmpty(), claim + " must not resolve to a Role"); + } + + @Test + void fromClaimTrimsSurroundingWhitespace() { + assertEquals(Optional.of(Role.PLATFORM_ADMIN), Role.fromClaim(" platform-admin ")); + } + + @ParameterizedTest + @NullAndEmptySource + @ValueSource(strings = {" "}) + void fromClaimRejectsNullBlankAndEmpty(String claim) { + assertTrue(Role.fromClaim(claim).isEmpty()); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/security/TenantResolverTest.java b/api/src/test/java/net/onelitefeather/apus/api/security/TenantResolverTest.java new file mode 100644 index 0000000..c0aae6d --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/security/TenantResolverTest.java @@ -0,0 +1,130 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.security; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import net.onelitefeather.apus.operator.api.Tenant; +import net.onelitefeather.apus.operator.tenant.TenantReconciler; +import org.junit.jupiter.api.Test; + +/** + * The core safety net for design spec §10.3: the namespace a caller may act in comes solely + * from their token's tenant claim, never from anything else. Every test here is written to + * catch exactly the regression it is named for. + */ +class TenantResolverTest { + + private final TenantResolver resolver = new TenantResolver(); + + @Test + void namespaceForRejectsAPrincipalWithoutATenant() { + ApusPrincipal noTenant = new ApusPrincipal("alice", null, Set.of(Role.TENANT_VIEWER)); + assertThrows(ForbiddenException.class, () -> resolver.namespaceFor(noTenant)); + } + + @Test + void namespaceForRejectsAPlatformAdminWithoutATenantToo() { + // Platform-admin's cross-tenant reach (design spec §10.3: "clusterweite Sicht") is a + // decision the REST layer makes elsewhere, by routing to a platform-level endpoint that + // never calls namespaceFor at all -- not a bypass built into this method. If it ever + // silently defaulted an admin token to some namespace, that would be exactly the "no + // default tenant" rule broken for the one role most dangerous to break it for. + ApusPrincipal admin = new ApusPrincipal("root", null, Set.of(Role.PLATFORM_ADMIN)); + assertThrows(ForbiddenException.class, () -> resolver.namespaceFor(admin)); + } + + @Test + void namespaceForUsesTheTenantOnThePrincipal() { + ApusPrincipal viewer = new ApusPrincipal("carol", "acme", Set.of(Role.TENANT_VIEWER)); + assertEquals("bluemap-acme", resolver.namespaceFor(viewer)); + } + + @Test + void namespaceForIsTheSameForEveryRole() { + // Role gates *what* a caller may do inside a namespace (see ApusPrincipalTest); it must + // never change *which* namespace resolution produces. + for (Role role : Role.values()) { + ApusPrincipal principal = new ApusPrincipal("user", "acme", Set.of(role)); + assertEquals("bluemap-acme", resolver.namespaceFor(principal), () -> role + " changed the resolved namespace"); + } + } + + @Test + void namespaceForMatchesTheOperatorsOwnNamingConvention() { + // Cross-checked against the real TenantReconciler instead of duplicating "bluemap-" as + // a second, independent source of truth: this fails the moment the operator's naming + // convention changes and this resolver is not updated to match, instead of the two + // silently drifting apart and namespaceFor pointing at a namespace the operator never + // actually provisions. + Tenant tenant = new Tenant(); + tenant.getMetadata().setName("acme"); + String expected = TenantReconciler.namespaceFor(tenant); + + ApusPrincipal principal = new ApusPrincipal("carol", "acme", Set.of(Role.TENANT_VIEWER)); + assertEquals(expected, resolver.namespaceFor(principal)); + } + + @Test + void differentTenantsResolveToDifferentNamespaces() { + ApusPrincipal acme = new ApusPrincipal("a", "acme", Set.of(Role.TENANT_VIEWER)); + ApusPrincipal globex = new ApusPrincipal("b", "globex", Set.of(Role.TENANT_VIEWER)); + assertNotEquals(resolver.namespaceFor(acme), resolver.namespaceFor(globex)); + } + + @Test + void namespaceForRequiresANonNullPrincipal() { + assertThrows(NullPointerException.class, () -> resolver.namespaceFor(null)); + } + + @Test + void namespaceForHasExactlyOnePublicMethodAndItTakesOnlyAPrincipal() { + // The load-bearing test for the brief's central rule: there is no path -- no overload, + // no extra parameter -- through which anything other than the validated principal's own + // tenant claim can influence the resolved namespace. If a future change adds e.g. + // namespaceFor(ApusPrincipal, String namespaceOverride) "for platform-admin" or "for + // testing", this test fails the build before any endpoint gets to use it. + List publicMethods = Arrays.stream(TenantResolver.class.getDeclaredMethods()) + .filter(method -> Modifier.isPublic(method.getModifiers())) + .toList(); + + assertEquals(1, publicMethods.size(), () -> "expected exactly one public method on TenantResolver, found: " + + publicMethods); + + Method namespaceFor = publicMethods.get(0); + assertEquals("namespaceFor", namespaceFor.getName()); + assertArrayEquals(new Class[] {ApusPrincipal.class}, namespaceFor.getParameterTypes()); + assertEquals(String.class, namespaceFor.getReturnType()); + } + + @Test + void tenantResolverIsFinal() { + // Not subclassable to add a second, overriding namespaceFor with a different signature + // or a loosened contract. + assertTrue(Modifier.isFinal(TenantResolver.class.getModifiers())); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/support/K3sTestKubernetesClientFactory.java b/api/src/test/java/net/onelitefeather/apus/api/support/K3sTestKubernetesClientFactory.java new file mode 100644 index 0000000..7dce33d --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/support/K3sTestKubernetesClientFactory.java @@ -0,0 +1,53 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.support; + +import io.fabric8.kubernetes.client.Config; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.micronaut.context.annotation.Factory; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.context.annotation.Requires; +import io.micronaut.context.annotation.Value; +import jakarta.inject.Singleton; + +/** + * Replaces {@link KubernetesClientFactory}'s {@link KubernetesClient} bean with one pointed at + * the k3s cluster {@code TenantIsolationIntegrationTest} starts via Testcontainers, active only + * under the {@code k3s} Micronaut environment that test declares. Every {@code Fabric8*Repository} + * in this module stays completely unaware of this -- they inject {@link KubernetesClient}, not + * this factory -- so the integration test proves cross-tenant isolation through the real, + * production repository implementations against a real API server, not fakes. + * + *

{@code apus.test.k3s.kubeconfig} is supplied by {@code + * TenantIsolationIntegrationTest#getProperties()} ({@link + * io.micronaut.test.support.TestPropertyProvider}), which starts the container and applies the + * generated CRDs to it before this factory (or anything else in the application context) is + * built. + */ +@Factory +@Requires(env = "k3s") +class K3sTestKubernetesClientFactory { + + @Singleton + @Replaces(bean = KubernetesClient.class, factory = KubernetesClientFactory.class) + KubernetesClient kubernetesClient(@Value("${apus.test.k3s.kubeconfig}") String kubeconfigYaml) { + Config config = Config.fromKubeconfig(kubeconfigYaml); + return new KubernetesClientBuilder().withConfig(config).build(); + } +} diff --git a/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java new file mode 100644 index 0000000..dbe0377 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java @@ -0,0 +1,105 @@ +/** + * Apus - render and host BlueMap maps on Kubernetes. + * Copyright (C) 2026 OneLiteFeather and contributors + *

+ * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + *

+ * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + *

+ * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +package net.onelitefeather.apus.api.support; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.micronaut.security.authentication.Authentication; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; +import org.junit.jupiter.api.Test; + +/** + * {@link Authentication#build} gives us a real, spec-compliant {@link Authentication} without + * needing a mocking library or a running Micronaut context -- neither is on this module's test + * classpath (see task-1-report.md's "Concerns" section). + * + *

Covers both the {@code rest.support.PrincipalResolverTest} and {@code + * events.PrincipalMapperTest} cases the phase 5a consolidation merged into this one class -- see + * {@link PrincipalResolver}'s Javadoc for why the two existed in parallel and why {@code + * "organization"} (not {@code "org"}) is the surviving claim name. + */ +class PrincipalResolverTest { + + private final PrincipalResolver resolver = new PrincipalResolver(); + + @Test + void resolvesSubjectAndRolesAndTenant() { + Authentication auth = Authentication.build( + "alice", List.of("tenant-owner", "tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, "acme")); + + ApusPrincipal principal = resolver.resolve(auth); + + assertEquals("alice", principal.subject()); + assertEquals("acme", principal.tenant()); + assertEquals(Set.of(Role.TENANT_OWNER, Role.TENANT_VIEWER), principal.roles()); + } + + @Test + void theTenantClaimKeyIsOrganization() { + // The specific literal matters: it is design spec §10.3/§8.1's vocabulary, and the one + // the two duplicated bridges disagreed on before this consolidation. Asserted directly + // (not just exercised indirectly above) so a future edit reverting to "org" fails loudly + // here instead of silently splitting the API's tenant resolution again. + assertEquals("organization", PrincipalResolver.TENANT_CLAIM); + } + + @Test + void unrecognisedRoleClaimsAreDroppedNotRejected() { + Authentication auth = Authentication.build( + "bob", List.of("tenant-viewer", "some-future-role"), Map.of(PrincipalResolver.TENANT_CLAIM, "acme")); + + ApusPrincipal principal = resolver.resolve(auth); + + assertEquals(Set.of(Role.TENANT_VIEWER), principal.roles()); + } + + @Test + void missingTenantClaimResolvesToNullNotADefault() { + Authentication auth = Authentication.build("root", List.of("platform-admin"), Map.of()); + + ApusPrincipal principal = resolver.resolve(auth); + + assertNull(principal.tenant()); + assertTrue(principal.isPlatformAdmin()); + } + + @Test + void nonStringTenantClaimResolvesToNullRatherThanThrowing() { + Authentication auth = Authentication.build( + "carol", List.of("tenant-viewer"), Map.of(PrincipalResolver.TENANT_CLAIM, 42)); + + ApusPrincipal principal = resolver.resolve(auth); + + assertNull(principal.tenant()); + } + + @Test + void noRolesAtAllMapsToAnEmptySet() { + Authentication auth = Authentication.build("eve", List.of(), Map.of(PrincipalResolver.TENANT_CLAIM, "acme")); + + ApusPrincipal principal = resolver.resolve(auth); + + assertTrue(principal.roles().isEmpty()); + } +} diff --git a/api/src/test/resources/application-test.yml b/api/src/test/resources/application-test.yml new file mode 100644 index 0000000..18d7d93 --- /dev/null +++ b/api/src/test/resources/application-test.yml @@ -0,0 +1,22 @@ +# Loaded automatically by micronaut-test-junit5 for every @MicronautTest in this module (it +# always activates the "test" environment). Overrides the two placeholders +# src/main/resources/application.yml otherwise requires from the environment +# (APUS_JWT_JWKS_URI/APUS_JWT_ISSUER) with fixed test values, and adds a symmetric HS256 secret +# used both to mint tokens (via the injected TokenGenerator) and to validate them -- so these +# tests exercise the real Micronaut Security JWT filter chain end to end without a reachable +# identity broker. The JWKS URL is never actually dereferenced: every token these tests mint is +# signed with the secret below, which micronaut-security-jwt tries as one of several configured +# signature verifiers and succeeds against, so the (deliberately unreachable) JWKS URL is only +# ever a fallback that is never exercised. +APUS_JWT_JWKS_URI: "http://127.0.0.1:1/unused-jwks-endpoint" +APUS_JWT_ISSUER: "https://apus-test-issuer.internal" + +micronaut: + security: + token: + jwt: + signatures: + secret: + generator: + secret: "phase-5a-test-only-signing-secret-never-used-outside-tests" + jws-algorithm: HS256 diff --git a/docs/superpowers/plans/2026-08-09-phase-5a-api.md b/docs/superpowers/plans/2026-08-09-phase-5a-api.md new file mode 100644 index 0000000..4b1ee89 --- /dev/null +++ b/docs/superpowers/plans/2026-08-09-phase-5a-api.md @@ -0,0 +1,123 @@ +# Apus Phase 5a — API und Autorisierung: Implementierungsplan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Eine REST- und SSE-Schnittstelle über die Custom Resources, die Mandantentrennung durchsetzt — damit die Oberfläche aus Phase 5b darauf aufsetzen kann und niemand YAML schreiben muss. + +**Architecture:** Micronaut. Die Custom Resources sind die Quelle der Wahrheit; die API hält keine eigene Kopie, sondern liest über den Fabric8-Client. **Die API ist der Durchsetzungspunkt für Autorisierung**: Sie prüft erst die Rechte des Aufrufers und spricht danach mit der Kubernetes-API über ihr eigenes ServiceAccount — keine Impersonation. Der Mandant kommt aus dem Token, niemals aus der Anfrage. + +**Tech Stack:** Java 25, Micronaut, Micronaut Security (JWT-Validierung gegen einen OIDC-Issuer), Fabric8 7.8.0, JUnit Jupiter. + +## Global Constraints + +- Java-Toolchain 25, Basispaket `net.onelitefeather.apus.api`, neues Modul `api`. +- **Der Mandant wird ausschließlich aus dem Token abgeleitet.** Kein Endpunkt nimmt einen Mandanten oder Namespace als Parameter entgegen. Das ist die zentrale Sicherheitsregel dieser Phase: Nimmt ein Endpunkt den Namespace aus der Anfrage, kann jeder Nutzer auf fremde Mandanten zugreifen. +- **Rollen:** `platform-admin` (alles), `tenant-owner` (alles im eigenen Mandanten inkl. Mitglieder), `tenant-operator` (Quellen und Karten pflegen, Renders auslösen), `tenant-viewer` (nur lesen). Aus §10.3 der Spec. +- Zugangsdaten und Secret-Inhalte erscheinen **niemals** in Antworten. +- Fehler geben keine Auskunft über die Existenz fremder Ressourcen: Eine Ressource in einem fremden Mandanten wird wie „nicht gefunden" behandelt, nicht wie „verboten" — sonst ist die API ein Verzeichnis fremder Mandanten. +- AGPL-Header über Spotless, Conventional Commits, **keine** Claude-Attribution, Englisch. + +### Was bereits existiert + +Alle Custom Resources aus den Phasen 2a, 2b und 3 unter `net.onelitefeather.apus.operator.api`: `Tenant`, `BlueMapMap`, `BlueMapRender`, `WorldSource`, `WorldIngest`, `BlueMapHosting`. Das `operator`-Modul kann als Abhängigkeit eingebunden werden — die Klassen sind reine Datenhalter ohne Logik, genau dafür wurden sie so geschnitten. + +`TenantReconciler.namespaceFor(...)` bildet den Mandantennamen auf den Namespace ab; der Namespace trägt ein Label mit dem Mandantennamen. + +--- + +## Parallelisierung + +| Gruppe | Aufgaben | Ausführung | +|---|---|---| +| A | Task 1 — Modul, Auth, Mandantenauflösung | sequenziell | +| B | Task 2, Task 3 | **parallel**, je eigener Worktree | +| C | Task 4 — Integrationstest | sequenziell | + +--- + +### Task 1: Modul, Authentifizierung und Mandantenauflösung + +**Files:** +- Modify: `settings.gradle.kts` (Modul `api`, Micronaut-Einträge im Katalog) +- Create: `api/build.gradle.kts` +- Create: `api/src/main/java/net/onelitefeather/apus/api/security/ApusPrincipal.java` +- Create: `api/src/main/java/net/onelitefeather/apus/api/security/TenantResolver.java` +- Create: `api/src/main/java/net/onelitefeather/apus/api/security/Role.java` +- Tests dazu + +**Interfaces:** + +```java +public enum Role { PLATFORM_ADMIN, TENANT_OWNER, TENANT_OPERATOR, TENANT_VIEWER } + +/** Who is calling, derived solely from the validated token. */ +public record ApusPrincipal(String subject, String tenant, Set roles) { + public boolean isPlatformAdmin(); + public boolean canWrite(); // owner or operator +} + +public final class TenantResolver { + /** @return the namespace this principal may act in + * @throws ForbiddenException when the principal has no tenant */ + public String namespaceFor(ApusPrincipal principal); +} +``` + +**Recherchiere die Micronaut-Version real** gegen Maven Central und trage sie in den Inline-Version-Catalog ein. Für die Token-Validierung genügt `micronaut-security-jwt` gegen einen konfigurierbaren Issuer — welcher Identity-Broker davorsteht, ist bewusst offen (§15 der Spec). + +**Tests, die den Kern absichern:** +- Ein Token ohne Mandanten-Claim führt zu einer Ablehnung, nicht zu einem Standardmandanten. +- Ein `platform-admin` darf mandantenübergreifend, ein `tenant-viewer` nicht schreiben. +- Der Namespace wird ausschließlich aus dem Mandanten des Tokens gebildet — es gibt keinen Pfad, über den ein Parameter ihn beeinflusst. + +--- + +### Task 2: Lesende und schreibende Endpunkte *(parallel mit Task 3)* + +> Eigener Worktree. Prüfe zuerst die Basis (`git log --oneline -1`). Ausschließlich Dateien unter `api/src/main/java/net/onelitefeather/apus/api/rest/` und deren Tests. + +Endpunkte gemäß §11.1 der Spec: + +| Endpunkt | Rolle | +|---|---| +| `GET /api/tenants`, `POST /api/tenants` | nur `platform-admin` | +| `GET /api/sources`, `POST /api/sources` | eigener Mandant | +| `GET /api/maps`, `GET /api/maps/{id}` | eigener Mandant | +| `POST /api/maps/{id}/render` | schreibberechtigt; erzeugt einen `BlueMapRender` | +| `GET /api/renders`, `GET /api/renders/{id}` | eigener Mandant | +| `GET /api/hostings` | eigener Mandant | + +**Bindend:** Jeder Endpunkt leitet den Namespace über `TenantResolver` aus dem Token ab. Eine Ressource, die es im eigenen Namespace nicht gibt, ergibt 404 — auch wenn sie in einem fremden existiert. + +**Antwortmodelle sind eigene Typen**, keine durchgereichten Custom Resources. Ein Custom Resource trägt Felder, die niemanden außerhalb angehen (Finalizer, `resourceVersion`, verwaltete Felder) — und würde bei jeder CRD-Änderung ungewollt die öffentliche Schnittstelle ändern. + +**Tests:** je Endpunkt der Gutfall, der Fall „fremder Mandant ergibt 404", und der Fall „unzureichende Rolle ergibt 403". + +--- + +### Task 3: Fortschritt und Logs als Ereignisstrom *(parallel mit Task 2)* + +> Eigener Worktree. Ausschließlich Dateien unter `api/src/main/java/net/onelitefeather/apus/api/events/` und deren Tests. + +- `GET /api/renders/{id}/events` — SSE mit dem Fortschritt aus `BlueMapRender.status.progress`. Die Werte stehen bereits im Status; der Operator hält sie aktuell. Beobachte die Ressource statt zu pollen. +- `GET /api/renders/{id}/logs` — SSE mit den Logzeilen des zugehörigen Jobs. + +**Zur Log-Quelle:** §11.1 nennt Loki, weil Alloy im Cluster ohnehin alle Pod-Logs sammelt und die API so keinen Pod-Zugriff braucht. Prüfe, ob eine Loki-Instanz konfigurierbar erreichbar ist; ist sie es nicht, ist der Fallback der direkte Log-Abruf über den Kubernetes-Client. **Entscheide begründet und dokumentiere es** — der direkte Weg braucht mehr Rechte für das ServiceAccount, was in der Spec bewusst vermieden werden sollte. + +**Bindend:** Auch hier gilt die Mandantenprüfung. Ein Render eines fremden Mandanten ergibt 404, bevor irgendein Strom geöffnet wird — sonst wären Logs fremder Mandanten mitlesbar. + +**Tests:** Der Strom liefert Fortschrittswerte bei Statusänderung; ein fremder Render ergibt 404; der Strom endet sauber, wenn der Render terminal wird (sonst hält jeder Betrachter dauerhaft eine Verbindung). + +--- + +### Task 4: Integrationstest + +Gegen k3s mit den echten CRDs: Ressourcen anlegen, über die API abfragen, Mandantentrennung prüfen. Insbesondere: Ein Token für Mandant A darf Ressourcen von Mandant B weder sehen noch ändern — mit echtem API-Server, nicht nur gegen Mocks. + +Eigene `integrationTest`-Task, nicht Teil von `build`. + +--- + +## Abschluss Phase 5a + +Danach ist die Plattform ohne YAML bedienbar, und Phase 5b kann die Oberfläche darauf setzen. diff --git a/settings.gradle.kts b/settings.gradle.kts index 1110148..85d086d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,6 +1,6 @@ rootProject.name = "Apus" -include("telemetry-addon", "runner", "operator", "ingest") +include("telemetry-addon", "runner", "operator", "ingest", "api") dependencyResolutionManagement { repositories { @@ -51,6 +51,65 @@ dependencyResolutionManagement { // brief asks for rather than a heavyweight addition. version("cron-utils", "9.2.1") + // Micronaut, for the `api` module (phase 5a, task 1) -- REST + SSE over the CRs, + // with Micronaut Security validating JWTs against a configurable issuer (the + // identity broker in front of Apus is intentionally undecided, see design spec + // §15). No Micronaut Gradle plugin is used, in keeping with this project's own + // convention of a hand-written inline catalog rather than a generated one (see + // minestom-knowledge:gradle) -- these three artifact families are added directly, + // the same way josdk/fabric8/aws-sdk are above. Versions verified against Maven + // Central on 2026-08-09 via each artifact's maven-metadata.xml () and + // cross-checked against io.micronaut.platform:micronaut-platform:5.1.0's own POM, + // which pins exactly this combination (micronaut.core.version=5.1.10, + // micronaut.security.version=5.3.1, micronaut.serialization.version=3.1.0) -- + // Micronaut 5 is the current major; there is no newer 4.x release to prefer over it. + version("micronaut", "5.1.10") + version("micronaut-security", "5.3.1") + version("micronaut-serde", "3.1.0") + // Test-only (phase 5a consolidation, part 2): micronaut-test-junit5 versions + // independently of micronaut-core -- verified against Maven Central on 2026-08-09, + // 5.1.0 is the newest io.micronaut.test:micronaut-test-bom release and is the one + // the io.micronaut.platform:micronaut-platform:5.1.0 BOM (already cross-checked + // above for the other Micronaut coordinates) pins for this major. + version("micronaut-test", "5.1.0") + + library("micronaut.core.bom", "io.micronaut", "micronaut-core-bom").versionRef("micronaut") + library("micronaut.inject.java", "io.micronaut", "micronaut-inject-java").withoutVersion() + library("micronaut.http.server.netty", "io.micronaut", "micronaut-http-server-netty").withoutVersion() + library("micronaut.runtime", "io.micronaut", "micronaut-runtime").withoutVersion() + // Test-only: backs the `@Client("/") HttpClient` micronaut-test-junit5 injects into + // `@MicronautTest` classes, so the phase 5a consolidation's HTTP-level security tests + // (401/403/404) exercise the real embedded server and filter chain instead of calling + // controller methods directly. + library("micronaut.http.client", "io.micronaut", "micronaut-http-client").withoutVersion() + + library("micronaut.security.bom", "io.micronaut.security", "micronaut-security-bom") + .versionRef("micronaut-security") + library("micronaut.security.jwt", "io.micronaut.security", "micronaut-security-jwt").withoutVersion() + library("micronaut.security.annotations", "io.micronaut.security", "micronaut-security-annotations") + .withoutVersion() + + library("micronaut.serde.bom", "io.micronaut.serde", "micronaut-serde-bom").versionRef("micronaut-serde") + library("micronaut.serde.jackson", "io.micronaut.serde", "micronaut-serde-jackson").withoutVersion() + library("micronaut.serde.processor", "io.micronaut.serde", "micronaut-serde-processor").withoutVersion() + + library("micronaut.test.bom", "io.micronaut.test", "micronaut-test-bom").versionRef("micronaut-test") + library("micronaut.test.junit5", "io.micronaut.test", "micronaut-test-junit5").withoutVersion() + + // The full fabric8 client (not just kubernetes-client-api): the `api` module reads + // Tenant/BlueMapMap/BlueMapRender/... CRs directly (see operator dependency below), + // and unlike :operator it does not get these transitively, because :operator itself + // depends on JOSDK/fabric8 via `implementation`, which -- correctly -- does not leak + // onto a downstream project's compile classpath (verified directly: referencing + // Tenant from a first draft of this module failed to compile with "Klassendatei für + // io.fabric8.kubernetes.client.CustomResource nicht gefunden" until this was added). + // kubernetes-httpclient-jdk is picked as the HTTP engine over the vertx/okhttp + // options fabric8 7.x supports: it needs no extra dependency of its own, and -- more + // importantly -- avoids pulling a second, differently-versioned Netty into a module + // whose own HTTP server (micronaut-http-server-netty, above) already brings one. + library("fabric8.kubernetes.client", "io.fabric8", "kubernetes-client").versionRef("fabric8") + library("fabric8.httpclient.jdk", "io.fabric8", "kubernetes-httpclient-jdk").versionRef("fabric8") + library("bluemap.api", "de.bluecolored", "bluemap-api").versionRef("bluemap-api") library("bluemap.core", "de.bluecolored", "bluemap-core").versionRef("bluemap") library("bluemap.common", "de.bluecolored", "bluemap-common").versionRef("bluemap")