feat: Phase 5a — REST and SSE API with tenant isolation - #6
Closed
TheMeinerLP wants to merge 11 commits into
Closed
Conversation
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.
…s, 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.
Taken from the parallel task's worktree, which produced the files but did not commit them.
…idges 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.
…core 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.
…ion 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.
…onfig 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.
This was referenced Aug 9, 2026
Contributor
Author
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 5a of Apus: the API that makes the platform usable without writing YAML. Stacked on PR #5.
Custom resources stay the source of truth — the API holds no copy of the state. It is, however, the enforcement point for authorization: it checks the caller's rights first and only then talks to the Kubernetes API through its own ServiceAccount. No impersonation.
The rule this phase is built around
The tenant is derived only from the validated token. No endpoint accepts a tenant, tenant id or namespace as a parameter. A single endpoint taking the namespace from the request would reopen every isolation hole closed in phases 2a and 3 — namespace adoption, hostname hijacking, foreign maps in a hosting.
The second rule follows from the first: a resource that does not exist in the caller's own namespace returns 404, even when it exists in another tenant. A 403 would confirm its existence and turn the API into a directory of other tenants' resources.
Both are now proven over real HTTP, not just against classes:
BlueMapMapControllerHttpTestwith fakes, andTenantIsolationIntegrationTestagainst a real k3s cluster with a real JWT and a real resource belonging to tenant B.What this delivers
platform-admin,tenant-owner,tenant-operator,tenant-viewerper spec §10.3resourceVersionand managed fields are nobody's business outside, and a CRD change must not silently alter the public interface. Secret names are excluded from every response.Log source: Loki when
APUS_LOKI_URLis configured, direct pod logs otherwise. The Loki path needs no pod RBAC at all, which is what spec §11.1 intended; the fallback needsget/liston pods. Documented as a deployment-time decision.What parallel work cost, and what it caught
REST endpoints and event streams were built simultaneously in separate worktrees. Both agents independently built a Kubernetes client factory and a token-to-principal bridge. The second one mattered: two bridges reading different claim names would make the API behave differently per endpoint, and a bug in one would only surface half the time. Both are now merged into a single tested place, with the claim name (
organization) declared exactly once.One agent avoided a bean collision preemptively by wrapping the client type — a conflict that isolated work usually only reveals at merge time.
Known gaps
reactor-corewas deliberately not added; the hand-rolledSseSourceis kept and covered for abort, error and cancel.