Skip to content

feat: Phase 5a — REST and SSE API with tenant isolation - #6

Closed
TheMeinerLP wants to merge 11 commits into
feat/phase-4-shardingfrom
feat/phase-5-api
Closed

feat: Phase 5a — REST and SSE API with tenant isolation#6
TheMeinerLP wants to merge 11 commits into
feat/phase-4-shardingfrom
feat/phase-5-api

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

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: BlueMapMapControllerHttpTest with fakes, and TenantIsolationIntegrationTest against a real k3s cluster with a real JWT and a real resource belonging to tenant B.

What this delivers

  • JWT validation against a configurable issuer; roles platform-admin, tenant-owner, tenant-operator, tenant-viewer per spec §10.3
  • REST endpoints for tenants, sources, maps, renders and hostings
  • SSE streams for live render progress (watching the resource, not polling) and for logs
  • Response models are dedicated types, never pass-through custom resources — finalizers, resourceVersion and 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_URL is configured, direct pod logs otherwise. The Loki path needs no pod RBAC at all, which is what spec §11.1 intended; the fallback needs get/list on 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

  • HTTP-level security tests cover one controller; the other four rely on direct-call tests for the same logic.
  • The k3s isolation test duplicates ~30 lines of CRD setup from the operator module — no cross-module test fixtures exist yet.
  • reactor-core was deliberately not added; the hand-rolled SseSource is kept and covered for abort, error and cancel.

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.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Closing in favor of #14: same content, rebuilt as a single squash commit on a new clean/* branch because a secret scanner flagged disposable test credentials in this PR's commit history and history cannot be rewritten in this environment. See #14 for detail.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant