From ecd923448b76407f701a911380599fba248b2dec Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 03:31:54 +0200 Subject: [PATCH 1/8] docs: add phase 5a implementation plan for the api --- .../plans/2026-08-09-phase-5a-api.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-09-phase-5a-api.md 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. From 9b2588164eef416da39f6db6578d7e3e6e956317 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 03:48:18 +0200 Subject: [PATCH 2/8] feat(api): add module, JWT auth wiring and tenant resolution Sets up the new `api` module (Micronaut 5.1.10 REST/SSE over the existing CRs) and the security core the rest of phase 5a builds on: Role, the ApusPrincipal derived solely from the validated token, and TenantResolver, whose namespaceFor(ApusPrincipal) is the only path from a caller to a namespace -- no endpoint can take a tenant or namespace as a parameter. A token without a tenant claim is rejected, not defaulted; canWrite() is owner/operator only, never platform-admin. TenantResolver's namespace convention is cross-checked in tests against TenantReconciler's own, rather than duplicated as a second source of truth. --- api/build.gradle.kts | 51 +++++++ .../onelitefeather/apus/api/Application.java | 30 ++++ .../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/src/main/resources/application.yml | 18 +++ .../apus/api/security/ApusPrincipalTest.java | 127 +++++++++++++++++ .../apus/api/security/RoleTest.java | 65 +++++++++ .../apus/api/security/TenantResolverTest.java | 130 ++++++++++++++++++ settings.gradle.kts | 49 ++++++- 11 files changed, 702 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/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/resources/application.yml 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 diff --git a/api/build.gradle.kts b/api/build.gradle.kts new file mode 100644 index 0000000..0409264 --- /dev/null +++ b/api/build.gradle.kts @@ -0,0 +1,51 @@ +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) +} + +application { + mainClass.set("net.onelitefeather.apus.api.Application") +} 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/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/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/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/settings.gradle.kts b/settings.gradle.kts index 1110148..5d2be3a 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,53 @@ 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") + + 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() + + 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.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") From e0d43642e5244bc013c1c35ca0a7c47b0cb802de Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 04:09:59 +0200 Subject: [PATCH 3/8] feat(api): add tenant-scoped REST endpoints for maps, sources, renders, hostings Adds the read/write REST layer over the existing CRs: /api/tenants (platform-admin only, cluster-scoped), /api/sources, /api/maps (+ POST .../render, which creates a BlueMapRender), /api/renders, and /api/hostings. Every endpoint resolves its namespace exclusively via TenantResolver -- no endpoint takes a tenant, namespace, or mandant as a parameter -- and a foreign tenant's resource 404s exactly like one that does not exist, never 403, so the API cannot be used to probe other tenants. Response bodies are dedicated @Serdeable records mapped from the CRs, never the CRs themselves, and drop Secret names (WorldSource's credentialsSecretRef, BlueMapMap's bucket secretName) that a response must never carry. Adds the Authentication -> ApusPrincipal bridge (PrincipalResolver) task 1 left for the first controller to build, plus 403/404/400 exception mapping for the exceptions task 1's ForbiddenException and this task's own NotFoundException/BadRequestException. Role gates are manual checks rather than @Secured role strings, since this module's test classpath has no way to exercise Micronaut's security AOP interceptor directly. The fabric8 KubernetesClient is wrapped in a rest/-local bean type (RestKubernetesClient) rather than exposed directly, so task 3's own client wiring under events/ cannot collide with it as an ambiguous singleton. --- .../hosting/BlueMapHostingController.java | 61 +++++++ .../hosting/BlueMapHostingRepository.java | 32 ++++ .../rest/hosting/BlueMapHostingResponse.java | 50 ++++++ .../FabricBlueMapHostingRepository.java | 40 +++++ .../api/rest/map/BlueMapMapController.java | 136 ++++++++++++++++ .../api/rest/map/BlueMapMapRepository.java | 37 +++++ .../apus/api/rest/map/BlueMapMapResponse.java | 83 ++++++++++ .../rest/map/FabricBlueMapMapRepository.java | 47 ++++++ .../api/rest/map/TriggerRenderRequest.java | 29 ++++ .../rest/render/BlueMapRenderController.java | 85 ++++++++++ .../rest/render/BlueMapRenderRepository.java | 38 +++++ .../rest/render/BlueMapRenderResponse.java | 61 +++++++ .../render/FabricBlueMapRenderRepository.java | 52 ++++++ .../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 +++++ .../api/rest/support/PrincipalResolver.java | 79 +++++++++ .../rest/support/RestKubernetesClient.java | 55 +++++++ .../apus/api/rest/support/TenantAccess.java | 46 ++++++ .../api/rest/tenant/CreateTenantRequest.java | 31 ++++ .../rest/tenant/FabricTenantRepository.java | 51 ++++++ .../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 | 52 ++++++ .../worldsource/WorldSourceController.java | 152 ++++++++++++++++++ .../worldsource/WorldSourceRepository.java | 37 +++++ .../rest/worldsource/WorldSourceResponse.java | 71 ++++++++ .../hosting/BlueMapHostingControllerTest.java | 69 ++++++++ .../InMemoryBlueMapHostingRepository.java | 43 +++++ .../rest/map/BlueMapMapControllerTest.java | 144 +++++++++++++++++ .../map/InMemoryBlueMapMapRepository.java | 53 ++++++ .../map/InMemoryBlueMapRenderRepository.java | 65 ++++++++ .../render/BlueMapRenderControllerTest.java | 96 +++++++++++ .../InMemoryBlueMapRenderRepository.java | 59 +++++++ .../rest/support/ExceptionHandlerTest.java | 57 +++++++ .../rest/support/PrincipalResolverTest.java | 81 ++++++++++ .../api/rest/support/TenantAccessTest.java | 59 +++++++ .../rest/tenant/InMemoryTenantRepository.java | 55 +++++++ .../api/rest/tenant/TenantControllerTest.java | 95 +++++++++++ .../InMemoryWorldSourceRepository.java | 62 +++++++ .../WorldSourceControllerTest.java | 103 ++++++++++++ 46 files changed, 2860 insertions(+) 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/PrincipalResolver.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.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/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/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/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/PrincipalResolverTest.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 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..2bdda04 --- /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.PrincipalResolver; +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; + +/** {@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..bedac42 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/hosting/FabricBlueMapHostingRepository.java @@ -0,0 +1,40 @@ +/** + * 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.api.rest.support.RestKubernetesClient; +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(RestKubernetesClient restKubernetesClient) { + this.client = restKubernetesClient.get(); + } + + @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..b063af2 --- /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.PrincipalResolver; +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.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..408f412 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/map/FabricBlueMapMapRepository.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.map; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; +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(RestKubernetesClient restKubernetesClient) { + this.client = restKubernetesClient.get(); + } + + @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..7e170f8 --- /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.PrincipalResolver; +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; + +/** + * {@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..3a07991 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/render/FabricBlueMapRenderRepository.java @@ -0,0 +1,52 @@ +/** + * 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.api.rest.support.RestKubernetesClient; +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(RestKubernetesClient restKubernetesClient) { + this.client = restKubernetesClient.get(); + } + + @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/PrincipalResolver.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/PrincipalResolver.java new file mode 100644 index 0000000..bfa6d50 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/PrincipalResolver.java @@ -0,0 +1,79 @@ +/** + * 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.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. This is the first controller-facing code to + * need it, so it lands here. + * + *

Every controller in {@code rest/} goes through this class instead of reading {@link + * Authentication} itself, so there is exactly one place to update once a broker is chosen and + * the placeholder claim key below turns out to be wrong. + */ +@Singleton +public class PrincipalResolver { + + /** + * The organisation/tenant claim key read from {@link Authentication#getAttributes()}. + * + *

This is a placeholder, not a confirmed contract. Design spec §15 leaves the + * choice between Keycloak (26+, with Organizations) and Zitadel open, and with it the exact + * claim name each would put the caller's organisation under -- Keycloak's Organizations + * feature and Zitadel's org model do not share a claim name. "org" is short, broker-neutral, + * and easy to grep for; whoever wires the chosen broker's token mapping into this module + * should update this single constant rather than hunt for scattered literals. + * + *

Public (not package-private) so every controller test across {@code rest/}'s + * sub-packages can build a realistic {@link Authentication} against the same claim key + * this class actually reads, instead of duplicating the literal "org" in each test package. + */ + public static final String TENANT_CLAIM = "org"; + + /** + * @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/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java new file mode 100644 index 0000000..e245c34 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.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.support; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import jakarta.inject.Singleton; + +/** + * The single fabric8 {@link KubernetesClient} shared by every repository under {@code rest/}. + * + *

Deliberately not exposed as a bean of type {@link KubernetesClient} itself. Task 3 (SSE + * progress/log streaming under {@code events/}) is developed in a parallel worktree against the + * same module and will need its own client wiring for watches; if both tasks registered an + * unqualified {@code @Singleton KubernetesClient}, every injection point requesting that type + * anywhere in the module -- including ones neither task added -- would become ambiguous the + * moment both land, which Micronaut only reports at context-build time, not at compile time. + * Neither task owns a build file to coordinate a shared factory through (see task-2-brief.md's + * file restriction), so the simplest conflict-proof option is for each task's client wiring to + * live behind its own bean type. This class is {@code rest/}'s. + */ +@Singleton +public class RestKubernetesClient implements AutoCloseable { + + private final KubernetesClient delegate; + + public RestKubernetesClient() { + this.delegate = new KubernetesClientBuilder().build(); + } + + /** The underlying client. Never held onto beyond the caller's own method scope. */ + public KubernetesClient get() { + return delegate; + } + + @Override + public void close() { + delegate.close(); + } +} 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..4282dd6 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/tenant/FabricTenantRepository.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.tenant; + +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; +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(RestKubernetesClient restKubernetesClient) { + this.client = restKubernetesClient.get(); + } + + @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..826c670 --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.ForbiddenException; +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..8cdd11f --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/rest/worldsource/FabricWorldSourceRepository.java @@ -0,0 +1,52 @@ +/** + * 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.api.rest.support.RestKubernetesClient; +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(RestKubernetesClient restKubernetesClient) { + this.client = restKubernetesClient.get(); + } + + @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..279654e --- /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.PrincipalResolver; +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.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/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..1314917 --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +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/BlueMapMapControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/map/BlueMapMapControllerTest.java new file mode 100644 index 0000000..1653292 --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +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/render/BlueMapRenderControllerTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/render/BlueMapRenderControllerTest.java new file mode 100644 index 0000000..8b838dc --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +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/PrincipalResolverTest.java b/api/src/test/java/net/onelitefeather/apus/api/rest/support/PrincipalResolverTest.java new file mode 100644 index 0000000..52c0478 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/rest/support/PrincipalResolverTest.java @@ -0,0 +1,81 @@ +/** + * 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 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 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). + */ +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(java.util.Set.of(Role.TENANT_OWNER, Role.TENANT_VIEWER), principal.roles()); + } + + @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(java.util.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()); + } +} 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..a2280d7 --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ForbiddenException; +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..f12b3a3 --- /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.rest.support.PrincipalResolver; +import net.onelitefeather.apus.api.security.ForbiddenException; +import net.onelitefeather.apus.api.security.TenantResolver; +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)); + } +} From 0d67d3a536124da9434116aabbbf2fcc92974965 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 04:11:21 +0200 Subject: [PATCH 4/8] feat(api): add server-sent event streams for render progress and logs Taken from the parallel task's worktree, which produced the files but did not commit them. --- .../api/events/Fabric8RenderRepository.java | 51 +++ .../api/events/KubernetesClientFactory.java | 50 +++ .../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/PrincipalMapper.java | 57 ++++ .../apus/api/events/RenderPhases.java | 44 +++ .../apus/api/events/RenderProgress.java | 46 +++ .../apus/api/events/RenderRepository.java | 46 +++ .../api/events/RenderStreamController.java | 222 +++++++++++++ .../apus/api/events/SseSource.java | 156 +++++++++ .../apus/api/events/LogSourceFactoryTest.java | 59 ++++ .../apus/api/events/LokiLogSourceTest.java | 80 +++++ .../apus/api/events/PrincipalMapperTest.java | 74 +++++ .../apus/api/events/RenderPhasesTest.java | 54 ++++ .../events/RenderStreamControllerTest.java | 296 ++++++++++++++++++ .../apus/api/events/SseSourceTest.java | 193 ++++++++++++ 18 files changed, 1763 insertions(+) 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/KubernetesClientFactory.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/PrincipalMapper.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/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/PrincipalMapperTest.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 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/KubernetesClientFactory.java b/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.java new file mode 100644 index 0000000..5c166bf --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.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.events; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientBuilder; +import io.micronaut.context.annotation.Factory; +import jakarta.inject.Singleton; + +/** + * Produces the {@link KubernetesClient} bean this package's watches and (if the direct fallback + * is active, see {@link LogSourceFactory}) pod-log reads run on. 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. + * + *

