Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 19 additions & 27 deletions .gitguardian.yaml
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,15 @@ build/
.idea/
*.iml
runner/vendor/

# ui/ (Nuxt) -- not a Gradle module, see ui/README.md. Node dependencies and build/test
# output never belong in the repository.
ui/node_modules/
ui/.nuxt/
ui/.output/
ui/dist/
ui/coverage/
ui/.env
# Local marker @nuxt/test-utils writes recording which version last ran -- see
# vitest.nuxt.config.ts/tests/nuxt/ (added for the layout render regression test).
ui/.nuxtrc
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@
import io.micronaut.security.authentication.Authentication;
import io.micronaut.security.rules.SecurityRule;
import java.util.List;
import java.util.stream.Stream;
import net.onelitefeather.apus.api.rest.support.NotFoundException;
import net.onelitefeather.apus.api.rest.support.TenantAccess;
import net.onelitefeather.apus.api.rest.tenant.TenantRepository;
import net.onelitefeather.apus.api.security.ApusPrincipal;
import net.onelitefeather.apus.api.security.ForbiddenException;
import net.onelitefeather.apus.api.security.TenantResolver;
Expand All @@ -37,6 +39,16 @@
* 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.
*
* <p>{@code GET /api/renders/cluster} is the one deliberate exception to "the caller's own
* tenant only": {@code platform-admin}'s cluster-wide view (design spec §10.3). It is a
* literal route, checked before the {@code /{id}} route can match it, and its own method
* ({@link #listCluster}) does not go through {@link TenantResolver} at all -- same reasoning as
* {@code TenantController} not going through it (see that class's Javadoc): a platform-admin is
* not necessarily a member of any tenant, so resolving *a* namespace for it would be wrong even
* if one happened to exist. This is the only method on this controller allowed to see more than
* one tenant's resources; everything else keeps the invariant that the tenant comes from the
* token and the token alone.
*/
@Controller("/api/renders")
@Secured(SecurityRule.IS_AUTHENTICATED)
Expand All @@ -45,12 +57,17 @@ public class BlueMapRenderController {
private final BlueMapRenderRepository repository;
private final PrincipalResolver principalResolver;
private final TenantResolver tenantResolver;
private final TenantRepository tenantRepository;

public BlueMapRenderController(
BlueMapRenderRepository repository, PrincipalResolver principalResolver, TenantResolver tenantResolver) {
BlueMapRenderRepository repository,
PrincipalResolver principalResolver,
TenantResolver tenantResolver,
TenantRepository tenantRepository) {
this.repository = repository;
this.principalResolver = principalResolver;
this.tenantResolver = tenantResolver;
this.tenantRepository = tenantRepository;
}

@Get
Expand All @@ -65,6 +82,37 @@ public HttpResponse<List<BlueMapRenderResponse>> list(Authentication authenticat
return HttpResponse.ok(renders);
}

/**
* The cluster-wide view (design spec §10.3, §11.2: "laufende Jobs clusterweit"),
* {@code platform-admin} only. Walks every {@code Tenant} the platform-admin has cluster-wide
* reach to (via {@link TenantRepository}, exactly like {@code TenantController} does), and
* for each one lists renders in that tenant's own namespace -- the same {@link
* BlueMapRenderRepository#list(String)} every tenant-scoped call already uses, just invoked
* once per tenant instead of once for the caller's own. A tenant with no namespace recorded
* yet in its status (freshly created, not yet reconciled) is skipped rather than failing the
* whole call.
*/
@Get("/cluster")
public HttpResponse<List<ClusterRenderResponse>> listCluster(Authentication authentication) {
ApusPrincipal principal = principalResolver.resolve(authentication);
if (!principal.isPlatformAdmin()) {
throw new ForbiddenException("principal '" + principal.subject() + "' is not a platform-admin");
}

List<ClusterRenderResponse> renders = tenantRepository.list().stream()
.flatMap(tenant -> {
String namespace = tenant.getStatus().getNamespace();
if (namespace == null || namespace.isBlank()) {
return Stream.<ClusterRenderResponse>empty();
}
String tenantName = tenant.getMetadata().getName();
return repository.list(namespace).stream()
.map(render -> ClusterRenderResponse.from(tenantName, render));
})
.toList();
return HttpResponse.ok(renders);
}

@Get("/{id}")
public HttpResponse<BlueMapRenderResponse> getById(Authentication authentication, @PathVariable String id) {
ApusPrincipal principal = principalResolver.resolve(authentication);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* 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.
* <p>
* 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.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.rest.render;

import io.micronaut.serde.annotation.Serdeable;
import net.onelitefeather.apus.operator.api.BlueMapRender;

/**
* One render, as {@code GET /api/renders/cluster} exposes it -- the {@code platform-admin}-only
* cluster-wide view (design spec §10.3: "clusterweite Sicht"). Wraps the ordinary {@link
* BlueMapRenderResponse} rather than duplicating its fields, and adds exactly the one thing a
* single tenant's own {@code GET /api/renders} does not need to say about itself: which tenant
* this render belongs to. {@code tenant} is the {@code Tenant} custom resource's own {@code
* metadata.name} -- resolved by {@link BlueMapRenderController#listCluster} from {@code
* TenantRepository}, never guessed back out of a namespace string (that reverse mapping belongs
* to no one; see {@code TenantResolver}'s Javadoc on why it has exactly one public method).
*/
@Serdeable
public record ClusterRenderResponse(String tenant, BlueMapRenderResponse render) {

public static ClusterRenderResponse from(String tenant, BlueMapRender render) {
return new ClusterRenderResponse(tenant, BlueMapRenderResponse.from(render));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,9 @@ public Optional<Tenant> findByName(String name) {
public Tenant create(Tenant tenant) {
return client.resource(tenant).create();
}

@Override
public Tenant update(Tenant tenant) {
return client.resource(tenant).update();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,22 +21,26 @@
import io.micronaut.http.annotation.Body;
import io.micronaut.http.annotation.Controller;
import io.micronaut.http.annotation.Get;
import io.micronaut.http.annotation.Patch;
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.support.BadRequestException;
import net.onelitefeather.apus.api.rest.support.NotFoundException;
import net.onelitefeather.apus.api.security.ApusPrincipal;
import net.onelitefeather.apus.api.security.ForbiddenException;
import net.onelitefeather.apus.api.support.PrincipalResolver;
import net.onelitefeather.apus.operator.api.Tenant;
import net.onelitefeather.apus.operator.api.TenantSpec;

/**
* {@code GET /api/tenants} and {@code POST /api/tenants} -- platform-level, {@code
* platform-admin} only (design spec §10.3, §11.1). Unlike every other controller in {@code
* rest/}, this one never calls {@code TenantResolver}: {@code Tenant} is cluster-scoped, and a
* {@code GET /api/tenants}, {@code POST /api/tenants}, and {@code PATCH /api/tenants/{name}} --
* 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.
Expand Down Expand Up @@ -96,6 +100,34 @@ public HttpResponse<TenantResponse> create(Authentication authentication, @Body
return HttpResponse.created(TenantResponse.from(created));
}

/**
* Changes an existing tenant's storage quota and/or allowed hosting domains (design spec
* §10.3: {@code platform-admin} may "Tenants anlegen/ändern/löschen, Quotas"). {@code name}
* comes from the path, exactly like every other tenant-identifying value in this module --
* never re-derived from the body. Closes the gap the platform dashboard flagged: before this,
* a quota was only settable at {@link #create}-time.
*/
@Patch("/{name}")
public HttpResponse<TenantResponse> update(
Authentication authentication, @PathVariable String name, @Body UpdateTenantRequest request) {
requirePlatformAdmin(authentication);
Tenant tenant = repository.findByName(name).orElseThrow(() -> new NotFoundException("no tenant '" + name + "'"));

TenantSpec spec = tenant.getSpec();
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 updated = repository.update(tenant);
return HttpResponse.ok(TenantResponse.from(updated));
}

private ApusPrincipal requirePlatformAdmin(Authentication authentication) {
ApusPrincipal principal = principalResolver.resolve(authentication);
if (!principal.isPlatformAdmin()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,12 @@ public interface TenantRepository {
Optional<Tenant> findByName(String name);

Tenant create(Tenant tenant);

/**
* Persists changes to an already-existing {@link Tenant} (design spec §10.3: {@code
* platform-admin} may "Tenants anlegen/ändern/löschen, Quotas"). {@code tenant} must be one
* previously returned by {@link #findByName(String)} (or {@link #list()}) with its fields
* mutated -- this method does not create a new resource if the name does not already exist.
*/
Tenant update(Tenant tenant);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Apus - render and host BlueMap maps on Kubernetes.
* Copyright (C) 2026 OneLiteFeather and contributors
* <p>
* 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.
* <p>
* 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.
* <p>
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.apus.api.rest.tenant;

import io.micronaut.serde.annotation.Serdeable;
import java.util.List;

/**
* Request body for {@code PATCH /api/tenants/{name}} -- the only way to change quota or allowed
* hosting domains on a tenant after creation (design spec §10.3: {@code platform-admin} may
* "Tenants anlegen/ändern/löschen, Quotas"). {@code name} is deliberately not repeated here, nor
* is it ever taken from anywhere but the path -- see {@code TenantController#update}.
*
* <p>Partial-update semantics, same as {@link CreateTenantRequest}: a {@code null} field leaves
* the current value untouched rather than clearing it, so a caller changing only the storage
* quota does not have to first re-read and resend the current allowed domains. There is
* deliberately no way to change {@code displayName} here -- out of this endpoint's stated scope
* (design spec §10.3: quota and domains only).
*/
@Serdeable
public record UpdateTenantRequest(String storageQuota, Long maxObjects, List<String> allowedHostingDomains) {}
Loading