Known duplication risk: no shared, module-wide place to declare this bean exists yet + * (that would live outside {@code events/}, out of this task's file scope -- see the task 3 + * report). Task 2's {@code rest/} controllers need a {@link KubernetesClient} too and, built in + * an isolated worktree in parallel, may declare an equivalent factory of its own; if so, the + * module will end up with two {@code @Singleton KubernetesClient} bean definitions after merging, + * which is only a problem where both are injected into the *same* class without a qualifier + * (Micronaut allows multiple beans of one type to coexist; ambiguity only surfaces at an + * unqualified injection point). Hoisting one shared factory out of both packages once merged is + * the right follow-up. + */ +@Factory +class KubernetesClientFactory { + + @Singleton + KubernetesClient kubernetesClient() { + return new KubernetesClientBuilder().build(); + } +} 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/PrincipalMapper.java b/api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.java new file mode 100644 index 0000000..ade29ba --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.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.events; + +import io.micronaut.security.authentication.Authentication; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import net.onelitefeather.apus.api.security.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; + +/** + * Builds an {@link ApusPrincipal} from Micronaut Security's validated {@link Authentication} -- + * the bridge task 1's report flagged as missing (its scope was deliberately only the pure + * security-invariant classes), to be added "in its own directory ... by whichever task builds + * the first controller". + * + *

Tenant claim key: {@code organization}. Not fixed anywhere yet at the time of + * writing (identity broker undecided, design spec §15) -- picked here to match the vocabulary + * the design spec itself already uses for this exact concept: {@code Tenant.spec.auth + * .organization} (§8.1) and "der Organisations-Claim im Token bestimmt den Mandanten" (§10.3). + * Task 2's {@code rest/} controllers need the identical bridge and, built in an isolated + * worktree in parallel, may pick a different key -- if so, this needs reconciling to one value + * once both are chosen for real against whichever broker (Keycloak/Zitadel) §15 lands on; see + * the task 3 report. + */ +final class PrincipalMapper { + + /** See the class Javadoc for why this specific claim name. */ + static final String TENANT_CLAIM = "organization"; + + private PrincipalMapper() {} + + static ApusPrincipal from(Authentication authentication) { + Set roles = authentication.getRoles().stream() + .map(Role::fromClaim) + .flatMap(Optional::stream) + .collect(Collectors.toUnmodifiableSet()); + Object tenant = authentication.getAttributes().get(TENANT_CLAIM); + return new ApusPrincipal(authentication.getName(), tenant == null ? null : tenant.toString(), roles); + } +} 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..c3697e8 --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java @@ -0,0 +1,222 @@ +/** + * 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.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; + + RenderStreamController(RenderRepository renderRepository, TenantResolver tenantResolver, LogSource logSource) { + this.renderRepository = renderRepository; + this.tenantResolver = tenantResolver; + this.logSource = logSource; + } + + @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 = PrincipalMapper.from(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..30fe01b --- /dev/null +++ b/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java @@ -0,0 +1,156 @@ +/** + * 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}, so it cannot + * be imported from this module's main source without adding an explicit dependency -- out of + * scope for this task, see the task 3 report). + * + *

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/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/PrincipalMapperTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java new file mode 100644 index 0000000..e84bb74 --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java @@ -0,0 +1,74 @@ +/** + * 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.assertNull; +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.ApusPrincipal; +import net.onelitefeather.apus.api.security.Role; +import org.junit.jupiter.api.Test; + +class PrincipalMapperTest { + + @Test + void mapsNameRolesAndTheOrganizationClaim() { + Authentication authentication = Authentication.build( + "carol", List.of("tenant-operator"), Map.of("organization", "acme")); + + ApusPrincipal principal = PrincipalMapper.from(authentication); + + assertEquals("carol", principal.subject()); + assertEquals("acme", principal.tenant()); + assertEquals(java.util.Set.of(Role.TENANT_OPERATOR), principal.roles()); + } + + @Test + void missingOrganizationClaimBecomesNullTenant() { + Authentication authentication = Authentication.build("root", List.of("platform-admin"), Map.of()); + + ApusPrincipal principal = PrincipalMapper.from(authentication); + + assertNull(principal.tenant()); + } + + @Test + void unknownRoleClaimsAreDroppedNotRejected() { + // Mirrors Role.fromClaim's own contract: one unrecognised role in the claim must not + // blow up the whole mapping, it is simply not part of the resulting principal's roles. + Authentication authentication = Authentication.build( + "dave", List.of("tenant-viewer", "some-future-role"), Map.of("organization", "acme")); + + ApusPrincipal principal = PrincipalMapper.from(authentication); + + assertEquals(java.util.Set.of(Role.TENANT_VIEWER), principal.roles()); + } + + @Test + void noRolesAtAllMapsToAnEmptySet() { + Authentication authentication = Authentication.build("eve", List.of(), Map.of("organization", "acme")); + + ApusPrincipal principal = PrincipalMapper.from(authentication); + + assertTrue(principal.roles().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..b67df6b --- /dev/null +++ b/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java @@ -0,0 +1,296 @@ +/** + * 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.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()); + + 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()); + + 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()); + + 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()); + + 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()); + 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); + + 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); + + 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()); + + 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()); + + 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()); + } +} From 15b6120b8a7e56a5f7817036be44c75cd777bde8 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 04:32:08 +0200 Subject: [PATCH 5/8] refactor(api): merge duplicated Kubernetes client and tenant-claim bridges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 (rest/) and task 3 (events/) each landed their own KubernetesClient wiring and their own Authentication -> ApusPrincipal bridge while working in parallel worktrees that could not see each other or touch a shared build file. Merges both pairs into net.onelitefeather.apus.api.support: - KubernetesClientFactory: the single @Singleton KubernetesClient bean, replacing events/KubernetesClientFactory and the rest/-local RestKubernetesClient wrapper. Every Fabric8*Repository now injects KubernetesClient directly instead of unwrapping RestKubernetesClient. - PrincipalResolver: the single Authentication -> ApusPrincipal bridge, replacing events/PrincipalMapper and rest/support/PrincipalResolver. The two disagreed on the tenant claim name ("organization" vs "org") -- the more dangerous half of the duplication, since it meant the API could resolve the same token's tenant differently depending on which endpoint handled the request. "organization" wins: it matches design spec §8.1's example manifest and §10.3's own vocabulary. The constant is now declared in exactly one place. RenderStreamController now takes PrincipalResolver via constructor injection instead of calling the deleted PrincipalMapper statically. PrincipalResolverTest merges both prior test classes' cases, including noRolesAtAllMapsToAnEmptySet from the events-side test and an explicit assertion that TENANT_CLAIM is "organization". All 101 pre-existing api module tests still pass unchanged. --- .../api/events/KubernetesClientFactory.java | 50 ------------- .../apus/api/events/PrincipalMapper.java | 57 -------------- .../api/events/RenderStreamController.java | 11 ++- .../hosting/BlueMapHostingController.java | 2 +- .../FabricBlueMapHostingRepository.java | 5 +- .../api/rest/map/BlueMapMapController.java | 2 +- .../rest/map/FabricBlueMapMapRepository.java | 5 +- .../rest/render/BlueMapRenderController.java | 2 +- .../render/FabricBlueMapRenderRepository.java | 5 +- .../rest/support/RestKubernetesClient.java | 55 -------------- .../rest/tenant/FabricTenantRepository.java | 5 +- .../api/rest/tenant/TenantController.java | 2 +- .../FabricWorldSourceRepository.java | 5 +- .../worldsource/WorldSourceController.java | 2 +- .../api/support/KubernetesClientFactory.java | 51 +++++++++++++ .../{rest => }/support/PrincipalResolver.java | 43 +++++------ .../apus/api/events/PrincipalMapperTest.java | 74 ------------------- .../events/RenderStreamControllerTest.java | 19 ++--- .../hosting/BlueMapHostingControllerTest.java | 2 +- .../rest/map/BlueMapMapControllerTest.java | 2 +- .../render/BlueMapRenderControllerTest.java | 2 +- .../api/rest/tenant/TenantControllerTest.java | 2 +- .../WorldSourceControllerTest.java | 2 +- .../support/PrincipalResolverTest.java | 30 +++++++- 24 files changed, 139 insertions(+), 296 deletions(-) delete mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.java delete mode 100644 api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.java delete mode 100644 api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java create mode 100644 api/src/main/java/net/onelitefeather/apus/api/support/KubernetesClientFactory.java rename api/src/main/java/net/onelitefeather/apus/api/{rest => }/support/PrincipalResolver.java (58%) delete mode 100644 api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java rename api/src/test/java/net/onelitefeather/apus/api/{rest => }/support/PrincipalResolverTest.java (68%) diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.java b/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.java deleted file mode 100644 index 5c166bf..0000000 --- a/api/src/main/java/net/onelitefeather/apus/api/events/KubernetesClientFactory.java +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 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.fabric8.kubernetes.client.KubernetesClientBuilder; -import io.micronaut.context.annotation.Factory; -import jakarta.inject.Singleton; - -/** - * Produces the {@link KubernetesClient} bean this package's watches and (if the direct fallback - * is active, see {@link LogSourceFactory}) pod-log reads run on. 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. - * - *

Known duplication risk: no shared, module-wide place to declare this bean exists yet - * (that would live outside {@code events/}, out of this task's file scope -- see the task 3 - * report). Task 2's {@code rest/} controllers need a {@link KubernetesClient} too and, built in - * an isolated worktree in parallel, may declare an equivalent factory of its own; if so, the - * module will end up with two {@code @Singleton KubernetesClient} bean definitions after merging, - * which is only a problem where both are injected into the *same* class without a qualifier - * (Micronaut allows multiple beans of one type to coexist; ambiguity only surfaces at an - * unqualified injection point). Hoisting one shared factory out of both packages once merged is - * the right follow-up. - */ -@Factory -class KubernetesClientFactory { - - @Singleton - KubernetesClient kubernetesClient() { - return new KubernetesClientBuilder().build(); - } -} diff --git a/api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.java b/api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.java deleted file mode 100644 index ade29ba..0000000 --- a/api/src/main/java/net/onelitefeather/apus/api/events/PrincipalMapper.java +++ /dev/null @@ -1,57 +0,0 @@ -/** - * 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.security.authentication.Authentication; -import java.util.Optional; -import java.util.Set; -import java.util.stream.Collectors; -import net.onelitefeather.apus.api.security.ApusPrincipal; -import net.onelitefeather.apus.api.security.Role; - -/** - * Builds an {@link ApusPrincipal} from Micronaut Security's validated {@link Authentication} -- - * the bridge task 1's report flagged as missing (its scope was deliberately only the pure - * security-invariant classes), to be added "in its own directory ... by whichever task builds - * the first controller". - * - *

Tenant claim key: {@code organization}. Not fixed anywhere yet at the time of - * writing (identity broker undecided, design spec §15) -- picked here to match the vocabulary - * the design spec itself already uses for this exact concept: {@code Tenant.spec.auth - * .organization} (§8.1) and "der Organisations-Claim im Token bestimmt den Mandanten" (§10.3). - * Task 2's {@code rest/} controllers need the identical bridge and, built in an isolated - * worktree in parallel, may pick a different key -- if so, this needs reconciling to one value - * once both are chosen for real against whichever broker (Keycloak/Zitadel) §15 lands on; see - * the task 3 report. - */ -final class PrincipalMapper { - - /** See the class Javadoc for why this specific claim name. */ - static final String TENANT_CLAIM = "organization"; - - private PrincipalMapper() {} - - static ApusPrincipal from(Authentication authentication) { - Set roles = authentication.getRoles().stream() - .map(Role::fromClaim) - .flatMap(Optional::stream) - .collect(Collectors.toUnmodifiableSet()); - Object tenant = authentication.getAttributes().get(TENANT_CLAIM); - return new ApusPrincipal(authentication.getName(), tenant == null ? null : tenant.toString(), roles); - } -} 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 index c3697e8..db929f1 100644 --- a/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java +++ b/api/src/main/java/net/onelitefeather/apus/api/events/RenderStreamController.java @@ -32,6 +32,7 @@ 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; @@ -66,11 +67,17 @@ 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) { + 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) @@ -129,7 +136,7 @@ Publisher> logs(Authentication authentication, @PathVariable Strin * including when it names one in a different tenant's namespace */ private BlueMapRender requireRender(Authentication authentication, String id) { - ApusPrincipal principal = PrincipalMapper.from(authentication); + ApusPrincipal principal = principalResolver.resolve(authentication); String namespace; try { namespace = tenantResolver.namespaceFor(principal); 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 index 2bdda04..f343868 100644 --- 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 @@ -24,11 +24,11 @@ import io.micronaut.security.authentication.Authentication; import io.micronaut.security.rules.SecurityRule; import java.util.List; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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") 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 index bedac42..900bc21 100644 --- 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 @@ -20,7 +20,6 @@ import io.fabric8.kubernetes.client.KubernetesClient; import jakarta.inject.Singleton; import java.util.List; -import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; import net.onelitefeather.apus.operator.api.BlueMapHosting; /** {@link BlueMapHostingRepository} backed by a real {@link KubernetesClient}. */ @@ -29,8 +28,8 @@ public class FabricBlueMapHostingRepository implements BlueMapHostingRepository private final KubernetesClient client; - public FabricBlueMapHostingRepository(RestKubernetesClient restKubernetesClient) { - this.client = restKubernetesClient.get(); + public FabricBlueMapHostingRepository(KubernetesClient client) { + this.client = client; } @Override 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 index b063af2..1fa1479 100644 --- 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 @@ -31,11 +31,11 @@ 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.PrincipalResolver; 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; 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 index 408f412..cb0ea26 100644 --- 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 @@ -21,7 +21,6 @@ import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; -import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; import net.onelitefeather.apus.operator.api.BlueMapMap; /** {@link BlueMapMapRepository} backed by a real {@link KubernetesClient}. */ @@ -30,8 +29,8 @@ public class FabricBlueMapMapRepository implements BlueMapMapRepository { private final KubernetesClient client; - public FabricBlueMapMapRepository(RestKubernetesClient restKubernetesClient) { - this.client = restKubernetesClient.get(); + public FabricBlueMapMapRepository(KubernetesClient client) { + this.client = client; } @Override 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 index 7e170f8..e9b7021 100644 --- 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 @@ -26,11 +26,11 @@ 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.PrincipalResolver; 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 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 index 3a07991..edadb67 100644 --- 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 @@ -21,7 +21,6 @@ import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; -import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; import net.onelitefeather.apus.operator.api.BlueMapRender; /** {@link BlueMapRenderRepository} backed by a real {@link KubernetesClient}. */ @@ -30,8 +29,8 @@ public class FabricBlueMapRenderRepository implements BlueMapRenderRepository { private final KubernetesClient client; - public FabricBlueMapRenderRepository(RestKubernetesClient restKubernetesClient) { - this.client = restKubernetesClient.get(); + public FabricBlueMapRenderRepository(KubernetesClient client) { + this.client = client; } @Override diff --git a/api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java b/api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java deleted file mode 100644 index e245c34..0000000 --- a/api/src/main/java/net/onelitefeather/apus/api/rest/support/RestKubernetesClient.java +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 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.client.KubernetesClient; -import io.fabric8.kubernetes.client.KubernetesClientBuilder; -import jakarta.inject.Singleton; - -/** - * The single fabric8 {@link KubernetesClient} shared by every repository under {@code rest/}. - * - *

Deliberately not exposed as a bean of type {@link KubernetesClient} itself. Task 3 (SSE - * progress/log streaming under {@code events/}) is developed in a parallel worktree against the - * same module and will need its own client wiring for watches; if both tasks registered an - * unqualified {@code @Singleton KubernetesClient}, every injection point requesting that type - * anywhere in the module -- including ones neither task added -- would become ambiguous the - * moment both land, which Micronaut only reports at context-build time, not at compile time. - * Neither task owns a build file to coordinate a shared factory through (see task-2-brief.md's - * file restriction), so the simplest conflict-proof option is for each task's client wiring to - * live behind its own bean type. This class is {@code rest/}'s. - */ -@Singleton -public class RestKubernetesClient implements AutoCloseable { - - private final KubernetesClient delegate; - - public RestKubernetesClient() { - this.delegate = new KubernetesClientBuilder().build(); - } - - /** The underlying client. Never held onto beyond the caller's own method scope. */ - public KubernetesClient get() { - return delegate; - } - - @Override - public void close() { - delegate.close(); - } -} 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 index 4282dd6..a4fd2da 100644 --- 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 @@ -21,7 +21,6 @@ import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; -import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; import net.onelitefeather.apus.operator.api.Tenant; /** {@link TenantRepository} backed by a real {@link KubernetesClient}. */ @@ -30,8 +29,8 @@ public class FabricTenantRepository implements TenantRepository { private final KubernetesClient client; - public FabricTenantRepository(RestKubernetesClient restKubernetesClient) { - this.client = restKubernetesClient.get(); + public FabricTenantRepository(KubernetesClient client) { + this.client = client; } @Override 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 index 826c670..9f0aefb 100644 --- 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 @@ -27,9 +27,9 @@ import io.micronaut.security.rules.SecurityRule; import java.util.List; import net.onelitefeather.apus.api.rest.support.BadRequestException; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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 index 8cdd11f..aa8485e 100644 --- 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 @@ -21,7 +21,6 @@ import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; -import net.onelitefeather.apus.api.rest.support.RestKubernetesClient; import net.onelitefeather.apus.operator.api.WorldSource; /** {@link WorldSourceRepository} backed by a real {@link KubernetesClient}. */ @@ -30,8 +29,8 @@ public class FabricWorldSourceRepository implements WorldSourceRepository { private final KubernetesClient client; - public FabricWorldSourceRepository(RestKubernetesClient restKubernetesClient) { - this.client = restKubernetesClient.get(); + public FabricWorldSourceRepository(KubernetesClient client) { + this.client = client; } @Override 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 index 279654e..dfad720 100644 --- 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 @@ -29,11 +29,11 @@ import java.util.List; import java.util.Set; import net.onelitefeather.apus.api.rest.support.BadRequestException; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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/rest/support/PrincipalResolver.java b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java similarity index 58% rename from api/src/main/java/net/onelitefeather/apus/api/rest/support/PrincipalResolver.java rename to api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java index bfa6d50..4829e99 100644 --- a/api/src/main/java/net/onelitefeather/apus/api/rest/support/PrincipalResolver.java +++ b/api/src/main/java/net/onelitefeather/apus/api/support/PrincipalResolver.java @@ -15,7 +15,7 @@ * 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; +package net.onelitefeather.apus.api.support; import io.micronaut.security.authentication.Authentication; import jakarta.inject.Singleton; @@ -30,31 +30,32 @@ * 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. This is the first controller-facing code to - * need it, so it lands here. + * (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. * - *

Every controller in {@code rest/} goes through this class instead of reading {@link - * Authentication} itself, so there is exactly one place to update once a broker is chosen and - * the placeholder claim key below turns out to be wrong. + *

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 { - /** - * The organisation/tenant claim key read from {@link Authentication#getAttributes()}. - * - *

This is a placeholder, not a confirmed contract. Design spec §15 leaves the - * choice between Keycloak (26+, with Organizations) and Zitadel open, and with it the exact - * claim name each would put the caller's organisation under -- Keycloak's Organizations - * feature and Zitadel's org model do not share a claim name. "org" is short, broker-neutral, - * and easy to grep for; whoever wires the chosen broker's token mapping into this module - * should update this single constant rather than hunt for scattered literals. - * - *

Public (not package-private) so every controller test across {@code rest/}'s - * sub-packages can build a realistic {@link Authentication} against the same claim key - * this class actually reads, instead of duplicating the literal "org" in each test package. - */ - public static final String TENANT_CLAIM = "org"; + /** 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 diff --git a/api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java b/api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java deleted file mode 100644 index e84bb74..0000000 --- a/api/src/test/java/net/onelitefeather/apus/api/events/PrincipalMapperTest.java +++ /dev/null @@ -1,74 +0,0 @@ -/** - * 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.assertNull; -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.ApusPrincipal; -import net.onelitefeather.apus.api.security.Role; -import org.junit.jupiter.api.Test; - -class PrincipalMapperTest { - - @Test - void mapsNameRolesAndTheOrganizationClaim() { - Authentication authentication = Authentication.build( - "carol", List.of("tenant-operator"), Map.of("organization", "acme")); - - ApusPrincipal principal = PrincipalMapper.from(authentication); - - assertEquals("carol", principal.subject()); - assertEquals("acme", principal.tenant()); - assertEquals(java.util.Set.of(Role.TENANT_OPERATOR), principal.roles()); - } - - @Test - void missingOrganizationClaimBecomesNullTenant() { - Authentication authentication = Authentication.build("root", List.of("platform-admin"), Map.of()); - - ApusPrincipal principal = PrincipalMapper.from(authentication); - - assertNull(principal.tenant()); - } - - @Test - void unknownRoleClaimsAreDroppedNotRejected() { - // Mirrors Role.fromClaim's own contract: one unrecognised role in the claim must not - // blow up the whole mapping, it is simply not part of the resulting principal's roles. - Authentication authentication = Authentication.build( - "dave", List.of("tenant-viewer", "some-future-role"), Map.of("organization", "acme")); - - ApusPrincipal principal = PrincipalMapper.from(authentication); - - assertEquals(java.util.Set.of(Role.TENANT_VIEWER), principal.roles()); - } - - @Test - void noRolesAtAllMapsToAnEmptySet() { - Authentication authentication = Authentication.build("eve", List.of(), Map.of("organization", "acme")); - - ApusPrincipal principal = PrincipalMapper.from(authentication); - - assertTrue(principal.roles().isEmpty()); - } -} 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 index b67df6b..d7c2311 100644 --- a/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java +++ b/api/src/test/java/net/onelitefeather/apus/api/events/RenderStreamControllerTest.java @@ -36,6 +36,7 @@ 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; @@ -138,7 +139,7 @@ void progressStreamDeliversTheCurrentSnapshotImmediatelyAndAgainOnEachStatusChan FakeRenderRepository repository = new FakeRenderRepository(); repository.put("bluemap-acme", "render-1", render); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); Publisher> publisher = controller.events(VIEWER, "render-1"); RecordingSubscriber subscriber = new RecordingSubscriber<>(); @@ -164,7 +165,7 @@ void progressStreamEndsAndClosesTheWatchWhenTheRenderBecomesTerminal() { FakeRenderRepository repository = new FakeRenderRepository(); repository.put("bluemap-acme", "render-1", render); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); RecordingSubscriber subscriber = new RecordingSubscriber<>(); controller.events(VIEWER, "render-1").subscribe(subscriber); @@ -183,7 +184,7 @@ void progressStreamOfAnAlreadyTerminalRenderCompletesWithoutEverWatching() { FakeRenderRepository repository = new FakeRenderRepository(); repository.put("bluemap-acme", "render-1", render); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); RecordingSubscriber subscriber = new RecordingSubscriber<>(); controller.events(VIEWER, "render-1").subscribe(subscriber); @@ -200,7 +201,7 @@ void aRenderInAForeignTenantsNamespaceIs404BeforeAnyWatchOpens() { // 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 RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); HttpStatusException e = assertThrows(HttpStatusException.class, () -> controller.events(VIEWER, "render-1")); @@ -213,7 +214,7 @@ void aRenderInAForeignTenantsNamespaceIs404BeforeAnyWatchOpens() { void aPrincipalWithNoTenantClaimIsForbiddenBeforeAnyLookupHappens() { FakeRenderRepository repository = new FakeRenderRepository(); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); Authentication noTenant = Authentication.build("root", List.of("platform-admin"), Map.of()); HttpStatusException e = @@ -230,7 +231,7 @@ void logStreamTailsTheJobAndEndsWhenTheRenderBecomesTerminal() { FakeRenderRepository repository = new FakeRenderRepository(); repository.put("bluemap-acme", "render-1", render); FakeLogSource logSource = new FakeLogSource(); - RenderStreamController controller = new RenderStreamController(repository, new TenantResolver(), logSource); + RenderStreamController controller = new RenderStreamController(repository, new TenantResolver(), logSource, new PrincipalResolver()); RecordingSubscriber subscriber = new RecordingSubscriber<>(); controller.logs(VIEWER, "render-1").subscribe(subscriber); @@ -253,7 +254,7 @@ 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); + RenderStreamController controller = new RenderStreamController(repository, new TenantResolver(), logSource, new PrincipalResolver()); HttpStatusException e = assertThrows(HttpStatusException.class, () -> controller.logs(VIEWER, "render-1")); @@ -267,7 +268,7 @@ void anUnrelatedRenderIdIs404TheSameWayAForeignTenantsIs() { // vs. "does not exist at all" as two different outcomes. FakeRenderRepository repository = new FakeRenderRepository(); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); HttpStatusException e = assertThrows(HttpStatusException.class, () -> controller.events(VIEWER, "does-not-exist")); @@ -281,7 +282,7 @@ void logStreamPropagatesAWatcherCloseErrorAsAStreamError() { FakeRenderRepository repository = new FakeRenderRepository(); repository.put("bluemap-acme", "render-1", render); RenderStreamController controller = - new RenderStreamController(repository, new TenantResolver(), new FakeLogSource()); + new RenderStreamController(repository, new TenantResolver(), new FakeLogSource(), new PrincipalResolver()); RecordingSubscriber subscriber = new RecordingSubscriber<>(); controller.events(VIEWER, "render-1").subscribe(subscriber); 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 index 1314917..cc6f1f1 100644 --- 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 @@ -23,9 +23,9 @@ import io.micronaut.security.authentication.Authentication; import java.util.List; import java.util.Map; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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 index 1653292..3031223 100644 --- 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 @@ -25,9 +25,9 @@ import java.util.List; import java.util.Map; import net.onelitefeather.apus.api.rest.support.NotFoundException; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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 index 8b838dc..19dd75d 100644 --- 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 @@ -24,9 +24,9 @@ import java.util.List; import java.util.Map; import net.onelitefeather.apus.api.rest.support.NotFoundException; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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 index a2280d7..b8a0fec 100644 --- 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 @@ -24,8 +24,8 @@ import io.micronaut.security.authentication.Authentication; import java.util.List; import net.onelitefeather.apus.api.rest.support.BadRequestException; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; 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 index f12b3a3..a6b4142 100644 --- 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 @@ -25,9 +25,9 @@ import io.micronaut.security.authentication.Authentication; import java.util.List; import java.util.Map; -import net.onelitefeather.apus.api.rest.support.PrincipalResolver; 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; diff --git a/api/src/test/java/net/onelitefeather/apus/api/rest/support/PrincipalResolverTest.java b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java similarity index 68% rename from api/src/test/java/net/onelitefeather/apus/api/rest/support/PrincipalResolverTest.java rename to api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java index 52c0478..dbe0377 100644 --- a/api/src/test/java/net/onelitefeather/apus/api/rest/support/PrincipalResolverTest.java +++ b/api/src/test/java/net/onelitefeather/apus/api/support/PrincipalResolverTest.java @@ -15,7 +15,7 @@ * 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; +package net.onelitefeather.apus.api.support; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; @@ -24,6 +24,7 @@ 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; @@ -32,6 +33,11 @@ * {@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 { @@ -46,7 +52,16 @@ void resolvesSubjectAndRolesAndTenant() { assertEquals("alice", principal.subject()); assertEquals("acme", principal.tenant()); - assertEquals(java.util.Set.of(Role.TENANT_OWNER, Role.TENANT_VIEWER), principal.roles()); + 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 @@ -56,7 +71,7 @@ void unrecognisedRoleClaimsAreDroppedNotRejected() { ApusPrincipal principal = resolver.resolve(auth); - assertEquals(java.util.Set.of(Role.TENANT_VIEWER), principal.roles()); + assertEquals(Set.of(Role.TENANT_VIEWER), principal.roles()); } @Test @@ -78,4 +93,13 @@ void nonStringTenantClaimResolvesToNullRatherThanThrowing() { 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()); + } } From cd2d0cac7a109a4f34255161459913f57d2e4498 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 04:32:16 +0200 Subject: [PATCH 6/8] docs(api): record the decision to keep SseSource over adding reactor-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3's report flagged the missing reactor-core compile dependency as an open question for whoever merged the module back together. Decision: keep the hand-rolled Publisher, do not add the dependency. reactor-core is already on the runtime classpath transitively (pulled in by micronaut-http-server-netty), so declaring it explicitly would cost nothing at runtime -- but replacing SseSource with Flux.create/Sinks would mean rewriting, by hand, reactive-streams protocol code that already works and is already covered by SseSourceTest for exactly the failure modes that matter for a class like this: cancellation, producer error, double-completion, and the reactive-streams §3.9 non-positive- request case. That is a real risk (re-introducing a concurrency bug in code that already works) for no behavioural gain, since nothing else in this module needs Reactor's operators. --- .../apus/api/events/SseSource.java | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) 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 index 30fe01b..0b59b66 100644 --- a/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java +++ b/api/src/main/java/net/onelitefeather/apus/api/events/SseSource.java @@ -27,9 +27,22 @@ * 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}, so it cannot - * be imported from this module's main source without adding an explicit dependency -- out of - * scope for this task, see the task 3 report). + * 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 From d15addb3ee691b8f11bb59138c6df3d68b4e03c9 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 04:32:36 +0200 Subject: [PATCH 7/8] test(api): add micronaut-test-junit5, HTTP security tests, k3s isolation test Closes both gaps task 2 and task 3 each independently reported as missing: every existing test called controllers/repositories 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. - Adds micronaut-test-junit5 and micronaut-http-client (both already in the version catalog's Micronaut platform, just not yet wired into api/build.gradle.kts). Forces org.testcontainers back to the project- wide pinned version, since micronaut-test-bom otherwise pulls in a newer, unverified Testcontainers major as a competing constraint. - src/test/resources/application-test.yml gives every @MicronautTest in this module a symmetric HS256 signing secret and a fixed issuer, so tokens can be minted and validated through the real Micronaut Security JWT filter chain without a reachable identity broker. - BlueMapMapControllerHttpTest: three critical HTTP-level cases against a real embedded server -- no token -> 401, valid token with no tenant role -> 403, and a foreign tenant's resource -> 404 (not 403, so the API never confirms it exists) -- plus a same-tenant sanity check. Runs under the "apitest" environment, which replaces FabricBlueMapMapRepository with an in-memory TestBlueMapMapRepository; no Docker/cluster needed for this class. - TenantIsolationIntegrationTest: phase 5a's actual proof. Two tenants (acme, globex) reconciled for real on a k3s cluster (Testcontainers, same pattern as operator's *IntegrationTest classes), a BlueMapMap in each namespace, and a real JWT for tenant acme proven unable to see (GET by id, GET list) or modify (POST .../render, and the resulting namespace checked directly) tenant globex's map -- over the real embedded server, the real security filter chain, and the real, cluster-backed Fabric8*Repository implementations, none faked. Runs under the "k3s" environment, whose K3sTestKubernetesClientFactory (test-only, @Requires(env = "k3s")) points every repository's KubernetesClient at the container instead of ambient config. - api/build.gradle.kts gains a dedicated `integrationTest` Gradle task (excluded from test/build/check, same as operator/ingest's), which depends on :operator's generateCrds task and applies its generated CRD manifests to the k3s cluster before the tenants are created. Verified: ./gradlew :api:test (105 tests, 0 failures) and ./gradlew :api:integrationTest (4 tests, 0 failures, real k3s cluster) both green; no leftover Docker containers after either run. --- api/build.gradle.kts | 66 ++++ .../api/TenantIsolationIntegrationTest.java | 289 ++++++++++++++++++ .../map/BlueMapMapControllerHttpTest.java | 154 ++++++++++ .../rest/map/TestBlueMapMapRepository.java | 71 +++++ .../K3sTestKubernetesClientFactory.java | 53 ++++ api/src/test/resources/application-test.yml | 22 ++ settings.gradle.kts | 12 + 7 files changed, 667 insertions(+) create mode 100644 api/src/test/java/net/onelitefeather/apus/api/TenantIsolationIntegrationTest.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/TestBlueMapMapRepository.java create mode 100644 api/src/test/java/net/onelitefeather/apus/api/support/K3sTestKubernetesClientFactory.java create mode 100644 api/src/test/resources/application-test.yml diff --git a/api/build.gradle.kts b/api/build.gradle.kts index 0409264..683e121 100644 --- a/api/build.gradle.kts +++ b/api/build.gradle.kts @@ -1,3 +1,9 @@ +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 } @@ -44,8 +50,68 @@ dependencies { // 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/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/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: + * + *

    + *
  • {@link #requestWithoutATokenIsUnauthorized()} -- no {@code Authorization} header at all. + *
  • {@link #requestWithAValidTokenButNoTenantRoleIsForbidden()} -- a validly signed token + * whose caller holds none of the three tenant roles. + *
  • {@link #resourceInAForeignTenantsNamespaceIs404NotForbidden()} -- a validly signed, + * sufficiently privileged token for tenant {@code acme}, for a map that exists only in + * tenant {@code globex}'s namespace -- the central design-spec §10.3 invariant: the API + * must not distinguish "forbidden" from "does not exist" for a foreign tenant's resource. + *
+ * + *

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/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/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/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/settings.gradle.kts b/settings.gradle.kts index 5d2be3a..85d086d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,11 +66,22 @@ dependencyResolutionManagement { 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") @@ -82,6 +93,7 @@ dependencyResolutionManagement { 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 From aeb5cab96d5ead1d43ea08af01ff1b300d7b8cef Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sun, 9 Aug 2026 10:58:05 +0200 Subject: [PATCH 8/8] fix: remove test credentials from the plan document and the scanner config The strings the secret scanner flagged were not only in old commits: the phase 1 plan carried them in a code sample, and .gitguardian.yaml listed them in plaintext, which made the exemption file a finding of its own. The plan sample now shows a placeholder, and the exemptions are SHA256 digests. --- .gitguardian.yaml | 46 ++++++++----------- .../plans/2026-08-08-phase-1-render-kern.md | 8 ++-- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/.gitguardian.yaml b/.gitguardian.yaml index 77e65fa..20c0e6c 100644 --- a/.gitguardian.yaml +++ b/.gitguardian.yaml @@ -1,30 +1,22 @@ -# GitGuardian configuration for this repository. -# Schema reference: https://docs.gitguardian.com/ggshield-docs/reference/secret/ignore -# (config file format defined by ggshield, the engine GitGuardian's scanning uses) version: 2 secret: - # These two entries silence the "2 secrets uncovered" finding GitGuardian raised against the - # commit history introduced in PR #1 (feat/phase-1-render-kern) and inherited by every PR - # stacked on top of it (#2-#5). - # - # Why they are safe to ignore: - # - Both values are login/password pairs for a MinIO container started by Testcontainers in - # runner/src/test/java/net/onelitefeather/apus/runner/MinioFixtures.java. The container is - # created and destroyed within a single integration test run; nothing outside that test - # process ever talks to it. - # - They were never used against any real MinIO deployment, staging or production system, or - # any service reachable outside the throwaway test container. - # - The root cause is already fixed: MinioFixtures now generates a fresh, random access - # key/secret key on every test run instead of using these fixed literals (see that file's - # ACCESS_KEY/SECRET_KEY fields). These two entries only silence the two now-historical - # occurrences that remain in already-published commits on the stacked PR branches; rewriting - # that history for two harmless test values would be disproportionate. - # - # Scoped to exactly these two literal values -- not a path or file exclusion -- so nothing else - # in this repository is exempted from scanning. - ignored_matches: - - name: MinIO test-container access key (runner integration tests, throwaway container) - match: apustest - - name: MinIO test-container secret key (runner integration tests, throwaway container) - match: apustestsecret + ignored-matches: + # Two MinIO credentials that the phase 1 integration tests used to hard-code. + # They were never real: both were handed to a throwaway Testcontainers MinIO that + # lives for the duration of one test run, and they existed nowhere else. + # + # The cause is fixed — every container test now generates its credentials per run + # from SecureRandom (see runner/src/test/.../MinioFixtures.java and the equivalents + # in the ingest and api modules). These entries exist only because the old literals + # remain in already-published commits, which cannot be rewritten here. + # + # Listed as SHA256 rather than plaintext, so this file does not itself contain the + # strings it exempts. + # + # Do not extend this list to silence new findings. A finding in new code means the + # code is wrong, not the scanner. + - name: "phase 1 MinIO test access key (throwaway container, cause fixed)" + match: bfd5d64da90af877034e91f582242391fea586e6d187a475a3407bf37bd6f422 + - name: "phase 1 MinIO test secret key (throwaway container, cause fixed)" + match: 9c721c67b04a5ff4622f5796d44a042c328d5453795ac798e14c7a7a31125bdd diff --git a/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md b/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md index d182bd3..aa02893 100644 --- a/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md +++ b/docs/superpowers/plans/2026-08-08-phase-1-render-kern.md @@ -2218,8 +2218,8 @@ import org.testcontainers.utility.DockerImageName; */ class RenderEndToEndTest { - private static final String ACCESS_KEY = "apustest"; - private static final String SECRET_KEY = "apustestsecret"; + private static final String ACCESS_KEY = ""; + private static final String SECRET_KEY = ""; private static final String WORLD_BUCKET = "bundles"; private static final String MAP_BUCKET = "maps"; @@ -2399,8 +2399,8 @@ import org.testcontainers.utility.DockerImageName; */ class TelemetryContractTest { - private static final String ACCESS_KEY = "apustest"; - private static final String SECRET_KEY = "apustestsecret"; + private static final String ACCESS_KEY = ""; + private static final String SECRET_KEY = ""; private static Path fixture() { return Path.of(System.getProperty("user.dir")).getParent().resolve("testdata/mini-world");